Merge upstream/main into pr/mapillary-layer

Twenty layers now (weather, wind, cyclones and recent imagery landed
upstream); Street Level sits after Recent Imagery in the right rail and the
Cameras group, and the rail CSS names it beside the other panels.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Fable 5.1 committed 2026-09-23 19:05:59 +01:00
commit 817716f0c1
234 files changed
+45441 -1430

No files matched your search

+65
View File
@@ -99,6 +99,8 @@
"src/tooling/previewServing.test.mjs",
"server/providers/common/request.js",
"server/providers/common/source-root.js",
"src/data/tapAddress.test.mjs",
"src/data/tapAddress.js",
"server/providers/local.js",
"server/providers/military-installations.js",
"server/providers/military-installations/cache.js",
@@ -142,6 +144,8 @@
"src/keylessGeocoder.js",
"src/standalone/placeSearch.js",
"src/ui/panelDisclosure.js",
"src/ui/imagerySplit.js",
"src/ui/imagerySplit.test.mjs",
"src/ui/surfaceKeyboard.js",
"src/ui/surfaceKeyboard.test.mjs",
"src/ui/panelRails.js",
@@ -383,6 +387,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",
@@ -495,6 +503,7 @@
"src/layers/cctv/source.test.mjs",
"src/data/alprCameras.js",
"src/data/alprCameras.test.mjs",
"src/data/dataCredits.test.mjs",
"src/layers/alpr/index.js",
"src/layers/alpr/model.js",
"src/layers/alpr/policy.js",
@@ -548,6 +557,7 @@
"src/maps/catalog.js",
"src/maps/controller.js",
"src/maps/controller.test.mjs",
"src/maps/imageryComparison.js",
"src/maps/credits.js",
"src/maps/defaultSources.js",
"src/maps/google3d.js",
@@ -729,6 +739,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",
@@ -744,6 +775,40 @@
"scripts/qa-director-interactions.mjs",
"src/director/sharing/sharing.test.mjs",
"scripts/qa-director-sharing.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",
"src/layers/recentImagery/model.test.mjs",
"src/layers/recentImagery/catalog.test.mjs",
"src/maps/imageryHost.test.mjs",
"src/layers/recentImagery/rendering.test.mjs",
"src/layers/recentImagery/thumbnails.test.mjs",
"src/layers/recentImagery/index.test.mjs",
"src/ui/imageryBoxTool.test.mjs",
"src/ui/recentImagery.test.mjs",
"src/layers/recentImagery/testDoubles.mjs",
"scripts/qa-recent-imagery.mjs",
"src/ui/mapillaryControls.js",
"src/ui/mapillaryPresentation.js",
"src/ui/mapillaryPresentation.test.mjs",
+130 -4
View File
@@ -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",
@@ -290,6 +291,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",
@@ -397,11 +402,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": []
},
@@ -435,6 +447,7 @@
"src/ui/cctvCalibration.js",
"src/ui/cctvControls.js",
"src/ui/cctvFrames.js",
"src/ui/cctvVideo.js",
"src/ui/cctvPresentation.js"
],
"external": []
@@ -535,6 +548,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",
@@ -562,6 +576,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",
@@ -599,7 +619,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",
@@ -632,7 +654,9 @@
"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",
"src/ui/styles/recent-imagery.css"
],
"external": []
},
@@ -852,6 +876,18 @@
],
"external": ["cesium"]
},
"recent-imagery": {
"exports": ["./layers/recent-imagery"],
"modules": [
"src/layers/recentImagery/index.js",
"src/layers/recentImagery/model.js",
"src/layers/recentImagery/catalog.js",
"src/layers/recentImagery/rendering.js",
"src/layers/recentImagery/thumbnails.js",
"src/maps/imageryHost.js"
],
"external": ["cesium"]
},
"fire-layer": {
"exports": ["./layers/firms"],
"modules": [
@@ -880,6 +916,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",
@@ -888,6 +925,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",
@@ -1049,6 +1087,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",
@@ -1059,7 +1098,7 @@
"src/data/inputOwnership.js",
"src/layers/cctv/sourcePolicy.js"
],
"external": ["cesium"]
"external": ["hls.js", "cesium"]
},
"mapillary-layer": {
"exports": ["./layers/mapillary", "./layers/mapillary/source"],
@@ -1183,13 +1222,15 @@
"src/maps/availability.js",
"src/maps/catalog.js",
"src/maps/controller.js",
"src/maps/imageryComparison.js",
"src/maps/credits.js",
"src/maps/defaultSources.js",
"src/maps/google3d.js",
"src/maps/imagery.js",
"src/maps/registry.js",
"src/maps/terrain.js",
"src/keySetupCore.mjs"
"src/keySetupCore.mjs",
"src/maps/imageryHost.js"
],
"external": ["cesium"]
},
@@ -1249,6 +1290,7 @@
"modules": [
"src/scenes/nepalEvidencePack.js",
"src/data/bhoteKoshiEvent.js",
"src/ui/imagerySplit.js",
"src/data/bhoteKoshiEmbeddedMedia.js",
"src/data/bhoteKoshiLocator.js",
"src/data/bhoteKoshiIncidentPlaces.js",
@@ -1326,6 +1368,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",
@@ -1379,6 +1434,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",
@@ -1461,6 +1517,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",
@@ -1566,6 +1623,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",
@@ -1621,6 +1679,7 @@
"src/maps/availability.js",
"src/maps/catalog.js",
"src/maps/controller.js",
"src/maps/imageryComparison.js",
"src/maps/credits.js",
"src/maps/defaultSources.js",
"src/maps/google3d.js",
@@ -1676,6 +1735,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",
@@ -1704,6 +1764,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",
@@ -1774,6 +1840,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",
@@ -1829,6 +1896,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",
@@ -1857,6 +1931,21 @@
"src/director/authoring.js",
"src/scenes/sharing.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",
"src/app/layers/recentImagery.js",
"src/layers/recentImagery/index.js",
"src/layers/recentImagery/model.js",
"src/layers/recentImagery/catalog.js",
"src/layers/recentImagery/rendering.js",
"src/layers/recentImagery/thumbnails.js",
"src/maps/imageryHost.js",
"src/ui/recentImagery.js",
"src/ui/imageryBoxTool.js",
"src/ui/mapillaryControls.js",
"src/ui/mapillaryPresentation.js",
"src/layers/mapillary/index.js",
@@ -1878,6 +1967,7 @@
"src/app/layers/mapillary.js"
],
"external": [
"hls.js",
"@mapbox/vector-tile",
"cesium",
"egm96-universal",
@@ -1896,6 +1986,8 @@
"exports": ["./application/layers"],
"modules": [
"src/data/bhoteKoshiEvent.js",
"src/ui/imagerySplit.js",
"src/maps/imageryComparison.js",
"src/data/bhoteKoshiEmbeddedMedia.js",
"src/data/bhoteKoshiLocator.js",
"src/data/bhoteKoshiIncidentPlaces.js",
@@ -1905,6 +1997,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",
@@ -1952,6 +2054,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",
@@ -2043,6 +2146,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",
@@ -2148,6 +2252,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",
@@ -2271,6 +2376,26 @@
"src/sources/overpassFeatures.js",
"src/sources/featureSource.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",
"src/app/layers/recentImagery.js",
"src/layers/recentImagery/index.js",
"src/layers/recentImagery/model.js",
"src/layers/recentImagery/catalog.js",
"src/layers/recentImagery/rendering.js",
"src/layers/recentImagery/thumbnails.js",
"src/maps/imageryHost.js",
"src/layers/mapillary/index.js",
"src/layers/mapillary/state.js",
"src/layers/mapillary/policy.js",
@@ -2290,6 +2415,7 @@
"src/app/layers/mapillary.js"
],
"external": [
"hls.js",
"cesium",
"egm96-universal",
"satellite.js",
+15 -11
View File
@@ -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',
File diff suppressed because it is too large. Load diff
+423
View File
@@ -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();
}
+568
View File
@@ -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();
}
+203
View File
@@ -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();
}
+341
View File
@@ -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();
}
+44
View File
@@ -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(); }
+39 -5
View File
@@ -8,6 +8,8 @@ import { projectRoot } from './project-root.mjs';
import { selectMapStartupRoute } from '../src/mapStartup.js';
const ROOT = projectRoot(import.meta.url);
const OPENSKY_AUTH_MODE_DEFAULT = 'oauth';
const OPENSKY_AUTH_MODES = new Set(['basic', 'oauth', 'auto', 'anon']);
export const CREDENTIALS = Object.freeze([
{ name: 'GOOGLE_MAPS_API_KEY', label: 'Google Maps', keychain: [['google-maps-api', 'api-key'], ['google-maps-api', 'default'], ['google-maps-api', 'key']] },
@@ -103,6 +105,21 @@ export function readDoctorDotenvValue(
return String(values[key] ?? '');
}
/** Resolve the OpenSky auth mode using the same environment and dotenv precedence as the launcher. */
export function resolveOpenSkyAuthMode({
authoritativeEnvironment = false,
environment = process.env,
rootDir = ROOT,
} = {}) {
const environmentDefinesMode = Object.prototype.hasOwnProperty.call(environment, 'OPENSKY_AUTH_MODE');
const environmentMode = String(environment.OPENSKY_AUTH_MODE || '').trim();
const dotenvMode = authoritativeEnvironment && environmentDefinesMode
? ''
: readDoctorDotenvValue('OPENSKY_AUTH_MODE', rootDir);
const mode = (environmentMode || dotenvMode).trim().toLowerCase();
return OPENSKY_AUTH_MODES.has(mode) ? mode : OPENSKY_AUTH_MODE_DEFAULT;
}
function hasKeychainItem(service, account) {
if (process.platform !== 'darwin') return false;
const result = spawnSync('security', [
@@ -130,8 +147,25 @@ export function resolveCredential(spec, {
return { configured: false, source: null };
}
export function buildCapabilitySummary(credentials) {
export function buildCapabilitySummary(
credentials,
{ openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT } = {},
) {
const configured = (name) => credentials[name]?.configured === true;
const hasOAuthCredentials = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET');
const requestedOpenSkyMode = String(openSkyAuthMode || '').trim().toLowerCase();
const openSkyMode = OPENSKY_AUTH_MODES.has(requestedOpenSkyMode)
? requestedOpenSkyMode
: OPENSKY_AUTH_MODE_DEFAULT;
const flights = openSkyMode === 'anon'
? 'OpenSky keyless anonymous access (rate-limited)'
: openSkyMode === 'basic'
? 'OpenSky Basic mode selected (credential presence and validity not verified)'
: openSkyMode === 'auto'
? 'OpenSky auto mode selected (runtime credential choice and validity not verified)'
: hasOAuthCredentials
? 'OpenSky OAuth credentials present (runtime mode and validity not verified)'
: 'OpenSky keyless anonymous access (rate-limited)';
const route = selectMapStartupRoute({
googleApiKey: configured('GOOGLE_MAPS_API_KEY') ? 'configured' : '',
cesiumToken: configured('CESIUM_ION_TOKEN') ? 'configured' : '',
@@ -142,9 +176,7 @@ export function buildCapabilitySummary(credentials) {
: route === 'google-ion'
? 'Google Photorealistic 3D Tiles through Cesium ion; Bing and world-terrain stacks available'
: 'Esri World Imagery (keyless satellite basemap) with keyless terrain',
flights: configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET')
? 'OpenSky OAuth credentials present (runtime mode and validity not verified)'
: 'OpenSky OAuth credentials not configured',
flights,
voice: configured('OPENAI_API_KEY') ? 'available' : 'off until an OpenAI key is added',
vessels: configured('AISSTREAM_API_KEY') ? 'live AISStream feed' : 'off until an AISStream key is added',
fires: configured('FIRMS_MAP_KEY') ? 'live NASA FIRMS feed' : 'off until a FIRMS key is added',
@@ -175,7 +207,9 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment
: { available: false, version: null },
dependenciesInstalled,
credentials,
capabilities: buildCapabilitySummary(credentials),
capabilities: buildCapabilitySummary(credentials, {
openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment, rootDir }),
}),
};
}
+44
View File
@@ -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 {