mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
feat(weather): add globe wind, observed weather and cyclone context (#617)
Wind (NOAA GFS and ECMWF IFS: animated particles, colour field, a reading at map centre), observed weather (NOAA MRMS radar, GOES and NESDIS infrared clouds, lightning density) on one shared history timeline, NHC and CPHC cyclone advisories with tracks and cones, and a WEATHER panel in the right rail. On 3D Tiles the observed products render as raised shells with a sharper window around the view. Co-authored-by: Gustavo Beneduzi <[email protected]> Co-authored-by: Daniel Slay <[email protected]> Co-authored-by: Rehaan Delmotra <[email protected]> Co-authored-by: Cursor Agent <[email protected]>
This commit is contained in:
180 files changed
+30645
-434
No files matched your search
@@ -383,6 +383,10 @@
|
||||
"src/layers/launches/model.js",
|
||||
"src/layers/launches/selection.js",
|
||||
"src/layers/launches/camera.js",
|
||||
"src/data/localGeojson.analyst.test.mjs",
|
||||
"src/data/satellites.analyst.test.mjs",
|
||||
"src/data/layerSnapshot.test.mjs",
|
||||
"src/data/satellitePass.test.mjs",
|
||||
"src/data/satellites.js",
|
||||
"src/data/rocketLaunches.js",
|
||||
"src/layers/satellites/source.test.mjs",
|
||||
@@ -730,6 +734,27 @@
|
||||
"scripts/format-runtime.json",
|
||||
"src/tooling/transitService.test.mjs",
|
||||
"src/sources/overpassFeatures.test.mjs",
|
||||
"src/data/cctvHlsStream.test.mjs",
|
||||
"src/layers/cctv/videoPlayback.test.mjs",
|
||||
"src/ui/cctvVideo.test.mjs",
|
||||
"src/ui/styles/weather.css",
|
||||
"src/layers/weather/index.js",
|
||||
"src/layers/weather/clock.js",
|
||||
"src/layers/weather/clock.test.mjs",
|
||||
"src/layers/weather/rendering.js",
|
||||
"src/layers/weather/shellRendering.js",
|
||||
"src/layers/weather/shellRendering.test.mjs",
|
||||
"src/layers/weather/infraredAlpha.js",
|
||||
"src/layers/weather/infraredAlpha.test.mjs",
|
||||
"src/layers/weather/infraredImage.js",
|
||||
"src/layers/weather/infraredImage.test.mjs",
|
||||
"src/layers/weather/source.js",
|
||||
"src/layers/weather/weather.test.mjs",
|
||||
"src/layers/weather/rasterTiles.test.mjs",
|
||||
"src/layers/wind/rendering.test.mjs",
|
||||
"src/layers/weather/source.test.mjs",
|
||||
"src/data/weatherProxy.test.mjs",
|
||||
"server/providers/weather.js",
|
||||
"src/director/clock.test.mjs",
|
||||
"src/director/timeline.test.mjs",
|
||||
"src/scenes/packs/registry.test.mjs",
|
||||
@@ -745,5 +770,28 @@
|
||||
"scripts/qa-director-interactions.mjs",
|
||||
"src/director/sharing/sharing.test.mjs",
|
||||
"scripts/qa-director-sharing.mjs",
|
||||
"src/data/aisStreamSentinels.test.mjs"
|
||||
"src/ui/weatherPanel.js",
|
||||
"src/ui/weatherPanel.test.mjs",
|
||||
"src/ui/railCards.js",
|
||||
"src/ui/railCards.test.mjs",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/railTimeline.test.mjs",
|
||||
"src/layers/weather/imageryOrder.js",
|
||||
"src/layers/weather/imageryOrder.test.mjs",
|
||||
"server/providers/cyclones.js",
|
||||
"src/data/cyclonesProxy.test.mjs",
|
||||
"src/layers/cyclones/index.js",
|
||||
"src/layers/cyclones/rendering.js",
|
||||
"src/layers/cyclones/labels.js",
|
||||
"src/layers/cyclones/source.js",
|
||||
"src/layers/cyclones/rendering.test.mjs",
|
||||
"src/layers/cyclones/labels.test.mjs",
|
||||
"src/layers/cyclones/index.test.mjs",
|
||||
"src/layers/cyclones/source.test.mjs",
|
||||
"src/data/aisStreamSentinels.test.mjs",
|
||||
"scripts/qa-weather-perf.mjs",
|
||||
"scripts/qa-weather-journey.mjs",
|
||||
"scripts/qa-weather-swap.mjs",
|
||||
"scripts/qa-weather-teardown.mjs",
|
||||
"src/layers/weather/imageryHost.test.mjs"
|
||||
]
|
||||
@@ -161,6 +161,7 @@
|
||||
"server/providers/cctv/normalize.js",
|
||||
"server/providers/cctv/range.js",
|
||||
"server/providers/cctv/sources.js",
|
||||
"server/providers/cctv/stream.js",
|
||||
"server/providers/common/http.js",
|
||||
"src/data/directionText.js",
|
||||
"server/providers/common/geo.js",
|
||||
@@ -268,6 +269,10 @@
|
||||
"server/providers/openai/realtime.js",
|
||||
"server/providers/openai/tools.js",
|
||||
"src/hudSummaryResponse.js",
|
||||
"src/data/layerSnapshot.js",
|
||||
"src/data/feedState.js",
|
||||
"src/loadingFeedback.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/voice/voiceCost.js",
|
||||
"src/voice/actionSchemas.js",
|
||||
"server/providers/openai/toolDescriptions.js",
|
||||
@@ -375,11 +380,18 @@
|
||||
"modules": [
|
||||
"src/ui/layers.js",
|
||||
"src/ui/layerPanel.js",
|
||||
"src/ui/weatherPanel.js",
|
||||
"src/ui/railCards.js",
|
||||
"src/ui/railCardBlocks.js",
|
||||
"src/ui/chipGroup.js",
|
||||
"src/ui/rowList.js",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/clearLayersControl.js",
|
||||
"src/loadingFeedback.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/keySetupCore.mjs",
|
||||
"src/data/feedState.js"
|
||||
"src/data/feedState.js",
|
||||
"src/data/layerSnapshot.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -413,6 +425,7 @@
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
"src/ui/cctvPresentation.js"
|
||||
],
|
||||
"external": []
|
||||
@@ -513,6 +526,7 @@
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
"src/ui/cctvPresentation.js",
|
||||
"src/ui/clearLayersControl.js",
|
||||
"src/ui/cockpit.js",
|
||||
@@ -540,6 +554,12 @@
|
||||
"src/ui/effects.js",
|
||||
"src/ui/frameRateMonitor.js",
|
||||
"src/ui/layerPanel.js",
|
||||
"src/ui/weatherPanel.js",
|
||||
"src/ui/railCards.js",
|
||||
"src/ui/railCardBlocks.js",
|
||||
"src/ui/chipGroup.js",
|
||||
"src/ui/rowList.js",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/layers.js",
|
||||
"src/ui/leftPanelRail.js",
|
||||
"src/ui/location.js",
|
||||
@@ -577,7 +597,9 @@
|
||||
"src/ui/visualSettings.js",
|
||||
"src/worldFocus.js",
|
||||
"src/data/feedState.js",
|
||||
"src/data/layerSnapshot.js",
|
||||
"src/ui/aircraftDisplay.js",
|
||||
"src/layers/weather/imageryHost.js",
|
||||
"src/ui/layerBindings.js",
|
||||
"src/ui/displayBindings.js",
|
||||
"src/ui/shellFacade.js"
|
||||
@@ -608,7 +630,8 @@
|
||||
"src/ui/styles/command-dock-sliding.css",
|
||||
"src/ui/styles/voice-cost.css",
|
||||
"src/ui/styles/first-run.css",
|
||||
"src/ui/styles/provider-settings.css"
|
||||
"src/ui/styles/provider-settings.css",
|
||||
"src/ui/styles/weather.css"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -856,6 +879,7 @@
|
||||
"modules": [
|
||||
"src/data/issPass.js",
|
||||
"src/data/satelliteClass.js",
|
||||
"src/data/satellitePass.js",
|
||||
"src/layers/satellites/catalog.js",
|
||||
"src/layers/satellites/controls.js",
|
||||
"src/layers/satellites/index.js",
|
||||
@@ -864,6 +888,7 @@
|
||||
"src/layers/satellites/labels.js",
|
||||
"src/layers/satellites/lifecycle.js",
|
||||
"src/layers/satellites/orbits.js",
|
||||
"src/layers/satellites/records.js",
|
||||
"src/layers/satellites/policy.js",
|
||||
"src/layers/satellites/rendering.js",
|
||||
"src/layers/satellites/source.js",
|
||||
@@ -1025,6 +1050,7 @@
|
||||
"src/layers/cctv/policy.js",
|
||||
"src/layers/cctv/presentation.js",
|
||||
"src/layers/cctv/projection.js",
|
||||
"src/layers/cctv/videoPlayback.js",
|
||||
"src/layers/cctv/rendering.js",
|
||||
"src/layers/cctv/selection.js",
|
||||
"src/layers/cctv/source.js",
|
||||
@@ -1035,7 +1061,7 @@
|
||||
"src/data/inputOwnership.js",
|
||||
"src/layers/cctv/sourcePolicy.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": ["hls.js", "cesium"]
|
||||
},
|
||||
"alpr-cameras": {
|
||||
"exports": ["./layers/alpr"],
|
||||
@@ -1279,6 +1305,19 @@
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/contextStore.js",
|
||||
"src/data/dataCredits.js",
|
||||
"src/data/wind.js",
|
||||
"src/layers/wind/index.js",
|
||||
"src/layers/wind/model.js",
|
||||
"src/layers/wind/fields.js",
|
||||
"src/layers/wind/streamlines.js",
|
||||
"src/layers/wind/gpuRendering.js",
|
||||
"src/layers/wind/relief.js",
|
||||
"src/layers/wind/inspection.js",
|
||||
"src/layers/wind/presentation.js",
|
||||
"src/layers/wind/rendering.js",
|
||||
"src/layers/weather/rasterTiles.js",
|
||||
"src/layers/wind/source.js",
|
||||
"src/sources/windBody.js",
|
||||
"src/data/detection.js",
|
||||
"src/data/detectionCohort.js",
|
||||
"src/data/detectionDraw.js",
|
||||
@@ -1332,6 +1371,7 @@
|
||||
"src/data/rocketLaunches.js",
|
||||
"src/data/routePlausible.js",
|
||||
"src/data/satelliteClass.js",
|
||||
"src/data/satellitePass.js",
|
||||
"src/data/satellites.js",
|
||||
"src/data/scenePick.js",
|
||||
"src/data/spriteOrder.js",
|
||||
@@ -1414,6 +1454,7 @@
|
||||
"src/layers/cctv/policy.js",
|
||||
"src/layers/cctv/presentation.js",
|
||||
"src/layers/cctv/projection.js",
|
||||
"src/layers/cctv/videoPlayback.js",
|
||||
"src/layers/cctv/rendering.js",
|
||||
"src/layers/cctv/selection.js",
|
||||
"src/layers/cctv/source.js",
|
||||
@@ -1519,6 +1560,7 @@
|
||||
"src/layers/satellites/labels.js",
|
||||
"src/layers/satellites/lifecycle.js",
|
||||
"src/layers/satellites/orbits.js",
|
||||
"src/layers/satellites/records.js",
|
||||
"src/layers/satellites/policy.js",
|
||||
"src/layers/satellites/rendering.js",
|
||||
"src/layers/satellites/source.js",
|
||||
@@ -1629,6 +1671,7 @@
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
"src/ui/cctvPresentation.js",
|
||||
"src/ui/clearLayersControl.js",
|
||||
"src/ui/cockpit.js",
|
||||
@@ -1657,6 +1700,12 @@
|
||||
"src/ui/effects.js",
|
||||
"src/ui/frameRateMonitor.js",
|
||||
"src/ui/layerPanel.js",
|
||||
"src/ui/weatherPanel.js",
|
||||
"src/ui/railCards.js",
|
||||
"src/ui/railCardBlocks.js",
|
||||
"src/ui/chipGroup.js",
|
||||
"src/ui/rowList.js",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/layers.js",
|
||||
"src/ui/leftPanelRail.js",
|
||||
"src/ui/location.js",
|
||||
@@ -1727,6 +1776,7 @@
|
||||
"src/voice/realtimeEvents.js",
|
||||
"src/app/localGeojsonServices.js",
|
||||
"src/data/feedState.js",
|
||||
"src/data/layerSnapshot.js",
|
||||
"src/layers/alpr/records.js",
|
||||
"src/layers/earthquakes/records.js",
|
||||
"src/layers/cctv/sourcePolicy.js",
|
||||
@@ -1782,6 +1832,13 @@
|
||||
"src/voice/realtimeRadio.js",
|
||||
"src/voice/realtimeTurns.js",
|
||||
"src/voice/realtimeConnection.js",
|
||||
"src/layers/weather/index.js",
|
||||
"src/layers/weather/clock.js",
|
||||
"src/layers/weather/rendering.js",
|
||||
"src/layers/weather/shellRendering.js",
|
||||
"src/layers/weather/infraredAlpha.js",
|
||||
"src/layers/weather/infraredImage.js",
|
||||
"src/layers/weather/source.js",
|
||||
"src/director/clock.js",
|
||||
"src/director/timeline.js",
|
||||
"src/scenes/shotPresentation.js",
|
||||
@@ -1809,9 +1866,16 @@
|
||||
"src/director/sharing/lifetime.js",
|
||||
"src/director/authoring.js",
|
||||
"src/scenes/sharing.js",
|
||||
"src/ui/sceneSharing.js"
|
||||
"src/ui/sceneSharing.js",
|
||||
"src/layers/weather/imageryOrder.js",
|
||||
"src/layers/weather/imageryHost.js",
|
||||
"src/layers/cyclones/index.js",
|
||||
"src/layers/cyclones/rendering.js",
|
||||
"src/layers/cyclones/labels.js",
|
||||
"src/layers/cyclones/source.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
"@mapbox/vector-tile",
|
||||
"cesium",
|
||||
"egm96-universal",
|
||||
@@ -1838,6 +1902,16 @@
|
||||
"src/annotations/resolver.js",
|
||||
"src/app/catalog.js",
|
||||
"src/app/constructCatalog.js",
|
||||
"src/layers/wind/index.js",
|
||||
"src/layers/wind/model.js",
|
||||
"src/layers/wind/fields.js",
|
||||
"src/layers/wind/streamlines.js",
|
||||
"src/layers/wind/gpuRendering.js",
|
||||
"src/layers/wind/relief.js",
|
||||
"src/layers/wind/inspection.js",
|
||||
"src/layers/wind/presentation.js",
|
||||
"src/layers/wind/rendering.js",
|
||||
"src/layers/weather/rasterTiles.js",
|
||||
"src/app/layers/aisLiveVessels.js",
|
||||
"src/app/layers/alprCameras.js",
|
||||
"src/app/layers/bikeshare.js",
|
||||
@@ -1885,6 +1959,7 @@
|
||||
"src/data/infrastructure.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/data/issPass.js",
|
||||
"src/data/satellitePass.js",
|
||||
"src/data/labelArbiter.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/localGeojson.js",
|
||||
@@ -1976,6 +2051,7 @@
|
||||
"src/layers/cctv/policy.js",
|
||||
"src/layers/cctv/presentation.js",
|
||||
"src/layers/cctv/projection.js",
|
||||
"src/layers/cctv/videoPlayback.js",
|
||||
"src/layers/cctv/rendering.js",
|
||||
"src/layers/cctv/selection.js",
|
||||
"src/layers/cctv/source.js",
|
||||
@@ -2081,6 +2157,7 @@
|
||||
"src/layers/satellites/labels.js",
|
||||
"src/layers/satellites/lifecycle.js",
|
||||
"src/layers/satellites/orbits.js",
|
||||
"src/layers/satellites/records.js",
|
||||
"src/layers/satellites/policy.js",
|
||||
"src/layers/satellites/rendering.js",
|
||||
"src/layers/satellites/source.js",
|
||||
@@ -2203,9 +2280,23 @@
|
||||
"src/sources/overpassFeaturesRecords.js",
|
||||
"src/sources/overpassFeatures.js",
|
||||
"src/sources/featureSource.js",
|
||||
"src/sources/overpassRoads.js"
|
||||
"src/sources/overpassRoads.js",
|
||||
"src/layers/weather/index.js",
|
||||
"src/layers/weather/clock.js",
|
||||
"src/layers/weather/rendering.js",
|
||||
"src/layers/weather/shellRendering.js",
|
||||
"src/layers/weather/infraredAlpha.js",
|
||||
"src/layers/weather/infraredImage.js",
|
||||
"src/layers/weather/source.js",
|
||||
"src/layers/weather/imageryOrder.js",
|
||||
"src/layers/weather/imageryHost.js",
|
||||
"src/layers/cyclones/index.js",
|
||||
"src/layers/cyclones/rendering.js",
|
||||
"src/layers/cyclones/labels.js",
|
||||
"src/layers/cyclones/source.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
"cesium",
|
||||
"egm96-universal",
|
||||
"satellite.js",
|
||||
|
||||
+15
-11
@@ -23,13 +23,17 @@ try {
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.dataManager?._layerPanel,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
|
||||
timeout: 60000,
|
||||
});
|
||||
const results = await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
const container = manager._toggleContainer;
|
||||
const entry = document.querySelector(
|
||||
'script[type="module"][src*="/src/main.js"]',
|
||||
);
|
||||
const { application } = await import(entry.src);
|
||||
const { presentation } = application.getComponents().data;
|
||||
const container = presentation.panel._toggleContainer;
|
||||
const id = 'qa-panel-lifecycle';
|
||||
let listener = null;
|
||||
let enabled = 0;
|
||||
@@ -54,7 +58,7 @@ try {
|
||||
});
|
||||
const result = [];
|
||||
try {
|
||||
manager.buildTogglePanel(container);
|
||||
presentation.mount(container);
|
||||
const row = () => container.querySelector(`[data-layer-id="${id}"]`);
|
||||
const first = row().querySelector('.data-toggle-btn');
|
||||
result.push([
|
||||
@@ -66,7 +70,7 @@ try {
|
||||
'row subscription is installed',
|
||||
typeof listener === 'function',
|
||||
]);
|
||||
manager.buildTogglePanel(container);
|
||||
presentation.mount(container);
|
||||
first.click();
|
||||
await Promise.resolve();
|
||||
result.push([
|
||||
@@ -97,7 +101,7 @@ try {
|
||||
]);
|
||||
await manager.setEnabled(id, true);
|
||||
const final = row().querySelector('.data-toggle-btn');
|
||||
manager._layerPanel.destroy();
|
||||
presentation._panel.destroy();
|
||||
final.click();
|
||||
await Promise.resolve();
|
||||
result.push([
|
||||
@@ -109,10 +113,10 @@ try {
|
||||
manager.isEnabled(id),
|
||||
]);
|
||||
} finally {
|
||||
manager._layerPanel?.destroy();
|
||||
manager._layerPanel = null;
|
||||
presentation._panel?.destroy();
|
||||
presentation._panel = null;
|
||||
await window.__gevQaUnregisterLayer(manager, id);
|
||||
manager.buildTogglePanel(container);
|
||||
presentation.mount(container);
|
||||
}
|
||||
result.push([
|
||||
'ordinary layer rows return after reassembly',
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-weather-journey — drive the Weather layers the way a person would and
|
||||
* keep the evidence: numbered screenshots plus per-step layer diagnostics.
|
||||
*
|
||||
* Starts at the Earth view, opens Data Layers, switches Wind on from its real
|
||||
* row button, cycles the wind fields/models, switches radar, satellite clouds,
|
||||
* lightning and cyclones on, follows "View storm" and "View US radar", zooms
|
||||
* into a regional view, a city view and a street view, replays history, and
|
||||
* ends with real wheel/drag input. Every step records the enabled layers'
|
||||
* diagnostics (imagery tiles loaded, shown observation time, wind geometry)
|
||||
* and a 2 s `scene.postRender` count so a stalled renderer cannot pass as a
|
||||
* healthy screenshot.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/qa-weather-journey.mjs [--url http://localhost:4173]
|
||||
* [--label google] [--headless] [--out qa-shots/weather-journey]
|
||||
*
|
||||
* Requires a running dev server. Screenshots and steps.json land in
|
||||
* <out>/<label>/.
|
||||
*/
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const arg = (name, fallback) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')
|
||||
? argv[i + 1]
|
||||
: fallback;
|
||||
};
|
||||
const flag = (name) => argv.includes(name);
|
||||
const rawUrl = arg('--url', 'http://localhost:4173');
|
||||
const url = rawUrl.includes('welcome=')
|
||||
? rawUrl
|
||||
: `${rawUrl}${rawUrl.includes('?') ? '&' : '?'}welcome=0`;
|
||||
const label = arg('--label', 'journey');
|
||||
const outDir = path.resolve(arg('--out', 'qa-shots/weather-journey'), label);
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const WEATHER = ['weather-radar', 'weather-satellite', 'weather-lightning'];
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: flag('--headless') ? 'new' : false,
|
||||
executablePath:
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||
['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'].find((p) =>
|
||||
existsSync(p),
|
||||
),
|
||||
protocolTimeout: 300_000,
|
||||
defaultViewport: null,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--window-size=1400,1000',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-background-timer-throttling',
|
||||
],
|
||||
});
|
||||
|
||||
const steps = [];
|
||||
let index = 0;
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1400, height: 920, deviceScaleFactor: 2 });
|
||||
page.on('pageerror', (e) => console.log(' [pageerror]', e.message));
|
||||
// Non-2xx responses, counted per path so a burst of throttled tiles reads
|
||||
// as one line instead of one console error per tile.
|
||||
const failures = new Map();
|
||||
page.on('response', (r) => {
|
||||
const status = r.status();
|
||||
if (status < 400) return;
|
||||
const key = `${status} ${new URL(r.url()).pathname}`;
|
||||
failures.set(key, (failures.get(key) || 0) + 1);
|
||||
});
|
||||
const reportFailures = () => {
|
||||
if (!failures.size) return;
|
||||
console.log(
|
||||
' [http failures] ' +
|
||||
[...failures].map(([k, n]) => `${k} ×${n}`).join(' · '),
|
||||
);
|
||||
failures.clear();
|
||||
};
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error')
|
||||
console.log(' [console.error]', m.text().slice(0, 200));
|
||||
});
|
||||
// The Data Layers panel remembers its collapsed state; start it open.
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
try {
|
||||
localStorage.setItem('godsEyeView.v6.panelCollapsed.data-panel', '0');
|
||||
} catch {
|
||||
/* storage may be unavailable */
|
||||
}
|
||||
});
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
await sleep(12_000);
|
||||
|
||||
const diagnostics = () =>
|
||||
page.evaluate(
|
||||
({ WEATHER }) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const dm = gev.dataManager;
|
||||
const out = {
|
||||
basemap: gev.tileset ? 'google-3d' : 'keyless-globe',
|
||||
globeShown: gev.viewer.scene.globe.show,
|
||||
heightM: Math.round(gev.viewer.camera.positionCartographic.height),
|
||||
summaryHeight: Math.round(
|
||||
document.querySelector('.weather-summary')?.getBoundingClientRect()
|
||||
.height ?? 0,
|
||||
),
|
||||
};
|
||||
for (const id of ['wind', ...WEATHER, 'weather-cyclones']) {
|
||||
const entry = dm.layers.get(id);
|
||||
if (!entry?.enabled) continue;
|
||||
const d = entry.module?.getDiagnostics?.() || {};
|
||||
const s = entry.module?.getStats?.() || {};
|
||||
out[id] =
|
||||
id === 'wind'
|
||||
? {
|
||||
mode: d.renderMode,
|
||||
ready: d.gpu?.ready,
|
||||
paths: d.gpu?.pathCount,
|
||||
visibleCells: d.gpu?.visibleCells,
|
||||
heightFade: d.gpu?.heightFade,
|
||||
overlay: d.overlay,
|
||||
imagery: d.imageryActive,
|
||||
error: s.error,
|
||||
}
|
||||
: id === 'weather-cyclones'
|
||||
? { count: s.count, error: s.error }
|
||||
: {
|
||||
time: d.time,
|
||||
playing: d.playing,
|
||||
loading: d.loading,
|
||||
loadedTiles: d.loadedTiles,
|
||||
frameLoadMs: d.frameLoadMs && Math.round(d.frameLoadMs),
|
||||
error: d.error,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
},
|
||||
{ WEATHER },
|
||||
);
|
||||
|
||||
const renders = (ms = 2000) =>
|
||||
page.evaluate(
|
||||
(ms) =>
|
||||
new Promise((resolve) => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
let n = 0;
|
||||
const off = scene.postRender.addEventListener(() => {
|
||||
n += 1;
|
||||
});
|
||||
setTimeout(() => {
|
||||
off();
|
||||
resolve(n);
|
||||
}, ms);
|
||||
}),
|
||||
ms,
|
||||
);
|
||||
|
||||
const shot = async (name, note = '') => {
|
||||
index += 1;
|
||||
const file = `${String(index).padStart(2, '0')}-${name}.png`;
|
||||
const d = await diagnostics();
|
||||
const r = await renders(2000);
|
||||
await page.screenshot({ path: path.join(outDir, file) });
|
||||
reportFailures();
|
||||
steps.push({ index, name, note, file, renders2s: r, ...d });
|
||||
console.log(` ${file} renders/2s=${r} ${note} ${JSON.stringify(d)}`);
|
||||
};
|
||||
|
||||
const clickRow = async (id) => {
|
||||
const ok = await page.evaluate((id) => {
|
||||
const btn = document.querySelector(
|
||||
`[data-layer-id="${id}"] .data-toggle-btn`,
|
||||
);
|
||||
if (!btn) return false;
|
||||
btn.scrollIntoView({ block: 'center' });
|
||||
btn.click();
|
||||
return true;
|
||||
}, id);
|
||||
if (!ok) console.log(` !! no row button for ${id}`);
|
||||
};
|
||||
const clickChip = async (id, chip) => {
|
||||
const ok = await page.evaluate(
|
||||
({ id, chip }) => {
|
||||
const btn = document.querySelector(
|
||||
`[data-layer-id="${id}"] [data-chip-id="${chip}"]`,
|
||||
);
|
||||
if (!btn || btn.disabled) return false;
|
||||
btn.scrollIntoView({ block: 'center' });
|
||||
btn.click();
|
||||
return true;
|
||||
},
|
||||
{ id, chip },
|
||||
);
|
||||
if (!ok) console.log(` !! chip ${id}/${chip} missing or disabled`);
|
||||
return ok;
|
||||
};
|
||||
const waitReady = (ids, timeout = 90_000) =>
|
||||
page
|
||||
.waitForFunction(
|
||||
(ids) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
for (const id of ids) {
|
||||
const entry = dm.layers.get(id);
|
||||
if (!entry?.enabled) return false;
|
||||
const d = entry.module?.getDiagnostics?.() || {};
|
||||
if (id === 'wind') {
|
||||
if (d.renderMode === 'gpu-streamlines' && !d.gpu?.ready)
|
||||
return false;
|
||||
} else if (id !== 'weather-cyclones') {
|
||||
if (d.loading || (!d.time && !d.error)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout, polling: 250 },
|
||||
ids,
|
||||
)
|
||||
.catch(() =>
|
||||
console.log(` !! ${ids.join(',')} not ready within ${timeout} ms`),
|
||||
);
|
||||
const fly = async (
|
||||
lon,
|
||||
lat,
|
||||
height,
|
||||
pitch = -90,
|
||||
heading = 0,
|
||||
duration = 2,
|
||||
) => {
|
||||
await page.evaluate(
|
||||
({ lon, lat, height, pitch, heading, duration }) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const d2r = Math.PI / 180;
|
||||
v.camera.cancelFlight();
|
||||
v.camera.flyTo({
|
||||
destination: v.scene.ellipsoid.cartographicToCartesian({
|
||||
longitude: lon * d2r,
|
||||
latitude: lat * d2r,
|
||||
height,
|
||||
}),
|
||||
orientation: { heading: heading * d2r, pitch: pitch * d2r, roll: 0 },
|
||||
duration,
|
||||
});
|
||||
},
|
||||
{ lon, lat, height, pitch, heading, duration },
|
||||
);
|
||||
await sleep(duration * 1000 + 3000);
|
||||
};
|
||||
|
||||
console.log(`\nqa-weather-journey · ${label} · ${url}`);
|
||||
await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
for (const [id, entry] of gev.dataManager.layers)
|
||||
if (entry.enabled)
|
||||
await gev.dataManager.setEnabled(id, false, { origin: 'user' });
|
||||
document.querySelector('#data-panel')?.classList.remove('collapsed');
|
||||
});
|
||||
|
||||
// 1. Earth view, clean.
|
||||
await fly(-97.7431, 30.2672, 17_368_000);
|
||||
await shot('globe-clean', 'Earth view, no layers');
|
||||
|
||||
// 2. Wind from its row, then each field, then ECMWF.
|
||||
await clickRow('wind');
|
||||
await waitReady(['wind']);
|
||||
await sleep(1500);
|
||||
await shot('globe-wind-trails', 'Wind on (trails, no field)');
|
||||
await clickChip('wind', 'overlay-speed');
|
||||
await sleep(2500);
|
||||
await shot('globe-wind-speed', 'Wind speed field');
|
||||
await clickChip('wind', 'overlay-temperature');
|
||||
await sleep(2500);
|
||||
await waitReady(['wind']);
|
||||
await shot('globe-wind-temperature', 'Wind + 2 m temperature');
|
||||
await clickChip('wind', 'overlay-pressure');
|
||||
await sleep(2500);
|
||||
await waitReady(['wind']);
|
||||
await shot('globe-wind-pressure', 'Wind + MSL pressure');
|
||||
await clickChip('wind', 'model-ifs');
|
||||
await waitReady(['wind']);
|
||||
await sleep(2500);
|
||||
await shot('globe-wind-ecmwf-pressure', 'ECMWF IFS model');
|
||||
await clickChip('wind', 'model-gfs');
|
||||
await clickChip('wind', 'overlay-speed');
|
||||
await waitReady(['wind']);
|
||||
await sleep(1500);
|
||||
|
||||
// 3. Observed products.
|
||||
for (const id of WEATHER) await clickRow(id);
|
||||
await clickRow('weather-cyclones');
|
||||
await waitReady([...WEATHER]);
|
||||
await sleep(2500);
|
||||
await shot(
|
||||
'globe-all-weather',
|
||||
'Wind speed + radar + regional IR + lightning + cyclones',
|
||||
);
|
||||
await clickChip('weather-satellite', 'clouds');
|
||||
await waitReady(['weather-satellite']);
|
||||
await sleep(2500);
|
||||
await shot('globe-global-infrared', 'Satellite: global infrared mosaic');
|
||||
await clickChip('weather-satellite', 'clouds-regional');
|
||||
await waitReady(['weather-satellite']);
|
||||
|
||||
// 4. Follow a storm if there is one (advisories arrive a few seconds after enable).
|
||||
await page
|
||||
.waitForFunction(
|
||||
() =>
|
||||
!!document.querySelector(
|
||||
'[data-layer-id="weather-cyclones"] .data-toggle-chip:not([data-chip-id="focus"]):not([data-chip-id="advisory"])',
|
||||
),
|
||||
{ timeout: 25_000, polling: 500 },
|
||||
)
|
||||
.catch(() => {});
|
||||
const storm = await page.evaluate(() => {
|
||||
const first = document.querySelector(
|
||||
'[data-layer-id="weather-cyclones"] .data-toggle-chip:not([data-chip-id="focus"]):not([data-chip-id="advisory"])',
|
||||
);
|
||||
if (!first) return null;
|
||||
first.click();
|
||||
return first.textContent.trim();
|
||||
});
|
||||
if (storm) {
|
||||
await sleep(1500);
|
||||
if (await clickChip('weather-cyclones', 'focus')) {
|
||||
await sleep(6000);
|
||||
await shot('storm-view', `View storm: ${storm}`);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.viewer.camera.zoomIn(400_000),
|
||||
);
|
||||
await sleep(4000);
|
||||
await shot('storm-closer', 'Zoomed toward the storm');
|
||||
}
|
||||
} else console.log(' (no active cyclone advisories to follow)');
|
||||
|
||||
// 5. US radar, then regional → city → street over the same spot.
|
||||
await clickChip('weather-radar', 'coverage');
|
||||
await sleep(6000);
|
||||
await shot('us-radar', 'View US radar');
|
||||
await fly(-94.6, 37.5, 600_000);
|
||||
await shot('regional-600km', 'Regional 600 km');
|
||||
await fly(-94.6, 37.5, 120_000);
|
||||
await shot('regional-120km', 'Regional 120 km');
|
||||
await fly(-97.7431, 30.2672, 12_000, -60, 20);
|
||||
await shot('city-12km', 'Austin 12 km oblique');
|
||||
await fly(-97.7431, 30.2672, 1_200, -45, 20);
|
||||
await shot('city-1200m', 'Austin 1.2 km oblique (the perf route pose)');
|
||||
await fly(-97.7431, 30.2672, 350, -30, 60);
|
||||
await shot('street-350m', 'Austin 350 m street level');
|
||||
|
||||
// 6. History playback at the city pose (panel behavior while frames swap).
|
||||
await clickChip('weather-radar', 'play');
|
||||
const heights = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await sleep(2200);
|
||||
heights.push(
|
||||
await page.evaluate(() =>
|
||||
Math.round(
|
||||
document.querySelector('.weather-summary')?.getBoundingClientRect()
|
||||
.height ?? 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
await shot(
|
||||
'city-history-playing',
|
||||
`History playing; summary heights ${heights.join('/')}`,
|
||||
);
|
||||
await clickChip('weather-radar', 'play');
|
||||
await clickChip('weather-radar', 'latest');
|
||||
|
||||
// 7. Real input: wheel-zoom out, drag, wheel-zoom in.
|
||||
await fly(-97.7431, 30.2672, 40_000, -55, 0, 1.5);
|
||||
const canvas = await page.$(
|
||||
'canvas.cesium-widget-canvas, .cesium-widget canvas',
|
||||
);
|
||||
const box = canvas
|
||||
? await canvas.boundingBox()
|
||||
: { x: 700, y: 460, width: 0, height: 0 };
|
||||
const cx = box.x + (box.width || 0) / 2;
|
||||
const cy = box.y + (box.height || 0) / 2;
|
||||
await page.mouse.move(cx, cy);
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await page.mouse.wheel({ deltaY: 240 });
|
||||
await sleep(120);
|
||||
}
|
||||
await sleep(3000);
|
||||
await shot('wheel-out', 'Wheel zoom out ×12');
|
||||
await page.mouse.move(cx - 200, cy);
|
||||
await page.mouse.down();
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
await page.mouse.move(cx - 200 + i * 20, cy + i * 4, { steps: 2 });
|
||||
await sleep(30);
|
||||
}
|
||||
await page.mouse.up();
|
||||
await sleep(2500);
|
||||
await shot('drag-pan', 'Drag pan');
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.mouse.wheel({ deltaY: -240 });
|
||||
await sleep(120);
|
||||
}
|
||||
await sleep(4000);
|
||||
await shot('wheel-in', 'Wheel zoom in ×10');
|
||||
|
||||
// 8. Back to Earth with everything on.
|
||||
await fly(-97.7431, 30.2672, 17_368_000, -90, 0, 2.5);
|
||||
await shot('globe-return', 'Return to Earth view');
|
||||
|
||||
writeFileSync(
|
||||
path.join(outDir, 'steps.json'),
|
||||
JSON.stringify({ label, url, steps }, null, 2),
|
||||
);
|
||||
console.log(`\nWrote ${steps.length} steps to ${outDir}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-weather-perf — matched-route throughput and idle-work measurement for the
|
||||
* Weather layers (Wind, Rain radar, Satellite clouds, Lightning density).
|
||||
*
|
||||
* This is a measurement harness, not a pass/fail gate. It reproduces one fixed
|
||||
* camera route per weather state so two builds (or two basemaps) can be
|
||||
* compared on the same numbers:
|
||||
*
|
||||
* park 10 km over Austin → 3 s flight to 1.2 km (heading 20°, pitch −45°)
|
||||
* → 6 s close idle → 8 s orbit at 1.8 km range → 3 s flight back to the
|
||||
* 17,368 km globe view → 6 s globe idle.
|
||||
*
|
||||
* Route windows hold render demand constant (`scene.requestRender()` on every
|
||||
* animation frame) so the idle-render governor cannot masquerade as low FPS.
|
||||
* FPS counts `scene.postRender` fires, and every window asserts the canvas
|
||||
* size did not change, because a stopped renderer with a free-running rAF
|
||||
* reads as a clean 60 fps otherwise.
|
||||
*
|
||||
* The separate idle test restores the normal governor, parks the camera at
|
||||
* the 1.2 km pose with Wind off, and counts scene redraws, DOM mutations
|
||||
* inside `.weather-summary`, and `.weather-summary` height changes over
|
||||
* 8-second windows for: weather off, history paused, history playing.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/qa-weather-perf.mjs [--url http://localhost:4173]
|
||||
* [--label google-baseline] [--states off,wind,...] [--repeat 1]
|
||||
* [--headless] [--no-route] [--no-idle] [--out qa-shots/weather-perf]
|
||||
*
|
||||
* States: off · history-paused · history-playing · wind · wind+history-paused
|
||||
* · wind+history-playing. "history" is radar + regional clouds + lightning
|
||||
* together; "wind" is animated GFS wind with the Speed field selected.
|
||||
* Requires a running dev server. Screenshots and results.json land in
|
||||
* <out>/<label>/.
|
||||
*/
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const arg = (name, fallback) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')
|
||||
? argv[i + 1]
|
||||
: fallback;
|
||||
};
|
||||
const flag = (name) => argv.includes(name);
|
||||
|
||||
const rawUrl = arg('--url', 'http://localhost:4173');
|
||||
// The first-launch chooser would otherwise sit over the globe; `?welcome=0`
|
||||
// is the app's own suppression flag (src/firstRunExperience.js).
|
||||
const url = rawUrl.includes('welcome=')
|
||||
? rawUrl
|
||||
: `${rawUrl}${rawUrl.includes('?') ? '&' : '?'}welcome=0`;
|
||||
const label = arg(
|
||||
'--label',
|
||||
new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'),
|
||||
);
|
||||
const ALL_STATES = [
|
||||
'off',
|
||||
'history-paused',
|
||||
'history-playing',
|
||||
'wind',
|
||||
'wind+history-paused',
|
||||
'wind+history-playing',
|
||||
];
|
||||
const states = arg('--states', ALL_STATES.join(','))
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
for (const s of states)
|
||||
if (!ALL_STATES.includes(s)) {
|
||||
console.error(`unknown state "${s}"; choose from ${ALL_STATES.join(', ')}`);
|
||||
process.exit(2);
|
||||
}
|
||||
const repeat = Math.max(1, Number(arg('--repeat', '1')) || 1);
|
||||
// `--scale 2` renders at 2× resolutionScale (4× the pixels) so a GPU that idles
|
||||
// at 60 fps natively still exposes per-frame cost differences.
|
||||
const scale = Number(arg('--scale', '1')) || 1;
|
||||
const outDir = path.resolve(arg('--out', 'qa-shots/weather-perf'), label);
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const WEATHER = ['weather-radar', 'weather-satellite', 'weather-lightning'];
|
||||
const AUSTIN = { lon: -97.7431, lat: 30.2672 };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const chromePath =
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||
['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'].find((p) =>
|
||||
existsSync(p),
|
||||
);
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: flag('--headless') ? 'new' : false,
|
||||
executablePath: chromePath,
|
||||
protocolTimeout: 300_000,
|
||||
defaultViewport: null,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--window-size=1200,960',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-background-timer-throttling',
|
||||
],
|
||||
});
|
||||
|
||||
const results = [];
|
||||
let reportFailures = null;
|
||||
const record = (row) => {
|
||||
results.push(row);
|
||||
reportFailures?.();
|
||||
const {
|
||||
phase,
|
||||
state,
|
||||
run,
|
||||
window: win,
|
||||
fps,
|
||||
p95,
|
||||
longTasks,
|
||||
mutations,
|
||||
heightChanges,
|
||||
canvasStable,
|
||||
} = row;
|
||||
console.log(
|
||||
` ${phase.padEnd(5)} ${state.padEnd(22)} r${run} ${win.padEnd(12)} ` +
|
||||
`fps=${String(fps).padStart(5)} p95=${String(p95).padStart(6)}ms ` +
|
||||
`long=${String(longTasks).padStart(2)} dom=${String(mutations).padStart(4)} ` +
|
||||
`h∆=${String(heightChanges).padStart(2)}${canvasStable ? '' : ' CANVAS CHANGED'}`,
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1200, height: 886, deviceScaleFactor: 2 });
|
||||
page.on('pageerror', (e) => console.log(' [pageerror]', e.message));
|
||||
// Non-2xx responses, counted per path so a burst of throttled tiles reads
|
||||
// as one line instead of one console error per tile.
|
||||
const failures = new Map();
|
||||
page.on('response', (r) => {
|
||||
const status = r.status();
|
||||
if (status < 400) return;
|
||||
const key = `${status} ${new URL(r.url()).pathname}`;
|
||||
failures.set(key, (failures.get(key) || 0) + 1);
|
||||
});
|
||||
reportFailures = () => {
|
||||
if (!failures.size) return;
|
||||
console.log(
|
||||
' [http failures] ' +
|
||||
[...failures].map(([k, n]) => `${k} ×${n}`).join(' · '),
|
||||
);
|
||||
failures.clear();
|
||||
};
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
await sleep(15_000);
|
||||
|
||||
if (scale !== 1)
|
||||
await page.evaluate((scale) => {
|
||||
window.__godsEyeView.viewer.resolutionScale = scale;
|
||||
window.__godsEyeView.viewer.scene.requestRender();
|
||||
}, scale);
|
||||
const env = await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const scene = gev.viewer.scene;
|
||||
return {
|
||||
basemap: gev.tileset ? 'google-3d' : 'keyless-globe',
|
||||
globeShown: scene.globe.show,
|
||||
canvas: `${scene.canvas.width}x${scene.canvas.height}`,
|
||||
dpr: devicePixelRatio,
|
||||
ua:
|
||||
navigator.userAgent.match(/Chrome\/[\d.]+/)?.[0] || navigator.userAgent,
|
||||
msaa: scene.msaaSamples,
|
||||
resolutionScale: gev.viewer.resolutionScale,
|
||||
};
|
||||
});
|
||||
console.log(`\nqa-weather-perf · ${label}\n url=${url}`);
|
||||
console.log(` ${JSON.stringify(env)}`);
|
||||
|
||||
// Park deterministically, disable every layer, and instrument once.
|
||||
await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
const v = gev.viewer;
|
||||
v.camera.cancelFlight();
|
||||
for (const [id, entry] of gev.dataManager.layers)
|
||||
if (entry.enabled) {
|
||||
try {
|
||||
await gev.dataManager.setEnabled(id, false, { origin: 'user' });
|
||||
} catch {
|
||||
/* counted through the layer, not here */
|
||||
}
|
||||
}
|
||||
const P = (window.__gevWeatherPerf = {
|
||||
renders: 0,
|
||||
longTasks: 0,
|
||||
longTaskMs: 0,
|
||||
mutations: 0,
|
||||
});
|
||||
v.scene.postRender.addEventListener(() => {
|
||||
P.renders += 1;
|
||||
});
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
P.longTasks += 1;
|
||||
P.longTaskMs += entry.duration;
|
||||
}
|
||||
}).observe({ type: 'longtask', buffered: false });
|
||||
} catch {
|
||||
/* longtask unsupported → stays 0 */
|
||||
}
|
||||
const summary = document.querySelector('.weather-summary');
|
||||
if (summary)
|
||||
new MutationObserver((records) => {
|
||||
P.mutations += records.length;
|
||||
}).observe(summary, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
});
|
||||
});
|
||||
|
||||
const pose = (which) =>
|
||||
page.evaluate(
|
||||
({ which, AUSTIN }) => {
|
||||
const C = window.Cesium || null;
|
||||
const v = window.__godsEyeView.viewer;
|
||||
v.camera.cancelFlight();
|
||||
v.camera.lookAtTransform?.(
|
||||
(C || v.camera.constructor).Matrix4?.IDENTITY ??
|
||||
v.scene.camera.transform,
|
||||
);
|
||||
const ell = v.scene.ellipsoid || v.scene.globe.ellipsoid;
|
||||
const d2r = Math.PI / 180;
|
||||
const destination = ell.cartographicToCartesian({
|
||||
longitude: AUSTIN.lon * d2r,
|
||||
latitude: AUSTIN.lat * d2r,
|
||||
height:
|
||||
which === 'overhead'
|
||||
? 10_000
|
||||
: which === 'close'
|
||||
? 1_200
|
||||
: 17_368_000,
|
||||
});
|
||||
v.camera.setView({
|
||||
destination,
|
||||
orientation:
|
||||
which === 'close'
|
||||
? { heading: 20 * d2r, pitch: -45 * d2r, roll: 0 }
|
||||
: { heading: 0, pitch: -90 * d2r, roll: 0 },
|
||||
});
|
||||
},
|
||||
{ which, AUSTIN },
|
||||
);
|
||||
|
||||
/** Start a 3 s flight without awaiting it; the caller measures alongside. */
|
||||
const fly = (which) =>
|
||||
page.evaluate(
|
||||
({ which, AUSTIN }) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const ell = v.scene.ellipsoid || v.scene.globe.ellipsoid;
|
||||
const d2r = Math.PI / 180;
|
||||
v.camera.flyTo({
|
||||
destination: ell.cartographicToCartesian({
|
||||
longitude: AUSTIN.lon * d2r,
|
||||
latitude: AUSTIN.lat * d2r,
|
||||
height: which === 'close' ? 1_200 : 17_368_000,
|
||||
}),
|
||||
orientation:
|
||||
which === 'close'
|
||||
? { heading: 20 * d2r, pitch: -45 * d2r, roll: 0 }
|
||||
: { heading: 0, pitch: -90 * d2r, roll: 0 },
|
||||
duration: 3,
|
||||
});
|
||||
},
|
||||
{ which, AUSTIN },
|
||||
);
|
||||
|
||||
/** Start an 8 s orbit at 1.8 km range around the Austin anchor (not awaited). */
|
||||
const orbit = () =>
|
||||
page.evaluate(
|
||||
({ AUSTIN }) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const camera = v.camera;
|
||||
const ell = v.scene.ellipsoid || v.scene.globe.ellipsoid;
|
||||
const d2r = Math.PI / 180;
|
||||
const center = ell.cartographicToCartesian({
|
||||
longitude: AUSTIN.lon * d2r,
|
||||
latitude: AUSTIN.lat * d2r,
|
||||
height: 0,
|
||||
});
|
||||
const HPR = camera.constructor?.HeadingPitchRange
|
||||
? camera.constructor.HeadingPitchRange
|
||||
: null;
|
||||
const t0 = performance.now();
|
||||
let heading = 20 * d2r;
|
||||
const step = (now) => {
|
||||
const elapsed = (now - t0) / 1000;
|
||||
heading = 20 * d2r + (elapsed / 8) * Math.PI * 2;
|
||||
const hpr = HPR
|
||||
? new HPR(heading, -45 * d2r, 1800)
|
||||
: { heading, pitch: -45 * d2r, range: 1800 };
|
||||
camera.lookAt(center, hpr);
|
||||
if (elapsed < 8) requestAnimationFrame(step);
|
||||
else camera.lookAtTransform(camera.transform.constructor.IDENTITY);
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
},
|
||||
{ AUSTIN },
|
||||
);
|
||||
|
||||
/** Measure one window; `hold` keeps render demand constant. */
|
||||
const measure = (ms, hold) =>
|
||||
page.evaluate(
|
||||
({ ms, hold }) =>
|
||||
new Promise((resolve) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const scene = gev.viewer.scene;
|
||||
const P = window.__gevWeatherPerf;
|
||||
const canvas = scene.canvas;
|
||||
const w0 = canvas.width;
|
||||
const h0 = canvas.height;
|
||||
const r0 = P.renders;
|
||||
const l0 = P.longTasks;
|
||||
const lm0 = P.longTaskMs;
|
||||
const m0 = P.mutations;
|
||||
const summary = document.querySelector('.weather-summary');
|
||||
const intervals = [];
|
||||
const heights = [];
|
||||
let lastH = -1;
|
||||
let rafs = 0;
|
||||
let last = performance.now();
|
||||
const t0 = last;
|
||||
const finish = (now) => {
|
||||
const seconds = (now - t0) / 1000;
|
||||
intervals.sort((a, b) => a - b);
|
||||
const q = (p) =>
|
||||
intervals[
|
||||
Math.min(intervals.length - 1, Math.floor(p * intervals.length))
|
||||
] ?? 0;
|
||||
const hs = heights.map(([, h]) => h);
|
||||
resolve({
|
||||
seconds: Number(seconds.toFixed(2)),
|
||||
renders: P.renders - r0,
|
||||
fps: Number(((P.renders - r0) / seconds).toFixed(1)),
|
||||
rafHz: Number((rafs / seconds).toFixed(1)),
|
||||
p50: Number(q(0.5).toFixed(1)),
|
||||
p95: Number(q(0.95).toFixed(1)),
|
||||
max: Number((intervals.at(-1) ?? 0).toFixed(1)),
|
||||
longTasks: P.longTasks - l0,
|
||||
longTaskMs: Math.round(P.longTaskMs - lm0),
|
||||
mutations: P.mutations - m0,
|
||||
heightChanges: Math.max(0, heights.length - 1),
|
||||
heightMin: hs.length ? Math.min(...hs) : null,
|
||||
heightMax: hs.length ? Math.max(...hs) : null,
|
||||
canvasStable: canvas.width === w0 && canvas.height === h0,
|
||||
canvas: `${w0}x${h0}`,
|
||||
});
|
||||
};
|
||||
const tick = (now) => {
|
||||
rafs += 1;
|
||||
intervals.push(now - last);
|
||||
last = now;
|
||||
if (hold) scene.requestRender();
|
||||
if (summary && !summary.hidden) {
|
||||
const h =
|
||||
Math.round(summary.getBoundingClientRect().height * 2) / 2;
|
||||
if (h !== lastH) {
|
||||
heights.push([Math.round(now - t0), h]);
|
||||
lastH = h;
|
||||
}
|
||||
}
|
||||
if (now - t0 < ms) requestAnimationFrame(tick);
|
||||
else finish(now);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}),
|
||||
{ ms, hold },
|
||||
);
|
||||
|
||||
const diagnostics = () =>
|
||||
page.evaluate(
|
||||
({ WEATHER }) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const out = {};
|
||||
for (const id of ['wind', ...WEATHER]) {
|
||||
const entry = dm.layers.get(id);
|
||||
if (!entry?.enabled) continue;
|
||||
const d = entry.module?.getDiagnostics?.() || {};
|
||||
out[id] =
|
||||
id === 'wind'
|
||||
? {
|
||||
mode: d.renderMode,
|
||||
ready: d.gpu?.ready,
|
||||
paths: d.gpu?.pathCount,
|
||||
vertices: d.gpu?.vertexCount,
|
||||
buildMs: d.gpu?.buildMs && Math.round(d.gpu.buildMs),
|
||||
cells: d.gpu?.cellCount,
|
||||
visibleCells: d.gpu?.visibleCells,
|
||||
visibleVertices: d.gpu?.visibleVertexCount,
|
||||
heightFade: d.gpu?.heightFade,
|
||||
}
|
||||
: {
|
||||
time: d.time,
|
||||
playing: d.playing,
|
||||
loading: d.loading,
|
||||
loadedTiles: d.loadedTiles,
|
||||
imageryCount: d.imageryCount,
|
||||
frameLoadMs: d.frameLoadMs && Math.round(d.frameLoadMs),
|
||||
error: d.error,
|
||||
suspended: d.suspended ?? d.globeHidden ?? null,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
},
|
||||
{ WEATHER },
|
||||
);
|
||||
|
||||
/** Enable the layers a state names, wait for first imagery/geometry, then set playback. */
|
||||
const applyState = async (state) => {
|
||||
const wantWind = state.includes('wind');
|
||||
const wantHistory = state.includes('history');
|
||||
const wantPlaying = state.includes('playing');
|
||||
const started = Date.now();
|
||||
await page.evaluate(
|
||||
async ({ wantWind, wantHistory, WEATHER }) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const set = async (id, on) => {
|
||||
const entry = dm.layers.get(id);
|
||||
if (!entry || Boolean(entry.enabled) === on) return;
|
||||
await dm.setEnabled(id, on, { origin: 'user' });
|
||||
};
|
||||
await set('wind', wantWind);
|
||||
if (wantWind)
|
||||
dm.setLayerParams('wind', { overlay: 'speed' }, { origin: 'user' });
|
||||
for (const id of WEATHER) await set(id, wantHistory);
|
||||
},
|
||||
{ wantWind, wantHistory, WEATHER },
|
||||
);
|
||||
let ready = true;
|
||||
await page
|
||||
.waitForFunction(
|
||||
({ wantWind, wantHistory, WEATHER }) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
if (wantWind) {
|
||||
const d = dm.layers.get('wind')?.module?.getDiagnostics?.();
|
||||
if (!d) return false;
|
||||
if (d.renderMode === 'gpu-streamlines' && !d.gpu?.ready)
|
||||
return false;
|
||||
}
|
||||
if (wantHistory)
|
||||
for (const id of WEATHER) {
|
||||
const d = dm.layers.get(id)?.module?.getDiagnostics?.();
|
||||
if (!d || d.loading || (!d.time && !d.error)) return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout: 90_000, polling: 250 },
|
||||
{ wantWind, wantHistory, WEATHER },
|
||||
)
|
||||
.catch(() => {
|
||||
ready = false;
|
||||
});
|
||||
const readyMs = Date.now() - started;
|
||||
await page.evaluate(
|
||||
({ wantPlaying, WEATHER }) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
for (const id of WEATHER) {
|
||||
const module = dm.layers.get(id)?.module;
|
||||
if (!module || !dm.layers.get(id)?.enabled) continue;
|
||||
const playing = Boolean(module.getDiagnostics?.().playing);
|
||||
if (playing !== wantPlaying)
|
||||
dm.setLayerParams(id, { play: true }, { origin: 'user' });
|
||||
}
|
||||
},
|
||||
{ wantPlaying, WEATHER },
|
||||
);
|
||||
await sleep(2_500);
|
||||
return { ready, readyMs, layers: await diagnostics() };
|
||||
};
|
||||
|
||||
const shot = (name) =>
|
||||
page.screenshot({ path: path.join(outDir, `${name}.png`) });
|
||||
|
||||
// ── Matched route ────────────────────────────────────────────────────────
|
||||
if (!flag('--no-route')) {
|
||||
console.log('\nRoute windows (continuous-render hold):');
|
||||
for (let run = 1; run <= repeat; run++) {
|
||||
const order = run % 2 === 1 ? states : [...states].reverse();
|
||||
for (const state of order) {
|
||||
await pose('overhead');
|
||||
const setup = await applyState(state);
|
||||
console.log(
|
||||
` setup ${state}: ready=${setup.ready} in ${setup.readyMs}ms ${JSON.stringify(setup.layers)}`,
|
||||
);
|
||||
const base = { phase: 'route', state, run, setup };
|
||||
await fly('close');
|
||||
record({ ...base, window: 'zoom-in', ...(await measure(6_000, true)) });
|
||||
record({
|
||||
...base,
|
||||
window: 'close-idle',
|
||||
...(await measure(6_000, true)),
|
||||
});
|
||||
await shot(`${state}-r${run}-close`);
|
||||
await orbit();
|
||||
record({ ...base, window: 'orbit', ...(await measure(8_000, true)) });
|
||||
await fly('globe');
|
||||
record({
|
||||
...base,
|
||||
window: 'zoom-out',
|
||||
...(await measure(6_000, true)),
|
||||
});
|
||||
record({
|
||||
...base,
|
||||
window: 'globe-idle',
|
||||
...(await measure(6_000, true)),
|
||||
});
|
||||
await shot(`${state}-r${run}-globe`);
|
||||
console.log(` after ${state}: ${JSON.stringify(await diagnostics())}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Idle test (normal governor) ──────────────────────────────────────────
|
||||
if (!flag('--no-idle')) {
|
||||
console.log('\nIdle windows (normal governor, fixed 1.2 km pose):');
|
||||
for (const state of ['off', 'history-paused', 'history-playing', 'wind']) {
|
||||
await pose('close');
|
||||
const setup = await applyState(state);
|
||||
await sleep(4_000);
|
||||
for (let run = 1; run <= 2; run++)
|
||||
record({
|
||||
phase: 'idle',
|
||||
state,
|
||||
run,
|
||||
window: 'fixed-8s',
|
||||
setup,
|
||||
...(await measure(8_000, false)),
|
||||
});
|
||||
await shot(`idle-${state}`);
|
||||
}
|
||||
await applyState('off');
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
path.join(outDir, 'results.json'),
|
||||
JSON.stringify({ label, url, env, results }, null, 2),
|
||||
);
|
||||
const lines = [
|
||||
`# qa-weather-perf · ${label}`,
|
||||
'',
|
||||
`url: ${url} · ${env.basemap} · canvas ${env.canvas} @ DPR ${env.dpr} · ${env.ua}`,
|
||||
'',
|
||||
'| phase | state | run | window | fps | p50 ms | p95 ms | max ms | long tasks | long ms | dom mut | height ∆ | height min–max |',
|
||||
'|---|---|---|---|---|---|---|---|---|---|---|---|---|',
|
||||
...results.map(
|
||||
(r) =>
|
||||
`| ${r.phase} | ${r.state} | ${r.run} | ${r.window} | ${r.fps}${r.canvasStable ? '' : ' ⚠'} | ${r.p50} | ${r.p95} | ${r.max} | ${r.longTasks} | ${r.longTaskMs} | ${r.mutations} | ${r.heightChanges} | ${r.heightMin ?? '—'}–${r.heightMax ?? '—'} |`,
|
||||
),
|
||||
];
|
||||
writeFileSync(path.join(outDir, 'summary.md'), lines.join('\n') + '\n');
|
||||
console.log(`\nWrote ${path.join(outDir, 'results.json')} and summary.md`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Weather history-swap probe: does the scene keep drawing while a draped
|
||||
* weather frame is replaced? Runs against a live dev server, turns on one
|
||||
* observed product at the Earth view, steps Earlier and plays history while
|
||||
* screenshotting, then measures the black fraction inside the globe disc of
|
||||
* every capture. Writes <out>/<label>/summary.json.
|
||||
*
|
||||
* node scripts/qa-weather-swap.mjs --url http://localhost:4173 --label google
|
||||
* [--layer weather-satellite] [--product clouds] [--headless]
|
||||
*/
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const arg = (name, fallback) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')
|
||||
? argv[i + 1]
|
||||
: fallback;
|
||||
};
|
||||
const flag = (name) => argv.includes(name);
|
||||
const rawUrl = arg('--url', 'http://localhost:4173');
|
||||
const url = rawUrl.includes('welcome=')
|
||||
? rawUrl
|
||||
: `${rawUrl}${rawUrl.includes('?') ? '&' : '?'}welcome=0`;
|
||||
const label = arg('--label', 'swap');
|
||||
const layerId = arg('--layer', 'weather-satellite');
|
||||
const product = arg(
|
||||
'--product',
|
||||
layerId === 'weather-satellite' ? 'clouds' : null,
|
||||
);
|
||||
const outDir = path.resolve(arg('--out', 'qa-shots/weather-swap'), label);
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
// Globe disc in CSS pixels for the 1400×920 viewport at the Earth view.
|
||||
const DISC = { cx: 700, cy: 460, r: 300 };
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: flag('--headless') ? 'new' : false,
|
||||
executablePath:
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||
['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'].find((p) =>
|
||||
existsSync(p),
|
||||
),
|
||||
protocolTimeout: 300_000,
|
||||
defaultViewport: null,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--window-size=1400,1000',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-background-timer-throttling',
|
||||
],
|
||||
});
|
||||
const frames = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1400, height: 920, deviceScaleFactor: 1 });
|
||||
page.on('pageerror', (e) => console.log(' [pageerror]', e.message));
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
await sleep(12_000);
|
||||
await page.evaluate(
|
||||
async ({ layerId, product }) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const dm = gev.dataManager;
|
||||
for (const [id, e] of dm.layers)
|
||||
if (e.enabled) await dm.setEnabled(id, false, { origin: 'user' });
|
||||
const v = gev.viewer;
|
||||
const d = Math.PI / 180;
|
||||
v.camera.setView({
|
||||
destination: v.scene.ellipsoid.cartographicToCartesian({
|
||||
longitude: -80 * d,
|
||||
latitude: 25 * d,
|
||||
height: 17_368_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -90 * d, roll: 0 },
|
||||
});
|
||||
await dm.setEnabled(layerId, true, { origin: 'user' });
|
||||
if (product) dm.setLayerParams(layerId, { product }, { origin: 'user' });
|
||||
},
|
||||
{ layerId, product },
|
||||
);
|
||||
await page
|
||||
.waitForFunction(
|
||||
({ layerId, product }) => {
|
||||
const d = window.__godsEyeView.dataManager.layers
|
||||
.get(layerId)
|
||||
?.module?.getDiagnostics?.();
|
||||
return d && !d.loading && d.time && (!product || d.product === product);
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
{ layerId, product },
|
||||
)
|
||||
.catch(() => console.log(' !! first frame not ready'));
|
||||
await sleep(3000);
|
||||
const diag = () =>
|
||||
page.evaluate((layerId) => {
|
||||
const d = window.__godsEyeView.dataManager.layers
|
||||
.get(layerId)
|
||||
.module.getDiagnostics();
|
||||
return {
|
||||
time: d.time,
|
||||
loading: d.loading,
|
||||
pending: d.pendingTiles,
|
||||
loaded: d.loadedTiles,
|
||||
imagery: d.imageryCount,
|
||||
mosaic: d.mosaic ?? null,
|
||||
};
|
||||
}, layerId);
|
||||
const capture = async (name) => {
|
||||
const file = `${name}.png`;
|
||||
await page.screenshot({ path: path.join(outDir, file) });
|
||||
frames.push({ file, ...(await diag()) });
|
||||
};
|
||||
await capture('00-latest');
|
||||
const step = (params) =>
|
||||
page.evaluate(
|
||||
({ layerId, params }) =>
|
||||
window.__godsEyeView.dataManager.setLayerParams(layerId, params, {
|
||||
origin: 'user',
|
||||
}),
|
||||
{ layerId, params },
|
||||
);
|
||||
await step({ step: -1 });
|
||||
for (let i = 1; i <= 15; i++) {
|
||||
await sleep(400);
|
||||
await capture(`earlier-${String(i).padStart(2, '0')}`);
|
||||
}
|
||||
await step({ play: true });
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
await sleep(500);
|
||||
if (i % 2 === 0) await capture(`play-${String(i).padStart(2, '0')}`);
|
||||
}
|
||||
await step({ play: true });
|
||||
|
||||
// Black fraction inside the globe disc, measured in a blank page.
|
||||
const meter = await browser.newPage();
|
||||
for (const frame of frames) {
|
||||
const b64 = readFileSync(path.join(outDir, frame.file)).toString('base64');
|
||||
const black = await meter.evaluate(
|
||||
async ({ b64, DISC }) => {
|
||||
const img = new Image();
|
||||
img.src = `data:image/png;base64,${b64}`;
|
||||
await img.decode();
|
||||
const c = document.createElement('canvas');
|
||||
c.width = img.naturalWidth;
|
||||
c.height = img.naturalHeight;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const s = img.naturalWidth / 1400;
|
||||
const d = ctx.getImageData(0, 0, c.width, c.height).data;
|
||||
const rr = (DISC.r * s) ** 2;
|
||||
let n = 0;
|
||||
let dark = 0;
|
||||
for (let y = 0; y < c.height; y += 2)
|
||||
for (let x = 0; x < c.width; x += 2) {
|
||||
if ((x - DISC.cx * s) ** 2 + (y - DISC.cy * s) ** 2 > rr) continue;
|
||||
n++;
|
||||
const i = (y * c.width + x) * 4;
|
||||
if (d[i] < 18 && d[i + 1] < 18 && d[i + 2] < 18) dark++;
|
||||
}
|
||||
return dark / n;
|
||||
},
|
||||
{ b64, DISC },
|
||||
);
|
||||
frame.black = Number(black.toFixed(4));
|
||||
console.log(
|
||||
`${frame.file}\t${(black * 100).toFixed(1)}%\t${frame.time}\tpending=${frame.pending}`,
|
||||
);
|
||||
}
|
||||
const blackFrames = frames.filter((f) => f.black > 0.15).length;
|
||||
const distinctTimes = new Set(frames.map((f) => f.time).filter(Boolean)).size;
|
||||
const summary = {
|
||||
url: rawUrl,
|
||||
label,
|
||||
layerId,
|
||||
product,
|
||||
frames: frames.length,
|
||||
blackFrames,
|
||||
maxBlack: Math.max(...frames.map((f) => f.black)),
|
||||
distinctTimes,
|
||||
capturedAt: new Date().toISOString(),
|
||||
};
|
||||
writeFileSync(
|
||||
path.join(outDir, 'summary.json'),
|
||||
JSON.stringify({ summary, frames }, null, 2),
|
||||
);
|
||||
console.log(
|
||||
`frames=${frames.length} black(>15%)=${blackFrames} max=${(summary.maxBlack * 100).toFixed(1)}% distinctTimes=${distinctTimes}`,
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Weather teardown and cross-layer regression probe.
|
||||
*
|
||||
* Against a running dev server: records a resource baseline, turns every
|
||||
* weather layer on and exercises it (history playback, a wind reading, a
|
||||
* storm selection), turns them all off, and asserts the scene is back to the
|
||||
* baseline (imagery layers on the globe and the tileset, primitives, ground
|
||||
* primitives, data sources, entities, camera/scene listeners, the weather
|
||||
* panel, the wind reading marker). Then it enables a set of non-weather
|
||||
* layers one at a time and samples the render rate, long tasks and console
|
||||
* errors for each, and repeats the same non-weather sequence in a control
|
||||
* page that never had weather on. Writes <out>/<label>/results.json.
|
||||
*
|
||||
* node scripts/qa-weather-teardown.mjs --url http://localhost:4173 --label google
|
||||
*/
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const arg = (name, fallback) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')
|
||||
? argv[i + 1]
|
||||
: fallback;
|
||||
};
|
||||
const flag = (name) => argv.includes(name);
|
||||
const rawUrl = arg('--url', 'http://localhost:4173');
|
||||
const url = rawUrl.includes('welcome=')
|
||||
? rawUrl
|
||||
: `${rawUrl}${rawUrl.includes('?') ? '&' : '?'}welcome=0`;
|
||||
const label = arg('--label', 'teardown');
|
||||
const outDir = path.resolve(arg('--out', 'qa-shots/weather-teardown'), label);
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const WEATHER = [
|
||||
'wind',
|
||||
'weather-radar',
|
||||
'weather-satellite',
|
||||
'weather-lightning',
|
||||
'weather-cyclones',
|
||||
];
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: flag('--headless') ? 'new' : false,
|
||||
executablePath:
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||
['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'].find((p) =>
|
||||
existsSync(p),
|
||||
),
|
||||
protocolTimeout: 300_000,
|
||||
defaultViewport: null,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--window-size=1400,1000',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-background-timer-throttling',
|
||||
],
|
||||
});
|
||||
|
||||
const boot = async () => {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1400, height: 920, deviceScaleFactor: 1 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) =>
|
||||
errors.push(`pageerror: ${e.message}`.slice(0, 200)),
|
||||
);
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error') errors.push(`console: ${m.text()}`.slice(0, 200));
|
||||
});
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
await sleep(10_000);
|
||||
await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
const dm = gev.dataManager;
|
||||
for (const [id, e] of dm.layers)
|
||||
if (e.enabled) await dm.setEnabled(id, false, { origin: 'user' });
|
||||
const v = gev.viewer;
|
||||
const d = Math.PI / 180;
|
||||
v.camera.setView({
|
||||
destination: v.scene.ellipsoid.cartographicToCartesian({
|
||||
longitude: -95 * d,
|
||||
latitude: 32 * d,
|
||||
height: 4_500_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -90 * d, roll: 0 },
|
||||
});
|
||||
});
|
||||
await sleep(3000);
|
||||
return { page, errors };
|
||||
};
|
||||
|
||||
const snapshot = (page) =>
|
||||
page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const v = gev.viewer;
|
||||
const s = v.scene;
|
||||
const t = gev.tileset;
|
||||
const dm = gev.dataManager;
|
||||
const weather = {};
|
||||
for (const id of [
|
||||
'weather-radar',
|
||||
'weather-satellite',
|
||||
'weather-lightning',
|
||||
]) {
|
||||
const d = dm.layers.get(id)?.module?.getDiagnostics?.();
|
||||
if (d)
|
||||
weather[id] = {
|
||||
imagery: d.imageryCount,
|
||||
mosaics: d.cache?.mosaics ?? 0,
|
||||
prefetching: !!d.cache?.prefetching,
|
||||
};
|
||||
}
|
||||
const wind = dm.layers.get('wind')?.module?.getDiagnostics?.() || {};
|
||||
return {
|
||||
globeImagery: v.imageryLayers.length,
|
||||
tilesetImagery: t?.imageryLayers?.length ?? null,
|
||||
primitives: s.primitives.length,
|
||||
groundPrimitives: s.groundPrimitives.length,
|
||||
dataSources: v.dataSources.length,
|
||||
entities: v.entities.values.length,
|
||||
listeners: {
|
||||
postRender: s.postRender.numberOfListeners,
|
||||
preRender: s.preRender.numberOfListeners,
|
||||
postUpdate: s.postUpdate.numberOfListeners,
|
||||
moveEnd: v.camera.moveEnd.numberOfListeners,
|
||||
moveStart: v.camera.moveStart.numberOfListeners,
|
||||
changed: v.camera.changed.numberOfListeners,
|
||||
tick: v.clock.onTick.numberOfListeners,
|
||||
},
|
||||
dom: {
|
||||
weatherPanelHidden:
|
||||
document.getElementById('weather-panel')?.hidden ?? null,
|
||||
weatherCards: document.querySelectorAll('.weather-card').length,
|
||||
windReading: !!document.querySelector(
|
||||
'.gev-wind-reading:not([hidden])',
|
||||
),
|
||||
},
|
||||
weather,
|
||||
windCells: wind.gpu?.pathCount ?? wind.paths ?? null,
|
||||
enabled: [...dm.layers].filter(([, e]) => e.enabled).map(([id]) => id),
|
||||
};
|
||||
});
|
||||
|
||||
const sample = async (page, seconds) => {
|
||||
await page.evaluate(() => {
|
||||
window.__qaRenders = 0;
|
||||
window.__qaLong = { count: 0, ms: 0 };
|
||||
const s = window.__godsEyeView.viewer.scene;
|
||||
window.__qaOff = s.postRender.addEventListener(() => window.__qaRenders++);
|
||||
window.__qaObs = new PerformanceObserver((list) => {
|
||||
for (const e of list.getEntries()) {
|
||||
window.__qaLong.count++;
|
||||
window.__qaLong.ms += e.duration;
|
||||
}
|
||||
});
|
||||
window.__qaObs.observe({ entryTypes: ['longtask'] });
|
||||
window.__qaRaf = () => {
|
||||
s.requestRender();
|
||||
window.__qaRafId = requestAnimationFrame(window.__qaRaf);
|
||||
};
|
||||
window.__qaRaf();
|
||||
});
|
||||
await sleep(seconds * 1000);
|
||||
return page.evaluate((seconds) => {
|
||||
cancelAnimationFrame(window.__qaRafId);
|
||||
window.__qaOff();
|
||||
window.__qaObs.disconnect();
|
||||
return {
|
||||
fps: +(window.__qaRenders / seconds).toFixed(1),
|
||||
longTasks: window.__qaLong.count,
|
||||
longMs: Math.round(window.__qaLong.ms),
|
||||
};
|
||||
}, seconds);
|
||||
};
|
||||
|
||||
const setLayer = (page, id, on) =>
|
||||
page.evaluate(
|
||||
async ({ id, on }) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const e = dm.layers.get(id);
|
||||
if (e && Boolean(e.enabled) !== on)
|
||||
await dm.setEnabled(id, on, { origin: 'user' });
|
||||
return !!e;
|
||||
},
|
||||
{ id, on },
|
||||
);
|
||||
|
||||
const diff = (a, b) => {
|
||||
const out = {};
|
||||
const walk = (x, y, prefix) => {
|
||||
for (const key of Object.keys(x)) {
|
||||
const p = prefix ? `${prefix}.${key}` : key;
|
||||
if (x[key] && typeof x[key] === 'object' && !Array.isArray(x[key]))
|
||||
walk(x[key], y?.[key] || {}, p);
|
||||
else if (JSON.stringify(x[key]) !== JSON.stringify(y?.[key]))
|
||||
out[p] = [x[key], y?.[key]];
|
||||
}
|
||||
};
|
||||
walk(a, b, '');
|
||||
return out;
|
||||
};
|
||||
|
||||
const results = { url: rawUrl, label, startedAt: new Date().toISOString() };
|
||||
try {
|
||||
// ── Run A: weather on → exercised → off → baseline check → other layers ──
|
||||
const a = await boot();
|
||||
const others = await a.page.evaluate(() =>
|
||||
[...window.__godsEyeView.dataManager.layers.keys()].filter((id) =>
|
||||
/^flights$|^satellites$|vessel|firms|earthquake|cables|^military$/.test(
|
||||
id,
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log('other layers under test:', others.join(', '));
|
||||
const baseline = await snapshot(a.page);
|
||||
console.log('baseline', JSON.stringify(baseline));
|
||||
for (const id of WEATHER) await setLayer(a.page, id, true);
|
||||
await a.page
|
||||
.waitForFunction(
|
||||
() =>
|
||||
['weather-radar', 'weather-satellite', 'weather-lightning'].every(
|
||||
(id) => {
|
||||
const d = window.__godsEyeView.dataManager.layers
|
||||
.get(id)
|
||||
?.module?.getDiagnostics?.();
|
||||
return d && !d.loading && d.time;
|
||||
},
|
||||
),
|
||||
{ timeout: 120_000 },
|
||||
)
|
||||
.catch(() => console.log(' !! weather not ready'));
|
||||
await sleep(2000);
|
||||
// exercise: history playback, wind reading, storm selection, product switch
|
||||
await a.page.evaluate(() => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
dm.setLayerParams('weather-radar', { play: true }, { origin: 'user' });
|
||||
dm.setLayerParams('wind', { overlay: 'speed' }, { origin: 'user' });
|
||||
dm.setLayerParams('wind', { inspect: true }, { origin: 'user' });
|
||||
dm.setLayerParams(
|
||||
'weather-satellite',
|
||||
{ product: 'clouds' },
|
||||
{ origin: 'user' },
|
||||
);
|
||||
});
|
||||
await sleep(8000);
|
||||
await a.page.evaluate(() => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const storms =
|
||||
dm.layers.get('weather-cyclones')?.module?.getRowControls?.().list
|
||||
?.items || [];
|
||||
if (storms[1])
|
||||
dm.setLayerParams('weather-cyclones', storms[1].params, {
|
||||
origin: 'user',
|
||||
});
|
||||
dm.setLayerParams('weather-radar', { play: true }, { origin: 'user' });
|
||||
});
|
||||
await sleep(4000);
|
||||
const weatherOn = await snapshot(a.page);
|
||||
console.log('weather on', JSON.stringify(weatherOn));
|
||||
results.weatherOnSample = await sample(a.page, 5);
|
||||
for (const id of WEATHER) await setLayer(a.page, id, false);
|
||||
await sleep(4000);
|
||||
const afterOff = await snapshot(a.page);
|
||||
const leak = diff(
|
||||
{ ...baseline, enabled: undefined, weather: undefined },
|
||||
{ ...afterOff, enabled: undefined, weather: undefined },
|
||||
);
|
||||
results.baseline = baseline;
|
||||
results.afterOff = afterOff;
|
||||
results.leak = leak;
|
||||
console.log('after off', JSON.stringify(afterOff));
|
||||
console.log(
|
||||
Object.keys(leak).length
|
||||
? `!! DIFFERENCES after teardown: ${JSON.stringify(leak)}`
|
||||
: 'teardown: scene back to baseline',
|
||||
);
|
||||
results.afterWeather = [];
|
||||
for (const id of others) {
|
||||
const ok = await setLayer(a.page, id, true);
|
||||
await sleep(9000);
|
||||
const m = await sample(a.page, 5);
|
||||
const snap = await snapshot(a.page);
|
||||
results.afterWeather.push({
|
||||
id,
|
||||
ok,
|
||||
...m,
|
||||
primitives: snap.primitives,
|
||||
dataSources: snap.dataSources,
|
||||
});
|
||||
console.log(
|
||||
`after weather + ${id}: fps=${m.fps} long=${m.longTasks}/${m.longMs}ms`,
|
||||
);
|
||||
}
|
||||
results.errorsA = a.errors;
|
||||
await a.page.screenshot({ path: path.join(outDir, 'a-others-on.png') });
|
||||
await a.page.close();
|
||||
|
||||
// ── Run B: control (same others, never had weather) ──
|
||||
const b = await boot();
|
||||
results.control = [];
|
||||
for (const id of others) {
|
||||
const ok = await setLayer(b.page, id, true);
|
||||
await sleep(9000);
|
||||
const m = await sample(b.page, 5);
|
||||
const snap = await snapshot(b.page);
|
||||
results.control.push({
|
||||
id,
|
||||
ok,
|
||||
...m,
|
||||
primitives: snap.primitives,
|
||||
dataSources: snap.dataSources,
|
||||
});
|
||||
console.log(
|
||||
`control + ${id}: fps=${m.fps} long=${m.longTasks}/${m.longMs}ms`,
|
||||
);
|
||||
}
|
||||
results.errorsB = b.errors;
|
||||
await b.page.screenshot({ path: path.join(outDir, 'b-control.png') });
|
||||
await b.page.close();
|
||||
|
||||
console.log(
|
||||
'\nerrors (weather run):',
|
||||
results.errorsA.length ? results.errorsA : 'none',
|
||||
);
|
||||
console.log(
|
||||
'errors (control run):',
|
||||
results.errorsB.length ? results.errorsB : 'none',
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(outDir, 'results.json'),
|
||||
JSON.stringify(results, null, 2),
|
||||
);
|
||||
console.log(`wrote ${path.join(outDir, 'results.json')}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/** Real-canvas wind regression. Run against this candidate's dev server. */
|
||||
import assert from 'node:assert/strict';
|
||||
import puppeteer from 'puppeteer';
|
||||
const browser = await puppeteer.launch({ executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || await puppeteer.executablePath(), headless: false, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${process.env.QA_BASE_URL || 'http://localhost:4173'}/`, { waitUntil: 'domcontentloaded' });
|
||||
const result = await page.evaluate(async () => {
|
||||
const { createWindRendering } = await import('/src/layers/wind/rendering.js');
|
||||
const raf = window.requestAnimationFrame; const caf = window.cancelAnimationFrame;
|
||||
const callbacks = new Map(); let sequence = 0;
|
||||
window.requestAnimationFrame = callback => { callbacks.set(++sequence, callback); return sequence; };
|
||||
window.cancelAnimationFrame = id => callbacks.delete(id);
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = 'position:fixed;left:0;top:0;width:800px;height:600px;z-index:9999;background:#102030';
|
||||
document.body.appendChild(container);
|
||||
const sceneCanvas = { clientWidth: 800, clientHeight: 600 };
|
||||
const viewer = { scene: { canvas: sceneCanvas, camera: {} }, isDestroyed: () => false };
|
||||
const cesium = { Cartesian3: { fromDegrees: (lon,lat) => ({ lon,lat }) }, SceneTransforms: { worldToWindowCoordinates: (scene, point) => ({ x: 400 + point.lon, y: 300 + point.lat }) } };
|
||||
const rendering = createWindRendering({ cesium, container, getViewer: () => viewer });
|
||||
try {
|
||||
rendering.attach(); rendering.start();
|
||||
const idleCallbacks = callbacks.size;
|
||||
rendering.setField({ grid: { nx:1,ny:1,lo1:0,la1:90,dx:360,dy:180 }, u: new Float32Array([10]), v: new Float32Array([0]) });
|
||||
const canvas = container.querySelector('canvas'); const context = canvas.getContext('2d');
|
||||
const tick = time => { const [id, callback] = callbacks.entries().next().value; callbacks.delete(id); callback(time); };
|
||||
tick(16); // First camera signature clears the surface.
|
||||
context.fillStyle = '#ff0000'; context.fillRect(10,10,5,5);
|
||||
tick(32);
|
||||
const alpha = context.getImageData(12,12,1,1).data[3];
|
||||
const count = rendering.getParticleCount();
|
||||
sceneCanvas.clientWidth = 320; sceneCanvas.clientHeight = 240;
|
||||
tick(48);
|
||||
const resizedCount = rendering.getParticleCount();
|
||||
rendering.stop(); rendering.clear();
|
||||
return { idleCallbacks, alpha, count, resizedCount, pending: callbacks.size, afterClear: context.getImageData(12,12,1,1).data[3] };
|
||||
} finally { rendering.destroy(); container.remove(); window.requestAnimationFrame = raf; window.cancelAnimationFrame = caf; }
|
||||
});
|
||||
assert.equal(result.idleCallbacks, 0);
|
||||
assert.ok(result.alpha > 200, 'unchanged canvas dimensions preserve existing trail pixels');
|
||||
assert.ok(result.resizedCount < result.count);
|
||||
assert.equal(result.pending, 0); assert.equal(result.afterClear, 0);
|
||||
console.log(JSON.stringify(result));
|
||||
} finally { await browser.close(); }
|
||||
@@ -88,6 +88,7 @@
|
||||
* --source-base <path> Browser module prefix (default /src)
|
||||
* --url <url> App URL (default http://localhost:4173)
|
||||
* --headful Show the browser (debugging)
|
||||
* --offline-imagery Use bundled texture for known Esri/OSM tile images only
|
||||
* --keep-open Leave the browser open after the run (debugging)
|
||||
*/
|
||||
|
||||
@@ -110,6 +111,7 @@ const SOURCE_BASE = getOpt('--source-base', '/src').replace(/\/$/, '');
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const APP_ORIGIN = new URL(APP_URL).origin;
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const OFFLINE_IMAGERY = getFlag('--offline-imagery');
|
||||
const KEEP_OPEN = getFlag('--keep-open');
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
@@ -201,6 +203,15 @@ async function main() {
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Mode : ${HEADFUL ? 'headful' : 'headless'}\n`);
|
||||
|
||||
// Tracking does not assert imagery geography. This optional fixture leaves
|
||||
// provider metadata, terrain, models and every tracking assertion untouched.
|
||||
// Read before launching so a missing bundled asset fails explicitly.
|
||||
const offlineImageryTile = OFFLINE_IMAGERY
|
||||
? fs.readFileSync(new URL('../node_modules/cesium/Build/Cesium/Assets/Textures/NaturalEarthII/0/0/0.jpg', import.meta.url))
|
||||
: null;
|
||||
let offlineImageryRequests = 0;
|
||||
if (OFFLINE_IMAGERY) console.log(' Imagery : bundled NaturalEarthII fixture (tile images only)');
|
||||
|
||||
// Verify the dev server is up before launching a browser.
|
||||
try {
|
||||
const res = await fetch(APP_URL, { method: 'GET' });
|
||||
@@ -256,6 +267,25 @@ async function main() {
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
// Reuse the one existing interceptor; do not install competing handlers.
|
||||
// Exact HTTPS origins and image paths only: metadata, feature queries,
|
||||
// other providers, terrain and local/model resources continue normally.
|
||||
const knownImageryTile = request.method() === 'GET' && (
|
||||
(url.origin === 'https://services.arcgisonline.com'
|
||||
&& /^\/ArcGIS\/rest\/services\/World_Imagery\/MapServer\/tile\/\d+\/\d+\/\d+$/.test(url.pathname))
|
||||
|| (url.origin === 'https://tile.openstreetmap.org'
|
||||
&& /^\/\d+\/\d+\/\d+\.png$/.test(url.pathname))
|
||||
);
|
||||
if (OFFLINE_IMAGERY && knownImageryTile) {
|
||||
offlineImageryRequests++;
|
||||
request.respond({
|
||||
status: 200,
|
||||
contentType: 'image/jpeg',
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
body: offlineImageryTile,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.origin === APP_ORIGIN && url.pathname === '/api/openai/hud-summary') {
|
||||
request.respond({
|
||||
status: 200,
|
||||
@@ -365,6 +395,19 @@ async function main() {
|
||||
lastMessageAt: null,
|
||||
}));
|
||||
}
|
||||
// Contacts also enables mapped installations. This tracking harness
|
||||
// does not test installation acquisition; a valid empty viewport keeps
|
||||
// public Overpass outages out of aircraft/satellite tracking results.
|
||||
// Preserve the real response shape and all console-error assertions.
|
||||
if (isAppRequest && url.pathname === '/api/military-installations') {
|
||||
return Promise.resolve(jsonResponse({
|
||||
elements: [],
|
||||
saturated: false,
|
||||
elementCap: 700,
|
||||
retrievedAt: new Date().toISOString(),
|
||||
status: 'ready',
|
||||
}));
|
||||
}
|
||||
// CelesTrak TLE groups. Served from a fixed two-satellite catalog so
|
||||
// the satellite assertions are deterministic and the run never depends
|
||||
// on an upstream that rate-limits (it was answering 403 the night this
|
||||
@@ -4082,6 +4125,7 @@ async function main() {
|
||||
|
||||
finishAndExit();
|
||||
} finally {
|
||||
if (OFFLINE_IMAGERY) console.log(` Bundled imagery tiles served: ${offlineImageryRequests}`);
|
||||
if (!KEEP_OPEN) {
|
||||
await browser.close();
|
||||
} else {
|
||||
|
||||
Reference in new issue
Block a user