mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge upstream/main into pr/panel-resize
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
commit
11603729dc
234 files changed
+45360
-944
No files matched your search
@@ -101,6 +101,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",
|
||||
@@ -144,6 +146,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",
|
||||
@@ -386,6 +390,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",
|
||||
@@ -498,6 +506,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",
|
||||
@@ -551,6 +560,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",
|
||||
@@ -732,6 +742,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",
|
||||
@@ -746,5 +777,39 @@
|
||||
"src/director/interactions/interactions.test.mjs",
|
||||
"scripts/qa-director-interactions.mjs",
|
||||
"src/director/sharing/sharing.test.mjs",
|
||||
"scripts/qa-director-sharing.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"
|
||||
]
|
||||
@@ -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",
|
||||
@@ -578,7 +598,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"
|
||||
@@ -609,7 +631,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": []
|
||||
},
|
||||
@@ -829,6 +853,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": [
|
||||
@@ -857,6 +893,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",
|
||||
@@ -865,6 +902,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",
|
||||
@@ -1026,6 +1064,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",
|
||||
@@ -1036,7 +1075,7 @@
|
||||
"src/data/inputOwnership.js",
|
||||
"src/layers/cctv/sourcePolicy.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": ["hls.js", "cesium"]
|
||||
},
|
||||
"alpr-cameras": {
|
||||
"exports": ["./layers/alpr"],
|
||||
@@ -1137,13 +1176,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"]
|
||||
},
|
||||
@@ -1203,6 +1244,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",
|
||||
@@ -1280,6 +1322,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",
|
||||
@@ -1333,6 +1388,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",
|
||||
@@ -1415,6 +1471,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",
|
||||
@@ -1520,6 +1577,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",
|
||||
@@ -1575,6 +1633,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",
|
||||
@@ -1630,6 +1689,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",
|
||||
@@ -1658,6 +1718,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",
|
||||
@@ -1729,6 +1795,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",
|
||||
@@ -1784,6 +1851,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",
|
||||
@@ -1811,9 +1885,25 @@
|
||||
"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",
|
||||
"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"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
"@mapbox/vector-tile",
|
||||
"cesium",
|
||||
"egm96-universal",
|
||||
@@ -1831,6 +1921,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",
|
||||
@@ -1840,6 +1932,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",
|
||||
@@ -1887,6 +1989,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",
|
||||
@@ -1978,6 +2081,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",
|
||||
@@ -2083,6 +2187,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",
|
||||
@@ -2205,9 +2310,30 @@
|
||||
"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",
|
||||
"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"
|
||||
],
|
||||
"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',
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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(); }
|
||||
@@ -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']] },
|
||||
@@ -101,6 +103,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', [
|
||||
@@ -128,8 +145,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' : '',
|
||||
@@ -140,9 +174,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',
|
||||
@@ -173,7 +205,9 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment
|
||||
: { available: false, version: null },
|
||||
dependenciesInstalled,
|
||||
credentials,
|
||||
capabilities: buildCapabilitySummary(credentials),
|
||||
capabilities: buildCapabilitySummary(credentials, {
|
||||
openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment, rootDir }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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