Release God's Eye View as open source
No files matched your search
@@ -0,0 +1,121 @@
|
||||
# God's Eye View — environment variables
|
||||
# Copy to .env and fill in your keys. On macOS the launcher can also read keys
|
||||
# from the Keychain (see README); on Linux/Windows use this file or env vars.
|
||||
#
|
||||
# NOTE ON CLIENT-EXPOSED KEYS: GOOGLE_MAPS_API_KEY and CESIUM_ION_TOKEN are
|
||||
# injected into the browser bundle by design (they're used client-side) and
|
||||
# WILL be visible in devtools. Restrict/scope them rather than trying to hide
|
||||
# them (see SECURITY.md). All other keys below stay server-side.
|
||||
|
||||
# Required: Google Maps API key (Map Tiles API must be enabled).
|
||||
# CLIENT-EXPOSED — restrict it (HTTP referrer + API restriction) in Google Cloud.
|
||||
GOOGLE_MAPS_API_KEY=your_google_maps_api_key_here
|
||||
# Optional: opt-in per-IP rate limit for the Google Places cost endpoint
|
||||
# (/api/google/nearby-places), in requests per minute.
|
||||
# DEFAULT IS UNLIMITED — unset (or 0) means no throttling, unchanged behavior.
|
||||
# Set a positive integer to cap per-IP Places quota spend when self-hosting.
|
||||
# Recommended whenever HOST is not localhost. NOTE: this is a per-IP,
|
||||
# process-local guard (in-memory, resets on restart; over-limit requests get a
|
||||
# sanitized 429) — it is NOT a billing cap. For hard spend protection set
|
||||
# provider-side budget alerts: Google Cloud Console -> Billing -> Budgets & alerts
|
||||
# (plus per-API quotas under APIs & Services -> Quotas).
|
||||
# GEV_RATELIMIT_GOOGLE_PER_MIN=60
|
||||
|
||||
# Optional: Cesium ion token for Bing world imagery stacks and Cesium World Terrain.
|
||||
# CLIENT-EXPOSED — use a public assets:read token with URL restrictions.
|
||||
CESIUM_ION_TOKEN=
|
||||
|
||||
# Optional: OpenAI Realtime voice control. Do not prefix with VITE_.
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_REALTIME_MODEL=gpt-realtime-2
|
||||
# Cheaper voice tier, used when the GEV MIC panel's MINI toggle is on.
|
||||
# Model ids and prices are external facts that drift — if OpenAI moves this id,
|
||||
# override it here rather than editing src/voice/voiceCost.js.
|
||||
OPENAI_REALTIME_MODEL_MINI=gpt-realtime-2.1-mini
|
||||
OPENAI_REALTIME_VOICE=marin
|
||||
OPENAI_REALTIME_REASONING_EFFORT=low
|
||||
# Keep only a short conversational window. Current map state is fetched live.
|
||||
OPENAI_REALTIME_CONTEXT_TOKENS=3000
|
||||
OPENAI_REALTIME_CONTEXT_RETENTION=0.5
|
||||
OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
|
||||
# Optional: opt-in per-IP rate limit for the OpenAI cost endpoints
|
||||
# (/api/realtime/token + /api/openai/hud-summary), in requests per minute.
|
||||
# DEFAULT IS UNLIMITED — unset (or 0) means no throttling, unchanged behavior.
|
||||
# Set a positive integer to cap per-IP spend when self-hosting on a network.
|
||||
# Recommended whenever HOST is not localhost. NOTE: this is a per-IP,
|
||||
# process-local guard (in-memory, resets on restart; over-limit requests get a
|
||||
# sanitized 429) — it is NOT a billing cap. For hard spend protection set a
|
||||
# provider-side budget: OpenAI platform -> Settings -> Limits (usage limits).
|
||||
# GEV_RATELIMIT_OPENAI_PER_MIN=30
|
||||
|
||||
# Optional: OpenSky auth mode (oauth | basic | auto | anon)
|
||||
# Default: oauth — requires client credentials below
|
||||
# Set to "anon" to skip aircraft auth (rate-limited but works)
|
||||
OPENSKY_AUTH_MODE=oauth
|
||||
|
||||
# Optional: OpenSky OAuth credentials (from opensky-network.org account dashboard)
|
||||
OPENSKY_CLIENT_ID=
|
||||
OPENSKY_CLIENT_SECRET=
|
||||
|
||||
# Optional: OpenSky credentials JSON file path (alternative to above)
|
||||
# OPENSKY_CREDENTIALS_FILE=/path/to/credentials.json
|
||||
|
||||
# Optional: dev server config
|
||||
PORT=4173
|
||||
# Network binding. Default is localhost — the server is reachable ONLY from
|
||||
# this machine. This is the safe default: the dev server brokers your
|
||||
# configured API keys, so anyone who can reach it can spend your quota.
|
||||
# LAN opt-in: set HOST=0.0.0.0 to share on your local network. WARNING: that
|
||||
# exposes every key-brokering proxy to everyone on the network — use only on
|
||||
# networks you trust, and consider the GEV_RATELIMIT_* throttles above.
|
||||
# HOST=localhost
|
||||
# HOST=0.0.0.0
|
||||
|
||||
# Optional: NASA FIRMS live active fires. SERVER-SIDE ONLY (never reaches the
|
||||
# browser — the client polls the /api/firms proxy). Free key:
|
||||
# https://firms.modaps.eosdis.nasa.gov/api/map_key/
|
||||
# Without a key the FIRMS Active Fires layer is empty ("KEY REQUIRED").
|
||||
# FIRMS_MAP_KEY=
|
||||
|
||||
# Optional: Live AIS vessels layer
|
||||
# Browser fetch path. Keep same-origin so the private stream key stays server-side.
|
||||
VITE_AIS_LIVE_API_URL=/api/ais-live
|
||||
# Client render cap. The server can keep a larger AISStream cache, but drawing
|
||||
# every global vessel at once can overwhelm WebGL on laptops.
|
||||
VITE_AIS_LIVE_MAX_ROWS=12000
|
||||
# Active label cap after screen-space clustering. Labels exist for all rendered
|
||||
# ships, but only clustered winners are shown to keep text readable.
|
||||
VITE_AIS_LIVE_LABEL_MAX_ROWS=900
|
||||
# Private AISStream key for the server-side websocket. Do not prefix with VITE_.
|
||||
AISSTREAM_API_KEY=
|
||||
# Optional AISStream subscription filters.
|
||||
# AISSTREAM_BOUNDING_BOXES=[[[-90,-180],[90,180]]]
|
||||
# AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport
|
||||
# Feed watchdog: silence (ms) after which the feed is REPORTED stale; the socket
|
||||
# is recycled at 2.5x that. Default 120000. The watchdog self-arms only for the
|
||||
# default worldwide subscription — a narrow filter above can be legitimately
|
||||
# quiet, so set this explicitly (sized for your filter) to opt back in.
|
||||
# 0 disables the silence watch entirely.
|
||||
# AISSTREAM_SILENCE_TIMEOUT_MS=120000
|
||||
|
||||
# Optional: TomTom live traffic flow (BYOK freemium — free tier is ~50,000 tile
|
||||
# requests/day, get a key at https://developer.tomtom.com). SERVER-SIDE ONLY:
|
||||
# the browser fetches same-origin /api/tomtom/* and never sees the key.
|
||||
# Keyless fallback: the traffic layer runs its built-in simulation (white dots,
|
||||
# hardcoded per-road-class speeds) — no key required for the layer to work.
|
||||
# TOMTOM_API_KEY=
|
||||
# Optional soft cap on upstream tile fetches per UTC day (default 40000).
|
||||
# Over the cap the proxy serves cached/stale tiles instead of hitting upstream.
|
||||
# TOMTOM_DAILY_TILE_BUDGET=40000
|
||||
|
||||
# Optional: CCTV layer tuning (advanced). Defaults are sensible — leave unset
|
||||
# unless you're customizing the camera source pack.
|
||||
# CCTV_SOURCES_FILE=config/cctv_sources.austin.json # path to a source-pack JSON
|
||||
# CCTV_SOURCES_JSON= # inline source-pack JSON (overrides file)
|
||||
# CCTV_AUSTIN_ROWS_URL= # override the Austin Open Data catalog URL
|
||||
# CCTV_AUSTIN_MAX_SOURCES=36 # cap cameras loaded from the Austin catalog
|
||||
# CCTV_MAX_SOURCES=48 # overall cap on registered cameras
|
||||
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
|
||||
# CCTV_FORCE_AUSTIN= # force Austin-only sources
|
||||
# CCTV_AUTO_CALIBRATE=1 # auto-calibrate camera projection (client flag)
|
||||
# CCTV_DRAPE_MESH=1 # drape frames onto 3D geometry (client flag)
|
||||
@@ -1 +1,12 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
screenshots/
|
||||
qa-shots/
|
||||
output/
|
||||
.gev-logs/
|
||||
.gev-cache/
|
||||
.DS_Store
|
||||
.gstack/
|
||||
3d-models/
|
||||
@@ -0,0 +1,171 @@
|
||||
# Changelog
|
||||
|
||||
This changelog records public product changes. For the authoritative description
|
||||
of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md).
|
||||
|
||||
## [Unreleased] — 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
- Added honest aircraft identity narration: callsign, operator, registration,
|
||||
type, and route come only from selected-contact context, and missing operator,
|
||||
route, or type enrichment is named explicitly.
|
||||
- Added local, publication-compatible copies of the two README PNGs, with source
|
||||
records and third-party-license boundaries in `docs/media/README.md`.
|
||||
- Added regression coverage for aircraft identity narration and optional-key
|
||||
loading feedback.
|
||||
|
||||
### Changed
|
||||
|
||||
- First-run presentation now opens with Detection `DENSE` at 75%, `ELASTIC`
|
||||
allocation, Fade 7%, Outside 1%, scope feather 11%, and aircraft 3D models in
|
||||
`PROXIMITY`. Stored state and share links still override these baselines.
|
||||
- The 17 selected README GIFs remain unchanged and are documented separately
|
||||
from the two owner-published PNGs.
|
||||
- Bundled datacenter and dam snapshots now omit contact-oriented fields and
|
||||
note values containing email or phone identifiers. Feature geometry, names,
|
||||
operator/capacity/river metadata, counts, and ODbL terms are unchanged.
|
||||
- Public documentation and the L9 release matrix no longer reference non-public
|
||||
planning material or repository history.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A missing optional FIRMS key no longer turns the complete Environmental
|
||||
mission into `LOAD FAILED`. The FIRMS row still reports `KEY REQUIRED`, while
|
||||
earthquakes continue to load. Real lifecycle and fetch failures retain
|
||||
failure priority.
|
||||
- The mapped-installations layer retries after an unavailable request when it is
|
||||
enabled or the camera settles.
|
||||
- Aircraft trails attach to the rendered aircraft transform and remain near the
|
||||
rear center across headings. Parked aircraft do not draw a moving head
|
||||
segment.
|
||||
- Grounded aircraft keep validated floor evidence through temporary terrain
|
||||
outages and wait for measured photoreal-surface evidence before a 3D model
|
||||
takes over from its billboard.
|
||||
- Cockpit altitude uses aviation MSL data rather than Cesium render height.
|
||||
|
||||
### Security
|
||||
|
||||
- Production transitive dependencies resolve to patched DOMPurify and
|
||||
protobufjs releases without changing the Cesium version or application APIs.
|
||||
- Production dependency audit reports no known advisories; remaining audit
|
||||
findings are confined to development and QA tooling.
|
||||
|
||||
## [Unreleased] — 2026-08-23
|
||||
|
||||
### Added
|
||||
|
||||
- Added a first-run mission launcher for Contacts, Space Missions,
|
||||
Environmental, and manual exploration.
|
||||
- Added terrain-validity gating and bounded last-known placement for grounded
|
||||
aircraft models.
|
||||
|
||||
### Changed
|
||||
|
||||
- Environmental consistently presents both earthquakes and NASA FIRMS fires,
|
||||
with honest optional-key degradation.
|
||||
- The tracked aircraft trail acceptance bar is visual: roughly rear-center,
|
||||
stable across headings, with minor hull overlap allowed and no conspicuous
|
||||
top, bottom, or lateral projection.
|
||||
|
||||
## [Unreleased] — 2026-08-18 to 2026-08-22
|
||||
|
||||
### Added
|
||||
|
||||
- Added the four-source Map Source tray, share-link v2 state, cockpit/context
|
||||
voice parity, MSL altitude readouts, and close-range tracked aircraft models.
|
||||
- Added the L9 release-candidate matrix, AIS feed watchdog, voice cost controls,
|
||||
satellite classes, and the shared world-overlay host.
|
||||
- Added deterministic first-run, map-source, floor, overlay, tracking, and
|
||||
aircraft-model regression harnesses.
|
||||
|
||||
### Changed
|
||||
|
||||
- Consolidated world labels, cards, tracked readouts, CCTV thumbnails, cable
|
||||
labels, mission labels, and detection presentation under shared allocation and
|
||||
lifecycle rules.
|
||||
- Reduced idle rendering through the render governor and explicit scope mask.
|
||||
- Improved cockpit layout, context restoration, keyless feed honesty, and
|
||||
aircraft 2D/3D handoffs.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed degenerate depth picks, map-source restore states, route-camera motion,
|
||||
bright-ground label readability, grounded display flooring, and cross-layer
|
||||
tracking cleanup.
|
||||
- Fixed stale overlay callbacks, parked-idle render leaks, cable-label sweep
|
||||
starvation, and several share-link state conflicts.
|
||||
|
||||
## [Unreleased] — 2026-08-02 to 2026-08-16
|
||||
|
||||
### Added
|
||||
|
||||
- Added Global Context modes, Cockpit briefing surfaces, Radio context,
|
||||
satellite mission replay, and real per-class aircraft models with adjacent
|
||||
provenance records.
|
||||
- Added a shared screen-space overlay system with bounded allocation for labels,
|
||||
cards, callouts, detection brackets, and selected-object presentation.
|
||||
|
||||
### Changed
|
||||
|
||||
- Unified right-side product controls and responsive cockpit/map layouts.
|
||||
- Migrated public-safe neighborhood geometry to DataSF and tightened safe local
|
||||
development defaults.
|
||||
- Improved proxy resilience, annotation outline bounds, CCTV enable pacing,
|
||||
contact de-emphasis, and deterministic visual stacking.
|
||||
|
||||
## [Unreleased] — July 2026
|
||||
|
||||
### Added
|
||||
|
||||
- Added live NASA FIRMS fires, optional live TomTom traffic, Caltrans and TfL
|
||||
CCTV packs, CCTV viewsheds and direct-manipulation calibration, citywide CCTV
|
||||
cards, Natural Earth regions, analyst queries, and voice routing QA.
|
||||
- Added the end-to-end vertical-datum system for aircraft, vessels, CCTV,
|
||||
annotations, trails, and terrain-aware rendering.
|
||||
- Added aircraft class silhouettes, path-derived display heading, ADSBDB
|
||||
enrichment, cached CelesTrak TLE lookup, and next-ISS-pass prediction.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed elevated-airport aircraft placement, vessel sea-surface placement,
|
||||
close-zoom FIRMS anchors, antimeridian region framing, annotation resolution,
|
||||
cross-layer tracking ownership, and CCTV projection lifecycle issues.
|
||||
|
||||
## [Unreleased] — June 2026
|
||||
|
||||
### Added
|
||||
|
||||
- Added OpenAI Realtime voice control, scene-aware entity context, viewport image
|
||||
grounding, the AI HUD summary, live AIS vessels, infrastructure layers, map
|
||||
source switching, free-text navigation, and server-side data proxies.
|
||||
- Added hybrid map annotations, 3D aircraft, panoptic detection, tracking
|
||||
harnesses, and public data attribution.
|
||||
- Added MIT source licensing, security guidance, contribution guidance, data
|
||||
source notices, and third-party asset boundaries.
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed the experimental AI video-edit style and retained seven deterministic
|
||||
visual styles.
|
||||
- Moved Realtime text-history trimming to the server-side retention policy while
|
||||
keeping only the latest viewport image in conversation context.
|
||||
|
||||
## [0.7.0] — 2026-02-18
|
||||
|
||||
- Added the Bikeshare Pulse layer and panoptic label improvements.
|
||||
- Improved tracked-item boxes, post-render alignment, and CCTV projection
|
||||
quality.
|
||||
- Removed the experimental shift-drag CCTV calibration interaction.
|
||||
|
||||
## [0.6.0] — 2026-02-10
|
||||
|
||||
- Added the initial multi-layer 3D globe experience, visual styles, live
|
||||
aircraft, satellites, earthquakes, CCTV, traffic, FIRMS, infrastructure, and
|
||||
performance controls.
|
||||
- Added entity inspection, tracking, scenes, keyboard controls, and shareable
|
||||
views.
|
||||
|
||||
## [0.1.0] — 2026-02-09
|
||||
|
||||
- Initial project version.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Contributing to God's Eye View
|
||||
|
||||
Thanks for being here. God's Eye View is an open foundation for live spatial intelligence in the browser, and it gets better when more people run it, break it, and extend it.
|
||||
|
||||
## Getting set up
|
||||
|
||||
Use Node.js 24.14.x or 26.x (also enforced by `package.json`).
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bilawalsidhu/gods-eye-view.git
|
||||
cd gods-eye-view
|
||||
nvm install 24.14.0
|
||||
nvm use 24.14.0
|
||||
npm install
|
||||
./scripts/dev-fresh.sh # or: GOOGLE_MAPS_API_KEY="…" npm run dev
|
||||
```
|
||||
|
||||
You need a **Google Maps API key** with the Map Tiles API enabled (see the [README](README.md#-api-keys)). Most data layers work with no other accounts. On macOS the launcher pulls keys from the Keychain; on any platform you can pass them as env vars or use a `.env` (copy `.env.example`).
|
||||
|
||||
Open `http://localhost:4173`. Before sending a PR run `npm run build`, `npm test`, and `npm run test:track` (dev server must be up) — **all three must stay green.**
|
||||
|
||||
## Good first contributions
|
||||
|
||||
The highest-leverage places to jump in:
|
||||
|
||||
- **🌆 Add a CCTV source pack.** Austin is the reference camera source. Adding another city means a clean public camera catalog with coordinates, attribution, and server-registered frame URLs (the proxy only fetches registered URLs — never client-supplied ones, see [SECURITY.md](SECURITY.md)). City packs are the best first lane.
|
||||
- **🛰️ Add or improve a data layer.** Each layer is one self-contained module in `src/data/<layer>.js` implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template.
|
||||
- **🎙️ Extend voice control.** Voice tools are declared server-side (`GEV_REALTIME_TOOLS` in `vite.config.js`) and executed client-side (`src/voice/gevActions.js`). Keep the tool surface tight and the responses honest (confirm only what actually happened).
|
||||
- **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`.
|
||||
- **🐛 Fix bugs / improve the first-run experience.** See [docs/KNOWN-ISSUES.md](docs/KNOWN-ISSUES.md).
|
||||
|
||||
## Architecture in one minute
|
||||
|
||||
- **No framework.** Vanilla JS + [CesiumJS](https://cesium.com/platform/cesiumjs/) + [Vite](https://vitejs.dev/).
|
||||
- **UI lives in `src/ui.js`** (panels, HUD, styles, the control facade). **Layer logic lives in `src/data/<layer>.js`.** Keep them separate.
|
||||
- **Secrets stay server-side.** Anything needing a private key goes through a Vite proxy in `vite.config.js`. The browser only ever sees the Google Maps key (which you restrict) and ephemeral tokens.
|
||||
- `docs/CURRENT-STATE.md` is the authoritative runtime reference — read it first.
|
||||
|
||||
## Coding style
|
||||
|
||||
- ES modules, **2-space indent, single quotes, semicolons.**
|
||||
- JSDoc on exported/public functions.
|
||||
- Match the surrounding code — comment density, naming, and idiom.
|
||||
- Prefer small, reviewable commits. Conventional-commit-style prefixes (`feat:`, `fix:`, `perf:`, `docs:`) are appreciated but not required.
|
||||
|
||||
## Pull requests
|
||||
|
||||
1. Branch off `main`.
|
||||
2. Keep `npm run build`, `npm test`, and `npm run test:track` green and avoid new console errors.
|
||||
3. If you change runtime behavior, update `docs/CURRENT-STATE.md` and `CHANGELOG.md` in the same PR.
|
||||
4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution. **Don't add data you don't have the right to redistribute** — fetch it at runtime instead.
|
||||
5. Describe what you changed and how you verified it (screenshots welcome for anything visual).
|
||||
|
||||
## Ground rules
|
||||
|
||||
- This is a tool for **public** data. Don't add scraping of sources whose terms forbid it, private/paywalled datasets, or anything that misrepresents public-data inference as authoritative intelligence.
|
||||
- Be decent to each other. Assume good faith, keep it constructive.
|
||||
|
||||
By contributing, you agree your contributions are licensed under the project's [MIT License](LICENSE).
|
||||
@@ -0,0 +1,138 @@
|
||||
# Data Sources & Attribution
|
||||
|
||||
God's Eye View's **code** is [MIT](LICENSE)-licensed. **The MIT grant covers the source code only — it does NOT extend to third-party data or visual assets.** Every third-party source keeps its own license and terms. This file documents the live and bundled data sources; bundled 3D-model provenance is recorded in [`public/models/README.md`](public/models/README.md).
|
||||
|
||||
How to read this:
|
||||
|
||||
- **The non-permissive datasets are carved out, not omitted.** Some bundled data (e.g. TeleGeography, CC BY-NC-SA) isn't MIT-compatible. Rather than hide it, we **bundle it with a clear license carve-out** so the app works out of the box — but it stays under the provider's terms.
|
||||
- **If your use doesn't fit a dataset's license, remove that dataset.** Most importantly: TeleGeography is **NonCommercial** — commercial users must delete it (or license it from TeleGeography). It's one self-contained folder.
|
||||
- **Attribution is shown in-app** and listed here. Keep it intact. The required Google/Cesium credit renders on the on-globe credit line (bottom-left, `#cesium-credits`), and every per-layer credit below is registered into the expandable **"Data attribution"** lightbox on that line (`src/data/dataCredits.js` → `viewer.creditDisplay.addStaticCredit`). Both stay visible in clean-view and recording modes.
|
||||
- **Bundled model attribution lives beside the model files.** [`public/models/README.md`](public/models/README.md) records each shipped model's creator, source, license, and modification status.
|
||||
- **README media provenance lives beside the media.** [`docs/media/README.md`](docs/media/README.md) records the creator and likeness permissions for the 17 capture GIFs, plus the public source, publication permission, and reuse boundary for the two README PNGs. Third-party content visible within this media remains subject to its provider or owner's terms.
|
||||
|
||||
---
|
||||
|
||||
## Live sources (fetched at runtime — not stored in this repo)
|
||||
|
||||
| Source | Used for | License / terms | Attribution |
|
||||
|--------|----------|-----------------|-------------|
|
||||
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
|
||||
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., *"Bringing Up OpenSky"*, IPSN 2014 + opensky-network.org |
|
||||
| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` |
|
||||
| **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) |
|
||||
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
|
||||
| **CelesTrak** | Satellite TLEs (SGP4) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
|
||||
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
|
||||
| **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" |
|
||||
| **OpenStreetMap (Overpass API)** | Road geometry for traffic | ODbL 1.0 | "© OpenStreetMap contributors" |
|
||||
| **TomTom Traffic API** (flow vector tiles) | Live congestion coloring for the traffic layer (optional, BYOK) | [TomTom for Developers terms](https://developer.tomtom.com) (proprietary, your own key; quotas depend on the current account plan) | "Traffic flow data © TomTom" — registered when live mode activates |
|
||||
| **OpenStreetMap (Overpass API)** | Viewport-bounded mapped installation context for Global Context | ODbL 1.0 | "© OpenStreetMap contributors" (incomplete mapped context) |
|
||||
| **OpenStreetMap (Nominatim)** | Reverse-geocoded place label in the cockpit Local Info page | ODbL 1.0 + Nominatim usage policy | "© OpenStreetMap contributors" |
|
||||
| **Open-Meteo** | Current weather in the cockpit Local Info page and cockpit-local dynamic atmospheric effects | [CC BY 4.0 data licence and adjacent-link attribution requirement](https://open-meteo.com/en/licence) | Linked "Weather data by Open-Meteo.com" beside the displayed local data |
|
||||
| **Google News RSS** | Primary locality-matched headlines in the cockpit Regional News page | [Google News Terms of Service](https://www.google.com/intl/en_us/terms_google_news.html) restrict use to personal, noncommercial use; linked articles remain third-party publisher content and retain publisher terms | "Google News RSS" plus each article's linked publisher/domain |
|
||||
| **GDELT Project DOC 2.0** | Fail-soft fallback for location-matched cockpit headlines | [GDELT Terms of Use](https://www.gdeltproject.org/about.html#termsofuse): unrestricted academic/commercial/governmental dataset use, with citation and link required; linked articles retain publisher terms | "GDELT Project" plus each article's linked publisher/domain |
|
||||
| **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" |
|
||||
| **Caltrans (cwwp2.dot.ca.gov)** | CCTV camera catalogs + frames, California districts | Public Caltrans traffic camera data | "Caltrans — cwwp2.dot.ca.gov" (courtesy) |
|
||||
| **TfL Open Data (JamCams)** | CCTV camera catalog + frames, London | [TfL Open Data terms](https://tfl.gov.uk/info-for/open-data-users/) — attribution REQUIRED | "Powered by TfL Open Data. Contains OS data © Crown copyright and database rights" |
|
||||
| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
|
||||
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
|
||||
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
|
||||
|
||||
### Notes on the live sources
|
||||
|
||||
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
|
||||
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
|
||||
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
|
||||
- **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state.
|
||||
- **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover.
|
||||
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
|
||||
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a configurable daily tile-budget governor, default 40,000 requests) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. Set that governor within the current allowance for your TomTom account; the application default is a safety limit, not a promise of free quota. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
|
||||
- **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable.
|
||||
- **Global Context installation context.** `/api/military-installations` queries only an allow-listed subset of OSM `military=*` and `landuse=military` features inside a maximum 10° non-dateline viewport. It caches and may serve stale mapped context, but it is neither a global installation database nor evidence of capability, activity, or absence. User-requested Google Places results remain separately sourced candidates unless their returned types explicitly establish military classification; generic offices, museums, and similarly ambiguous matches are excluded from military proximity counts.
|
||||
- **Cockpit regional briefing.** `/api/regional-brief` rounds aircraft coordinates into 0.1° cache cells, caches results for five minutes, and serializes Nominatim calls at no more than one request per second. Google News RSS is queried with the resolved locality/region first; GDELT is used only when that RSS query fails or is empty. Google's published Google News terms restrict that source to personal, noncommercial use, so commercial deployments must disable/replace it or obtain separate permission; GDELT permits commercial dataset use with citation. The Data attribution popover identifies the active headline sources; article links retain publisher attribution. Headlines are location-query matches, not verified incidents, risk rankings, or evidence that a location is safe. Empty, partial, stale, and unavailable source states remain distinct. Open-Meteo supplies current conditions independently of the news source. `WX OFF` disables cockpit weather rendering only; the Local Info briefing still fetches its source-backed weather values and displays the required linked Open-Meteo credit.
|
||||
- **Dynamic weather presentation.** While cockpit mode is active, `/api/weather-effects` requests current Open-Meteo observations for the aircraft/camera location, rounds coordinates into 0.1° cache cells, caches results for five minutes, and may retain a stale observation for up to 30 minutes during a transient outage. WMO condition code selects the visual family; observed cloud cover, precipitation, visibility, wind speed, and wind direction bound its strength and motion. Missing or expired weather renders no synthetic atmospheric effect, and normal globe view never renders the weather overlay.
|
||||
|
||||
---
|
||||
|
||||
## Bundled snapshots (committed under `src/data/local_data/`)
|
||||
|
||||
Static datasets shipped in the repo for an out-of-the-box experience. **None are MIT** — each keeps its own license (see the carve-out in [LICENSE](LICENSE)). Each folder also has its own provenance README.
|
||||
|
||||
| Dataset | Folder | License | Commercial use? | Attribution |
|
||||
|---------|--------|---------|-----------------|-------------|
|
||||
| **Datacenters** (~4.3K) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | "© OpenStreetMap contributors" |
|
||||
| **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | "© OpenStreetMap contributors" (+ Open Infrastructure Map) |
|
||||
| **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" |
|
||||
| **Natural Earth physical regions** (1,046 land + 292 marine named polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
|
||||
| **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) |
|
||||
|
||||
### ⚠️ TeleGeography is bundled but NonCommercial
|
||||
|
||||
The submarine-cable GeoJSON is **CC BY-NC-SA 3.0** (Attribution-**NonCommercial**-**ShareAlike**). It is bundled so the cables layer works out of the box, but it is **not covered by this project's MIT license**. CC BY-NC-SA permits redistribution with attribution and share-alike — which is exactly how it ships here — but the **NonCommercial** clause means:
|
||||
|
||||
> If you use God's Eye View commercially, delete `src/data/local_data/telegeography_submarine_cables/` (or obtain a commercial license from TeleGeography). It is one self-contained folder; the rest of the app runs without it.
|
||||
|
||||
The richer structured dataset is licensed separately/commercially by TeleGeography.
|
||||
|
||||
### ODbL share-alike (datacenters, dams)
|
||||
|
||||
The OSM-derived datasets are under the **Open Database License**. ODbL's share-alike applies to the **data / derived database, not this MIT-licensed code** — the two coexist (exactly how Open Infrastructure Map ships: MIT software + ODbL data). If you publicly distribute a *modified* version of these databases, you must offer it under ODbL. Keep the "© OpenStreetMap contributors" notice (link: https://www.openstreetmap.org/copyright).
|
||||
|
||||
The bundled public-release copies omit contact-oriented tags and any note value
|
||||
that contains an email or phone identifier. Those fields are not used by the
|
||||
application. This privacy transform does not change feature geometry, identity,
|
||||
name, operator, capacity, or river metadata, and the resulting derived databases
|
||||
remain under ODbL 1.0.
|
||||
|
||||
### NASA FIRMS acknowledgement
|
||||
|
||||
> We acknowledge the use of data and/or imagery from NASA's Fire Information for Resource Management System (FIRMS) (https://earthdata.nasa.gov/firms), part of NASA's Earth Observing System Data and Information System (EOSDIS).
|
||||
|
||||
FIRMS active fires are **fetched live at runtime** (CC0 / U.S. public domain data): the
|
||||
`/api/firms` server-side proxy merges the three VIIRS NRT sources (NOAA-20, NOAA-21,
|
||||
Suomi-NPP) clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY
|
||||
transaction quota. Requires a free `FIRMS_MAP_KEY`
|
||||
(https://firms.modaps.eosdis.nasa.gov/api/map_key/); the layer is empty without it.
|
||||
The former bundled 2026-05-25 snapshot was removed 2026-07-16.
|
||||
|
||||
### Natural Earth physical regions (`natural_earth/`)
|
||||
|
||||
Curated from the **Natural Earth 10m physical vectors** (https://www.naturalearthdata.com/ —
|
||||
fetched from the canonical `nvkelso/natural-earth-vector` GitHub repo, commit
|
||||
`ca96624a56bd078437bca8184e78163e5039ad19`, 2026-07-28): `ne_10m_geography_regions_polys`
|
||||
(mountain ranges, deserts, plateaus, peninsulas, islands, …) → `regions.json` and
|
||||
`ne_10m_geography_marine_polys` (seas, gulfs, straits, bays) → `marine.json`. They back the
|
||||
voice-annotation resolver's named-natural-region lookup (`src/data/naturalEarthRegions.js`),
|
||||
so "outline the Alps" draws the real range polygon offline.
|
||||
|
||||
Curation (provenance in each file's `meta` header): named features only, outer rings only,
|
||||
Douglas-Peucker simplified at ~0.01° with coordinates rounded to 3 decimals, sub-20 km²
|
||||
MultiPolygon crumbs and zero-area sliver artifacts dropped (7.3 MB source → 2.5 MB pack).
|
||||
|
||||
Natural Earth is **public domain** (no permission needed, no attribution legally required —
|
||||
https://www.naturalearthdata.com/about/terms-of-use/). We credit anyway: "Made with Natural
|
||||
Earth". Registration in the in-app `dataCredits.js` attribution list ships with the resolver
|
||||
wiring (see below).
|
||||
|
||||
### DataSF Analysis Neighborhoods (`neighborhoods/`)
|
||||
|
||||
`neighborhoods/san-francisco.json` bundles the City & County of San Francisco's official
|
||||
**"Analysis Neighborhoods"** dataset (41 neighborhood polygons; DataSF dataset `j2bu-swwd`,
|
||||
catalog map view
|
||||
[`p5b7-5n3h`](https://data.sfgov.org/Geographic-Locations-and-Boundaries/Analysis-Neighborhoods-Map/p5b7-5n3h)).
|
||||
It backs the voice-annotation resolver's offline neighborhood-boundary lookup
|
||||
(`src/data/neighborhoodPolygons.js`), so "outline Chinatown" draws the city's real
|
||||
boundary polygon with no network dependency.
|
||||
|
||||
The dataset is licensed **PDDL 1.0** (Open Data Commons Public Domain Dedication and
|
||||
License — public domain; the DataSF metadata declares `licenseId: "PDDL"`). No attribution
|
||||
is legally required; we note the source here and in the folder's `SOURCE.md`, which records
|
||||
the retrieval date (2026-07-30), exact download URL, license evidence, and the
|
||||
deterministic transform (`scripts/build-sf-neighborhoods.mjs`: `nhood` → `name`, ~2 m
|
||||
Douglas-Peucker simplification, 6-decimal rounding).
|
||||
|
||||
---
|
||||
|
||||
## In-app attribution
|
||||
|
||||
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
|
||||
@@ -0,0 +1,54 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Bilawal Sidhu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
NOTE ON THIRD-PARTY DATA AND ASSETS — THE MIT LICENSE ABOVE COVERS THE SOURCE
|
||||
CODE ONLY.
|
||||
|
||||
The datasets bundled under src/data/local_data/, and all data fetched from
|
||||
third-party providers at runtime, are owned by their respective sources and
|
||||
are NOT licensed under MIT. They are included/visualized for convenience and
|
||||
remain subject to their own licenses and terms. Notably:
|
||||
|
||||
- TeleGeography Submarine Cable Map (src/data/local_data/
|
||||
telegeography_submarine_cables/): CC BY-NC-SA 3.0 — NonCommercial +
|
||||
ShareAlike. NOT for commercial use. If you use this project commercially,
|
||||
remove these files or obtain a commercial license from TeleGeography.
|
||||
- Datacenters and Dams (OpenStreetMap / Open Infrastructure Map extracts):
|
||||
Open Database License (ODbL) 1.0 — attribution + share-alike on the data.
|
||||
- NASA FIRMS active-fire snapshot: CC0 / U.S. public domain (citation
|
||||
requested).
|
||||
|
||||
Live sources (Google Maps, OpenSky, adsb.lol, AISStream, CelesTrak, USGS,
|
||||
OSM Overpass, City of Austin, GBFS) are fetched at runtime under each
|
||||
provider's terms; some (e.g. Google Maps, OpenSky) restrict commercial use
|
||||
and require you to supply your own credentials.
|
||||
|
||||
In short: the MIT grant does not extend to any third-party data. See
|
||||
DATA_SOURCES.md for the full per-source license + attribution summary, and
|
||||
remove any dataset whose terms don't fit your use.
|
||||
|
||||
The 3D models under public/models/ are also third-party works and are NOT
|
||||
licensed under MIT. They remain under their individual licenses. See
|
||||
public/models/README.md for the author, source, license, modification notice,
|
||||
and attribution covering each model file.
|
||||
@@ -1,36 +1,371 @@
|
||||
# God's Eye View
|
||||
<div align="center">
|
||||
|
||||
From the project behind the viral God's Eye View series, formerly known as WorldView.
|
||||
# 🌐 God's Eye View
|
||||
|
||||
<p>
|
||||
<img src="assets/god-view-globe.jpg" alt="Live flights, vessels and submarine cables on the globe" width="49%">
|
||||
<img src="assets/god-view-palm.jpg" alt="Thermal visual style over Palm Jumeirah" width="49%">
|
||||
</p>
|
||||
<p>
|
||||
<img src="assets/god-view-flights.jpg" alt="Thermal visual style over live air traffic" width="49%">
|
||||
<img src="assets/god-view-cctv.jpg" alt="Public camera feeds projected into 3D" width="49%">
|
||||
</p>
|
||||
### A spy-satellite simulator in your browser — then you realize the sources are public and the data is real.
|
||||
|
||||
You asked, so it's happening. Code is in final testing ahead of public release.
|
||||
Photorealistic 3D globe. Live aircraft, ships, satellites, earthquakes, traffic, and public cameras, with clearly labeled modeled views where a live feed is unavailable. Hands-free voice control powered by a realtime AI agent.
|
||||
|
||||
**Updates**
|
||||
*No place left behind.*
|
||||
|
||||
- **Aug 18, 2026** — release candidate in trusted testing (thanks!); public release by August 24, 2026
|
||||
- **Jul 30, 2026** — launch date set: August 18, 2026
|
||||
- **Jun 22, 2026** — placeholder repo created
|
||||

|
||||
|
||||
⭐ Star this repo. Get notified by email via [Map the World](https://maptheworld.ai/).
|
||||
<a href="https://www.youtube.com/@bilawalsidhu">
|
||||
<img src="docs/media/youtube-popular-videos.png" alt="The God's Eye View video series on YouTube" width="100%">
|
||||
</a>
|
||||
|
||||
<img src="assets/open-source-survey.png" alt="Community survey about open sourcing WorldView and God's Eye View" width="460">
|
||||
▶️ **From the project behind the viral God's Eye View series** *(formerly WorldView)* — [5M+ on YouTube](https://youtube.com/playlist?list=PL6qSg2I-7_koPbDnSMo0QeeHX_RknA2uv&si=nBGYMoHWQw41v93Q)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
Live open source spatial intelligence on a photorealistic 3D globe. Half the magic is that it feels like a forbidden cockpit — then you realize the sources are public and the code is inspectable.
|
||||
<div align="center">
|
||||
|
||||
<img src="assets/god-view-hero.gif" alt="God's Eye View live globe preview" width="100%">
|
||||
**[Quick Start](#-quick-start) · [First Five Minutes](#-the-first-five-minutes) · [Talk to It](#-talk-to-it) · [What's Live](#-whats-on-the-globe) · [Under the Hood](#-under-the-hood) · [Keys & Costs](#-api-keys)**
|
||||
|
||||
▶️ Watch the video series [[5M+ on YouTube](https://youtube.com/playlist?list=PL6qSg2I-7_koPbDnSMo0QeeHX_RknA2uv&si=nBGYMoHWQw41v93Q); [25M+ on Socials](https://www.google.com/search?q=god%27s+eye+view)].
|
||||
</div>
|
||||
|
||||
<a href="https://www.youtube.com/@bilawalsidhu">
|
||||
<img src="assets/youtube-popular-videos.png" alt="Bilawal Sidhu YouTube popular videos" width="100%">
|
||||
</a>
|
||||
---
|
||||
|
||||
## 🌍 Why This Exists
|
||||
|
||||
**You asked, so it's happening.** God's Eye View is open source. Track the world live. Talk to it. Break it. Extend it.
|
||||
|
||||
Most open-source intelligence is a pile of browser tabs. The signals are abundant, but the *interface* is the bottleneck. God's Eye View turns those signals into a **place**: the world is already broadcasting — flight transponders, ship beacons, orbital elements, seismographs, public cameras — and this makes it visible on a photorealistic 3D Earth in real time. No classified clearance required; it's public signal all the way down, and the interface runs in your browser, under your control.
|
||||
|
||||
> Half the magic is that it looks like a forbidden cockpit. The other half is that every line of code is inspectable.
|
||||
|
||||
The live layers are grounded in public feeds: the airliner crossing your screen is reporting telemetry, the camera is installed at a published location, and the ISS position is propagated from current orbital elements. The client deliberately renders flights one polling interval behind real time so it can interpolate smoothly. Some experiences are modeled rather than live: keyless traffic is labeled as a simulation, camera poses are estimated until calibrated, and launch ascent playback is marked `RECONSTRUCTED ESTIMATE`. Each layer keeps its source and freshness state visible, including partial, delayed, simulated, and unavailable states.
|
||||
|
||||
---
|
||||
|
||||
## 🎛️ What This Thing Does
|
||||
|
||||
- **🛩️ Cockpit view:** Ride inside a tracked flight — the camera holds the terrain under you all the way down.
|
||||
- **📡 Contacts:** A 250 km roster of everything near your target — step through live aircraft and drop into any cockpit.
|
||||
- **🎯 Click-to-track anything:** Camera locks on, draws a fading trail, surfaces full metadata — and a tracked fire or vessel hands you off to the nearest live camera in one click.
|
||||
- **🖊️ Voice whiteboard:** Speak annotations onto the world — real boundary polygons, marks, and routes.
|
||||
- **🛫 3D hangar:** Real per-class aircraft models — 787, ATR-72, Citation, Bell 206, MQ-9 — and a tracked contact swaps from glyph to 3D model as you close in.
|
||||
- **🎨 Reskin reality:** GLSL sensor looks over the normal globe — CRT, NVG, FLIR/thermal, Noir, Snow.
|
||||
- **🟩 Detection overlay:** Screen-space bounding boxes and IDs on everything in view.
|
||||
- **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry.
|
||||
- **🌐 Global Context:** Stage the full situational picture with one switch — and get your exact view back when you leave.
|
||||
- **🎥 Scene director:** Capture cinematic camera tours for clips and demos.
|
||||
- **🔗 Share Links:** Camera, style, layers, and even one tracked target serialize into a URL — a live target is a handoff, not a bookmark.
|
||||
- **🏠 Reset Globe:** One control — or one sentence — back to the full Earth.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Quick Start
|
||||
|
||||
Requires Node.js 24.14.x or 26.x (enforced by `package.json`).
|
||||
|
||||
1. Copy `.env.example` → `.env` and set `GOOGLE_MAPS_API_KEY`.
|
||||
2. Install and run:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev -- --host localhost --port 4173
|
||||
```
|
||||
|
||||
3. Open **`http://localhost:4173`**. Cold start settles in under two seconds on a recent laptop (median 1.86 s in a point-in-time M5/Chrome capture — [docs/PERFORMANCE.md](docs/PERFORMANCE.md); a comparison baseline, not a hardware requirement). A first-run card offers to stage a mission for you — **Live Contacts**, **Space Missions**, **Environmental** — or leaves you to explore manually.
|
||||
|
||||
**That one key is the whole entry fee.** Everything in this README is color-coded — 🟢 needs nothing · 🟡 free key · 🔴 metered — and Google Maps is the only 🔴 you need: it buys the photorealistic planet, and most of the globe lights up 🟢 from there. Full map in [Keys & Costs](#-api-keys).
|
||||
|
||||
The dev server binds to **localhost** — your keys stay on your machine. Sharing on a LAN and the cost rails live in [Keys & Costs](#-api-keys) and [SECURITY.md](SECURITY.md).
|
||||
|
||||
**macOS shortcut:** `./scripts/dev-fresh.sh` clears the Vite cache and pulls your keys straight from the Keychain.
|
||||
|
||||
---
|
||||
|
||||
## 🕐 The First Five Minutes
|
||||
|
||||
No account, no signup. The first-run card will offer to stage a mission for you — or run this gauntlet yourself. Somewhere in these five minutes it stops feeling like a demo:
|
||||
|
||||
1. **Light up the sky.** Take the **Live Contacts** mission (or turn on **Flights** yourself) — thousands of live aircraft, gliding on real telemetry, detection mesh already reading the scene. Click one: the camera locks on, a trail draws behind it, and its live telemetry card comes up.
|
||||
2. **Take the controls.** Hit **COCKPIT** on your tracked plane and ride it down, switching sensors mid-flight: NVG into Ironbow FLIR.
|
||||
|
||||

|
||||
|
||||
3. **Drop into a busy airport.** Search one and descend to the taxiways with **3D** aircraft on — grounded contacts, taxi trails, the whole apron working in real time.
|
||||
|
||||

|
||||
|
||||
4. **Look through a public camera.** Turn on **CCTV** over Austin, London, or California. The feeds aren't webcam embeds — they project *into* the 3D city. Cycle coverage to **VIEWSHED** and every camera draws its estimated coverage volume — where it reaches, and where it goes blind.
|
||||
|
||||

|
||||
|
||||
5. **Track something in orbit.** Turn on **Satellites** and click the ISS — you ride along at orbital distance, orbit ring and all.
|
||||
|
||||

|
||||
|
||||
6. **Switch the optics.** Tap `1`–`7` — CRT, NVG, FLIR — and the whole live planet re-renders through a different sensor.
|
||||
|
||||

|
||||
|
||||
7. **Talk to it** *(needs an OpenAI key)*: *"Take me to LAX and select the nearest airborne aircraft."*
|
||||
8. **Come home.** Hit **Reset Globe** — or just say *"zoom out to a globe view."*
|
||||
|
||||
**Keyboard:** `1`–`7` visual styles · `H` HUD · `D` detection · `C` cockpit · `Esc` out.
|
||||
|
||||
---
|
||||
|
||||
## 🛩️ The Cockpit
|
||||
|
||||
> Every plane should let you do this.
|
||||
|
||||
Real-time cockpit mode, built from live flight data: the camera rides your contact with real terrain holding underneath, all the way down — sensor styles come along for the ride, and **Contacts** keeps the 250 km roster one click away: jump plane to plane and fall straight into the next cockpit.
|
||||
|
||||

|
||||
|
||||
The cockpit even carries its own briefing strip: nearby live signals, regional headlines, and real local weather — with an opt-in **WX** mode that renders volumetric clouds from actual observations around your aircraft.
|
||||
|
||||

|
||||
|
||||
*Why cockpit mode exists: you're riding a real aircraft over real terrain — and you get to pick which sensor you see the world through.*
|
||||
|
||||
---
|
||||
|
||||
## 🎙️ Talk to It
|
||||
|
||||
> Voice needs an **OpenAI key**. Without one the entire app still runs — the mic button just reports voice is unavailable. The same key drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move.
|
||||
|
||||
Click **GEV MIC**, grant the microphone, and just talk. This is more than a voice-controlled remote:
|
||||
|
||||
- **🧠 It knows what it's looking at.** The agent pulls live scene context before answering — including coordinates, street names, active layers, and view scale. Ask *"what city is this?"* mid-flight and it knows.
|
||||
- **🎯 Entity Q&A.** Click any plane, ship, or datacenter and ask *"what's this?"* It answers using the object's live telemetry.
|
||||
- **👁️ Visual grounding.** At street level, it reads a viewport screenshot to identify legible signage and building names, and is instructed never to hallucinate labels.
|
||||
- **🎬 Cinematic framing.** *"Show me the planes overhead"* pulls the camera back, angles it, and frames the live traffic like a director.
|
||||
- **🔒 Honest and secure.** The agent only confirms actions that succeeded. Your `OPENAI_API_KEY` never touches the browser; the client only gets a short-lived session token.
|
||||
|
||||
Twenty-eight tools, four jobs — the commands below come straight from the product's voice test suite and tool playbook:
|
||||
|
||||
**🎥 Direct it** — drone-operator camera verbs:
|
||||
> 🗣️ *"Take me to Tokyo."* · *"Orbit around this area slowly."* · *"Draw the walking route from the Capitol to Zilker Park."* → *"Fly the route we just drew."* · *"Zoom out to a globe view."*
|
||||
|
||||
**🖊️ Annotate it** — a whiteboard over the real world:
|
||||
> 🗣️ *"Outline the state of Texas."* · *"Annotate the Texas State Capitol and its grounds"* — it draws the **actual enclosing boundary**, not a circle. · *"How far is the Eiffel Tower from the Louvre?"* — a connector arrow appears and it speaks the distance. Everything persists until you say *"clear the map."*
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
**🔎 Interrogate it** — analyst queries against the live layers:
|
||||
> 🗣️ *"How many flights are over Texas right now?"* · *"Which ships are headed to Oakland?"* · *"What is the biggest fire near Los Angeles?"* · *"Is anything flying above forty thousand feet?"* · *"When does the ISS pass over next?"*
|
||||
|
||||
**🎛️ Operate it** — the whole console, hands-free:
|
||||
> 🗣️ *"Switch to night vision and turn on the flights layer."* · *"Turn on the camera viewsheds."* · *"Play a news radio station near Austin."* · *"Track that plane."* → *"Enter Cockpit."*
|
||||
|
||||
**And the rapid-fire tier** — one sentence each:
|
||||
> 🗣️ *"Show me global infrastructure."* (stages the layers and pulls back to the globe) · *"Play Orbital Watch."* (a full cinematic scene) · *"Set detection density to fifty percent."* · *"Next contact — helicopters only."* (mid-cockpit) · *"Show me space missions."* · *"Switch to Bing aerial."* · *"Sharpen the image a touch."* · *"Switch to the tactical layout."* · *"What's turned on right now?"*
|
||||
|
||||

|
||||
|
||||
*Ask for radio near anywhere and the globe starts broadcasting — every station is a real place you can fly to.*
|
||||
|
||||
---
|
||||
|
||||
## 🛰️ What's on the Globe
|
||||
|
||||
Thirteen live layers. **Ten of them need nothing at all** — no key, no account, no signup. (🟢 nothing · 🟡 free key · 🔴 metered.)
|
||||
|
||||
| Layer | What you get | Source | Auth |
|
||||
|-------|--------------|--------|------|
|
||||
| 🗺️ **Map Stack** | Google Photorealistic 3D, Bing aerial, OSM | Google / Ion / OSM | 🔴 Google (required) · 🟡 ion for Bing · 🟢 OSM |
|
||||
| ✈️ **Live Flights** | Thousands of live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) |
|
||||
| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 |
|
||||
| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 |
|
||||
| 🛰️ **Satellites** | A roughly 840-object core catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
|
||||
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
|
||||
| 🚗 **Traffic** | Live congestion driving per-vehicle flow at street level — dive below ~8 km and the dots color to real jams. Keyless it's an approximate simulation | TomTom + OSM | 🟢 (🟡 TomTom makes it real — get one) |
|
||||
| 📹 **CCTV Mesh** | ~800 public cameras projected *into* the 3D space — Austin · California (Caltrans) · London (TfL). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
|
||||
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
||||
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
|
||||
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) |
|
||||
| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
|
||||
|
||||

|
||||
|
||||
*The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED ESTIMATE`, scrubbable 0.25×–4×.*
|
||||
|
||||
**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,351), Dams (704), and Submarine Cables (712).
|
||||
|
||||

|
||||
|
||||
**Missing a layer you want?** Open an issue — or add it and send the PR.
|
||||
|
||||
---
|
||||
|
||||
## 🎖️ Field Missions
|
||||
|
||||
Once the basics click, run these:
|
||||
|
||||
| Mission | How |
|
||||
|---|---|
|
||||
| **🚁 Ask the planet** | *"Why are all these military helicopters flying in circles?"* Select a military track — it silently backfills ~24 h of real trace history — and see what it's been doing, resolved as stacked 3D loops. |
|
||||
| **✈️ Final approach** | Click-track an airliner lining up for a runway, hop into the **cockpit**, and ride it down. |
|
||||
| **🌃 Night watch** | Fly to your own city, switch to **NVG**, and let the detection mesh and HUD read the scene. |
|
||||
| **🚢 Port call** | Vessels on over the Port of Long Beach. Click a tanker for its tactical card and wake trail — then hit **NEAREST** in the CCTV panel and look at the same water through a public camera. |
|
||||
| **📻 Tokyo FM** | Orbit Shibuya with the **Radio** layer on — then drag the analog tuner needle: every position snaps to a real station and the globe flies to whoever's broadcasting. |
|
||||
| **🔥 Fire line** | FIRMS over California. Click a detection — the camera dives to it — read the intensity, then hit **NEAREST** in the CCTV panel for a ground view. |
|
||||
| **🚶 Ask for a walking route** *🎙️* | Tell the world where you want to go and watch a real street-following route trace itself through the 3D city — then *"fly it"*: banked turns, eased ends, a camera that leads the path like a drone shot. |
|
||||
| **📏 Measure LAX to DFW** *🎙️* | *"How far is LAX from DFW?"* — an arrow spans the country, the distance lands in the caption, and the endpoints stay pinned to the real world as you orbit. |
|
||||
| **🚀 Launch replay** | Open **Space Missions**, pick a launch from the last 30 days, and ride the T-minus countdown through ascent to orbit — scrub it at 0.25×–4×. Labeled `RECONSTRUCTED ESTIMATE`, because it is one. |
|
||||
| **🪦 Walk the boneyard** | Fly from regional context down into dense, fully resolved rows of retired aircraft. |
|
||||
| **🏗️ Orbit Three Gorges** | Sweep the dam and its terrain at a glance — then flip on the **Dams** layer and find 703 more. |
|
||||
|
||||
*🎙️ = voice missions — they need an OpenAI key.*
|
||||
|
||||

|
||||
|
||||
*Ask the planet: a military contact's last ~24 hours of real trace history, resolved as stacked 3D loops.*
|
||||
|
||||

|
||||
|
||||
*"Draw the walking route… now fly it" — banked turns, eased ends, the camera leading the path like a drone shot.*
|
||||
|
||||

|
||||
|
||||
*Walk the boneyard: rows of retired airframes, fully resolved in 3D.*
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Under the Hood
|
||||
|
||||
Some of the engineering that makes it feel real rather than like a tech demo:
|
||||
|
||||
- **World-stable icons.** Aircraft and ships point along their *true real-world heading* at every camera angle — tracked or not, looking straight down or across the horizon — via per-frame screen-space course projection. No spinning, no viewport-locking.
|
||||
- **Smooth motion from choppy data.** Live feeds arrive every 15–30s; the globe renders one interval behind real time and interpolates between known fixes. Dead reckoning fills the gaps.
|
||||
- **Honest satellites.** SGP4 propagation with orbit rings that stay locked to their satellites via GMST realignment — no drift, no per-second flicker.
|
||||
- **Sits on the real ground.** Entity heights run through a real vertical datum — geoid-aware, sampled against the *rendered* terrain mesh — so aircraft park on aprons and cameras stand on street corners instead of floating.
|
||||
- **Spends your quota like it's its own.** The paid feeds run behind cached, budget-governed proxies — an OpenSky credit governor, a TomTom daily tile budget, disk-cached TLEs — so an afternoon of exploring doesn't torch an API allowance.
|
||||
- **Local-first key handling.** Secret-bearing providers such as OpenAI, AISStream, OpenSky OAuth, TomTom, and FIRMS are brokered server-side. Proxy destinations are fixed or allowlisted, and the higher-risk paths add bounded requests, timeouts, response caps, and sanitized errors as appropriate. The only provider credentials intentionally exposed to the browser are Google Maps and Cesium ion; restrict both at the provider.
|
||||
- **No framework.** Vanilla JavaScript, **CesiumJS**, and **Vite** — plus **Google Photorealistic 3D Tiles** for the planet and the **OpenAI Realtime API** for voice. Fast to read, fast to hack on.
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.js # Bootstrap: Google 3D tiles, layer registration
|
||||
├── ui.js # Runtime UI — panels, HUD, styles, control facade
|
||||
├── hud.js # Intelligence HUD + AI scene summary
|
||||
├── mapStackController.js # Google 3D / Bing / OSM switching
|
||||
├── iconOrientation.js # Screen-projected world-space headings + horizon cull
|
||||
├── voice/ # OpenAI Realtime session + 28 voice tools
|
||||
├── data/ # One module per layer + management + context store
|
||||
│ └── local_data/ # Bundled datasets (per-folder provenance)
|
||||
└── scenes/ # Cinematic scene director
|
||||
```
|
||||
|
||||
See [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md) for the authoritative runtime reference.
|
||||
|
||||
---
|
||||
|
||||
## 🔑 API Keys
|
||||
|
||||
**The legend, one more time:** 🟢 **no signup** — works out of the box · 🟡 **free key** — register, paste, done · 🔴 **metered** — a billing-enabled account; costs are small but real.
|
||||
|
||||
Most of the globe is 🟢: flights (anonymous), military traffic, satellites, earthquakes, CCTV, radio, bikeshare, space missions, mapped installations, and every bundled dataset run with **zero keys**.
|
||||
|
||||
### What you need for the good experience
|
||||
|
||||
Five keys cover the fully keyed experience. Three currently offer no-cost developer access; Google Maps and OpenAI are usage-metered. Provider prices and allowances change, so use the linked pricing pages before relying on a budget estimate:
|
||||
|
||||
| | Key | Why | Get it |
|
||||
|---|-----|-----|--------|
|
||||
| 🔴 | **Google Maps** *(required)* | The photorealistic 3D planet ([Map Tiles API](https://developers.google.com/maps/documentation/tile)) | [Google Cloud Console](https://console.cloud.google.com/) — metered; [check current pricing](https://developers.google.com/maps/billing-and-pricing/pricing) and URL-restrict it |
|
||||
| 🔴 | **OpenAI** | 🎙️ The voice experience + AI HUD summary. Want another provider behind the mic? PRs welcome | [platform.openai.com](https://platform.openai.com) — metered; [check current API pricing](https://openai.com/api/pricing/) |
|
||||
| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free, seriously, it's a two-minute signup |
|
||||
| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free |
|
||||
| 🟡 | **TomTom** | 🚦 Real traffic instead of an approximate simulation | [developer.tomtom.com](https://developer.tomtom.com) — check the current developer allowance for your account |
|
||||
|
||||

|
||||
|
||||
*What the TomTom key buys you: rush-hour density painted on the city — then dive from the jam straight into the camera watching it.*
|
||||
|
||||
### Cherry on top
|
||||
|
||||
| | Key | Why | Get it |
|
||||
|---|-----|-----|--------|
|
||||
| 🟡 | **Cesium ion** | 🗺️ Bing imagery map stacks (public `assets:read` token) | [cesium.com/ion](https://cesium.com/ion) — [check the plan that fits your use](https://cesium.com/platform/cesium-ion/pricing/) |
|
||||
| 🟡 | **OpenSky** | ✈️ More flight-polling credits (🟢 anonymous works without) | [opensky-network.org](https://opensky-network.org) |
|
||||
| 🟡 | **Launch Library 2** | 🚀 Higher space-missions request allowance (🟢 works without) | [thespacedevs.com](https://thespacedevs.com) |
|
||||
|
||||
All of them are worth getting. None of them are required to start.
|
||||
|
||||
```bash
|
||||
# Put keys in .env (see .env.example), or pass them as env vars:
|
||||
OPENAI_API_KEY="…" AISSTREAM_API_KEY="…" npm run dev -- --host localhost --port 4173
|
||||
|
||||
# On macOS, store any of them in the Keychain and dev-fresh.sh pulls them in:
|
||||
security add-generic-password -U -s "google-maps-api" -a "api-key" -w
|
||||
security add-generic-password -U -s "openai-api" -a "api-key" -w
|
||||
security add-generic-password -U -s "aisstream-api" -a "api-key" -w
|
||||
security add-generic-password -U -s "firms-map" -a "map-key" -w
|
||||
security add-generic-password -U -s "cesium-ion" -a "token" -w
|
||||
security add-generic-password -U -s "tomtom-api" -a "api-key" -w
|
||||
```
|
||||
|
||||
OpenSky can run fully anonymous (`OPENSKY_AUTH_MODE=anon`), or import OAuth credentials with `./scripts/opensky-import-client.sh /path/to/credentials.json`.
|
||||
|
||||
### 💸 What it actually costs
|
||||
|
||||
Honest numbers, roughly, as of mid-2026 — always check the provider pricing pages:
|
||||
|
||||
| | Cost reality |
|
||||
|---|---|
|
||||
| **🟢 Most layers** | **$0, no signup.** OpenSky anon, USGS, CelesTrak, adsb.lol, city CCTV, Radio Browser, GBFS, Launch Library 2, bundled datasets. |
|
||||
| **🟡 Optional developer access** | AISStream, FIRMS, TomTom, Cesium ion, and authenticated OpenSky may offer no-cost access, but limits and permitted uses differ. Cesium ion and OpenSky in particular have plan or use restrictions; verify the current provider terms for your deployment. |
|
||||
| **🔴 Google 3D tiles** | Map Tiles usage is billed by session, with current prices and free-usage caps varying by billing region. Check Google's pricing page, restrict the key, set quotas, and configure a budget alert before sustained use. |
|
||||
| **🔴 OpenAI voice** | Realtime audio is usage-metered and the total depends on the selected model, conversation length, and audio volume. The app shows a live session estimate, warns at $2, and applies a **$5 in-app session cap**; provider-side usage limits remain the billing backstop. |
|
||||
|
||||
### 🧗 The floor is low on purpose
|
||||
|
||||
Everything above is the deliberately cheap baseline — enough to get a real taste of geospatial intelligence, GEOINT, and OSINT without ever talking to a sales team. You'll also notice the ceiling: terrestrial AIS goes quiet mid-ocean and satellite AIS costs real money; premium imagery, SAR, and the deeper commercial feeds live behind enterprise contracts. That's not a limit of the architecture — every layer here is a pattern you can point at your own data sources. This repo hands you the foundation; what you fuse into it is up to you.
|
||||
|
||||
### 🔒 Sharing an instance
|
||||
|
||||
By default nobody else can reach your server — it binds to localhost. To share on your LAN, opt in explicitly (`npm run dev -- --host 0.0.0.0 --port 4173`, or `HOST=0.0.0.0 ./scripts/dev-fresh.sh` on macOS/Linux) — but know that ⚠️ **a LAN-visible server brokers your configured API keys to anyone who can reach it.** Set the per-IP throttles (`GEV_RATELIMIT_OPENAI_PER_MIN`, `GEV_RATELIMIT_GOOGLE_PER_MIN` — see `.env.example`) and, before anything else, **set provider-side budget caps** (Google Cloud budgets, OpenAI usage limits): the throttles are app-level guards, not billing caps. Full threat model in [SECURITY.md](SECURITY.md).
|
||||
|
||||
---
|
||||
|
||||
## 📋 Responsible & Open
|
||||
|
||||
God's Eye View runs on **public data, clear sources, and local-first execution.** No secrets, no private datasets, no mystery scraping — anything involving a private key is brokered server-side. It has the visual grammar of a classified ops room, built entirely from open signals and inspectable code.
|
||||
|
||||
**The line.** This project models **events, assets, infrastructure, and systems** — aircraft, vessels, satellites, fires, cameras, cities. It does not build features for named-person search, face recognition, or tracking individuals, and pull requests that cross that line won't be merged. People are not a query type here.
|
||||
|
||||
**Come build it.** This is the canonical live 3D client from the project that kicked off the recent wave of spatial-intelligence tools — and it's a canvas: the layers here are the signals one person could find and fuse. Add a city pack, a data source, a style, a voice tool. It's the window through which you see the world; bring that window to others.
|
||||
|
||||
**Status:** An evolving open-source client for exploration and learning — a fast, hackable foundation, not a hardened production service. Released under the **[MIT License](LICENSE)**. Bundled and live datasets carry their own terms — see **[DATA_SOURCES.md](DATA_SOURCES.md)**. Security model: **[SECURITY.md](SECURITY.md)**. Want to contribute? **[CONTRIBUTING.md](CONTRIBUTING.md)**.
|
||||
|
||||
<sub>Media note: Bilawal Sidhu created and owns the 17 capture GIFs on this page. He also published the two README PNGs in the existing public project and authorized their continued inclusion here. Any appearance by Bilawal is included with his permission. These files are project documentation, not MIT-licensed standalone assets. Platform interfaces, trademarks, avatars, data, and third-party imagery visible within them remain subject to their respective owners' terms. See [media provenance](docs/media/README.md) and [source terms](DATA_SOURCES.md).</sub>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> God's Eye View is an exploratory visualization of public and third-party data.
|
||||
> Data may be delayed, incomplete, modeled, inferred, or wrong. Do not use it
|
||||
> for flight or maritime navigation, emergency response, medical or health
|
||||
> decisions, investment decisions, or other safety-critical or operational
|
||||
> purposes. Verify important information with authoritative sources.
|
||||
|
||||
---
|
||||
|
||||
## 🧭 What's Next
|
||||
|
||||
First — thank you. To everyone who watched the God-view demos and went off to build their own, and to everyone who kept asking for the code: I'm grateful. And when I polled whether this should go open source, you weren't subtle about it:
|
||||
|
||||
<img src="docs/media/open-source-survey.png" alt="Community survey on open-sourcing God's Eye View" width="460">
|
||||
|
||||
So here it is. Step inside the spy-thriller cockpit — except the data is real — and let's turn this into our shared sandbox for making sense of the world, and have fun doing it. This repo is the baseline, it stays open, and the whole point is for you to break things and bolt on layers we haven't thought of yet.
|
||||
|
||||
One heads-up from the inside: build in this space for a week and you learn that **the present is the cheap part**. The moment you try to go back in time — tiling, serving, and scrubbing *what happened* and *what changed* at any real resolution — the data gets expensive and the compute gets brutal. For that, we're building something cool. More in the future — [halfpixel.ai](https://halfpixel.ai).
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
▶️ [Watch the God's Eye View series](https://youtube.com/playlist?list=PL6qSg2I-7_koPbDnSMo0QeeHX_RknA2uv&si=nBGYMoHWQw41v93Q) · 📬 [Map the World](https://maptheworld.ai/) — the newsletter behind the project
|
||||
|
||||
**🌐 God's Eye View. No place left behind.**
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,63 @@
|
||||
# Security
|
||||
|
||||
God's Eye View is a local-first client for **public** data. It is built for exploration, demos, and learning — not as a hardened production service. This document explains the security model so you can run it safely and report issues responsibly.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please report security issues **privately** — do not open a public issue for anything exploitable.
|
||||
|
||||
- Use GitHub's [private vulnerability reporting](https://github.com/bilawalsidhu/gods-eye-view/security/advisories/new) (Security tab → "Report a vulnerability"), or
|
||||
- Reach the maintainer directly via the contact on the GitHub profile.
|
||||
|
||||
Include repro steps and impact. We'll acknowledge, investigate, and credit you (if you'd like) once a fix ships.
|
||||
|
||||
## How secrets are handled
|
||||
|
||||
The golden rule: **secret-bearing API keys stay on the server side.** The dev/preview server (Vite middleware in `vite.config.js`) brokers requests that need private credentials, so the browser never receives those long-lived secrets. Google Maps and Cesium ion are the two deliberate client-side exceptions described below.
|
||||
|
||||
| Key | Where it lives | How the browser uses it |
|
||||
|-----|----------------|--------------------------|
|
||||
| `OPENAI_API_KEY` | Server only | Browser fetches a short-lived **ephemeral** Realtime session token from `/api/realtime/token`; the real key never ships |
|
||||
| `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/ais-live` cache |
|
||||
| OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/opensky` |
|
||||
|
||||
### Two deliberately client-side keys — restrict them
|
||||
|
||||
These are designed to be used directly in the browser (like a Mapbox public token). They are injected into the client bundle via Vite's `define`, so they **will** be visible in browser devtools. Scope and restrict them rather than trying to hide them:
|
||||
|
||||
1. **Google Maps API key** — loads the Photorealistic 3D Tiles in the browser. **Restrict it** (HTTP referrer + API restriction to the Map Tiles API) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you.
|
||||
2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — only for the Bing world-imagery map stacks) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment.
|
||||
|
||||
> The Vite `define` block in `vite.config.js` controls exactly what reaches the client: only these two keys plus two non-secret CCTV feature flags. Everything else stays server-side.
|
||||
|
||||
Never commit real keys. `.env` is gitignored; only `.env.example` (placeholder names) is tracked. On macOS the launcher reads keys from the Keychain; on other platforms use env vars or a local `.env`.
|
||||
|
||||
## Server-side proxy hardening
|
||||
|
||||
The data proxies in `vite.config.js` are written so the browser cannot turn the server into an open relay:
|
||||
|
||||
- **No arbitrary-URL fetching.** The CCTV frame proxy fetches only server-registered camera/frame URLs — clients cannot pass an upstream URL to fetch (SSRF mitigation). Other proxies target fixed upstream hosts.
|
||||
- **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio.
|
||||
- **Bounded high-risk paths.** Request bodies and high-volume or attacker-influenced upstream responses are capped where that boundary matters; network paths use explicit timeouts or other bounded lifecycles appropriate to the feed.
|
||||
- **Sanitized public failures.** Proxy handlers return controlled error messages instead of credentials or raw internal details.
|
||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
||||
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
||||
|
||||
## Network exposure — the operator threat model
|
||||
|
||||
The dev server is a **key broker**: every server-side key above is spendable by anyone who can send HTTP requests to it. That shapes the defaults:
|
||||
|
||||
- **Local-only by default.** `./scripts/dev-fresh.sh` (and the Vite config itself) bind to `localhost`, so only your machine can reach the server — and only local names are accepted (`allowedHosts` stays restricted, which also blunts DNS-rebinding tricks).
|
||||
- **LAN exposure is an explicit opt-in**: `HOST=0.0.0.0 ./scripts/dev-fresh.sh`. The launcher prints a prominent warning plus your LAN URL. Understand what opting in means: **every device on that network can drive the proxies and spend your OpenAI / Google / OpenSky / AISStream / TomTom / FIRMS quota** for as long as the server runs. Do this only on networks you trust.
|
||||
- **App-level throttles (opt-in):** `GEV_RATELIMIT_OPENAI_PER_MIN` and `GEV_RATELIMIT_GOOGLE_PER_MIN` cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). They are **per-IP, process-local, in-memory guards** — they reset on restart and are **not billing caps**.
|
||||
- **Provider-side budgets are the real backstop.** For hard spend protection, configure limits where the money is: OpenAI platform usage limits, Google Cloud budget alerts + per-API quotas, and equivalent controls for any other keyed provider.
|
||||
|
||||
## Scope & expectations
|
||||
|
||||
- The Vite server is a **development/preview** server. If you expose it beyond localhost, put it behind your own auth/proxy and review the bindings (see the threat model above).
|
||||
- All data shown is from **public** sources. See [DATA_SOURCES.md](DATA_SOURCES.md). Respect each provider's terms and rate limits.
|
||||
- The voice agent receives feed-sourced text (place names, callsigns) as scene context. It is instructed to act only via a fixed set of app-control tools and not to execute arbitrary instructions found in data, but treat model output as untrusted and keep the tool surface limited.
|
||||
|
||||
## Responsible use
|
||||
|
||||
This is an interface for signals that are **already public**. Use it accordingly: respect privacy, follow data providers' terms, and don't represent public-data inference as authoritative intelligence.
|
||||
@@ -0,0 +1,220 @@
|
||||
# God's Eye View — Testing Guide (voice map-annotation whiteboard + live tracking)
|
||||
|
||||
> [!NOTE]
|
||||
> This is a **manual field-test scenario script** for the June-2026 whiteboard +
|
||||
> tracking work. The AUTOMATED gates live elsewhere: `npm test` (unit),
|
||||
> `npm run test:track` (tracking invariants), and the headless harnesses under
|
||||
> `scripts/qa-*.mjs` — see [docs/CURRENT-STATE.md](docs/CURRENT-STATE.md) for the full test surface.
|
||||
|
||||
This guide covers the hardened annotation and tracking behavior. Record a voice
|
||||
note + screenshots as you go; each scenario
|
||||
lists what **✅ pass** looks like and (where it applies) the **❌ old bug** it replaces.
|
||||
|
||||
## Focus/horizon moving evidence
|
||||
|
||||
With the Vite development server already running on port 4173, capture all four
|
||||
focus/recession scenarios with a deterministic virtual frame clock:
|
||||
|
||||
```sh
|
||||
node scripts/qa-focus-evidence.mjs --url http://localhost:4173 \
|
||||
--screenshots-dir qa-shots/focus-evidence \
|
||||
--json qa-shots/focus-evidence/report.json
|
||||
```
|
||||
|
||||
For a quick operator loop, `--smoke` captures only S1 in six frames. For visual
|
||||
sign-off, add `--headful`; this removes the SwiftShader launch flags and uses
|
||||
the machine's real GPU. The harness brings its page to the foreground at the
|
||||
start of every scenario so a headful Chromium run keeps streaming tiles.
|
||||
|
||||
Google 3D remains the default basemap. If its tile stream is the bottleneck
|
||||
rather than the behavior under test, select an existing map stack explicitly:
|
||||
|
||||
```sh
|
||||
--basemap bing-aerial
|
||||
--basemap osm
|
||||
```
|
||||
|
||||
Accepted values are `photoreal`, `bing-aerial`, `bing-labels`, and `osm`; Bing
|
||||
stacks still require the app's usual Cesium ion token.
|
||||
|
||||
Tune both systems without editing source by adding, for example:
|
||||
|
||||
```sh
|
||||
--params '{"focus":{"dimFloor":0.35,"nearerBehavior":"partial"},"horizon":{"scaleFloor":0.5,"alphaFloor":0.4}}'
|
||||
```
|
||||
|
||||
The script never starts the server. During scenarios it pauses Cesium's default
|
||||
render loop, advances focus time explicitly, and renders each frame before the
|
||||
screenshot, so computed alpha/scale sequences repeat for identical parameters.
|
||||
Headless Chromium still forces SwiftShader, so its pixels are relative CI/A-B
|
||||
evidence only; headful real-GPU output is the sign-off surface. Screenshots use
|
||||
scenario/frame names and the JSON report records effective tuning plus
|
||||
per-contact alpha, scale, and screen data. Before each scenario's first capture,
|
||||
the harness gives the active Google photoreal tileset up to 45 seconds to reach
|
||||
Cesium's `tilesLoaded`/`allTilesLoaded` condition. Every frame records
|
||||
`tilesSettled: true|false` and whether that gate applied; a timeout is recorded
|
||||
as false, never promoted to a pass. Non-photoreal stacks record the gate as
|
||||
settled and not applicable because no Google 3D tileset is active.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Do not use a screenshot for visual judgment unless its report frame records
|
||||
> `tilesSettled: true`.
|
||||
|
||||
## Setup
|
||||
|
||||
- **URL:** http://localhost:4173 — auto-flies to Austin on load. Give photoreal tiles ~10s.
|
||||
- **Voice (the real feature):** click **GEV MIC** (bottom of screen) → wait for **LISTENING** →
|
||||
just talk. It marks the map *as it talks*, without announcing that it's drawing. Click
|
||||
**STOP** when done. (Needs `OPENAI_API_KEY`; `dev-fresh.sh` injects it from Keychain.)
|
||||
- **Console (deterministic, no mic/live-data needed):** open DevTools (**Cmd-Opt-J**) and use:
|
||||
- `window.__gevAnnotations.tour()` — self-running narrated SF tour (camera + marks in sequence)
|
||||
- `window.__gevAnnotations.demo()` — lays the SF set down at once
|
||||
- `window.__gevAnnotations.clear()` — erase all marks
|
||||
- `window.__gevAnnotations.count()` — how many marks are live
|
||||
|
||||
---
|
||||
|
||||
## What changed (the list)
|
||||
|
||||
**Live aircraft tracking & camera (Batch D)** — civilian + military, mirrored:
|
||||
- **R14** warm-up: tracked plane glides immediately (no freeze-then-jump / backward snap),
|
||||
via a display-layer reconciliation that smooths any position discontinuity over ~0.9s.
|
||||
- **R15** framing: calibrated initial follow distance (altitude-scaled, 3–30 km).
|
||||
- **R16** trail: head stays glued to the moving icon per-frame; never trails ~1s behind or
|
||||
pokes out in front of the nose.
|
||||
- **R13** z-order: screen annotations fade where they'd cover the **tracked** plane + its label.
|
||||
|
||||
**Voice annotation — scoping & honesty (Batch B):**
|
||||
- **R1** resolver scope: a place name resolves to the *right* thing (building vs neighborhood
|
||||
vs natural feature vs city) instead of an oversized admin blob.
|
||||
- **R2** boundary stitching: complex outlines no longer close with bay-spanning chords.
|
||||
- **R6** route honesty: a routing outage draws a *labeled* straight "direct line", never a
|
||||
silently faked road.
|
||||
- **R12** partial honesty: if some targets fail, it says so — no masking partial as success.
|
||||
- **R10** cache: transient failures aren't negative-cached for the whole session.
|
||||
|
||||
**Annotation rendering & motion (Batch C):**
|
||||
- **R8** route flow: route dashes **animate** (flow along the path).
|
||||
- **R9 / R17** perf: idle annotations stop their animation loop; height cache is bounded.
|
||||
- **R11** no-freeze: large/complex boundaries simplify without locking the UI.
|
||||
|
||||
**Abuse guards (Batch A)** — mostly internal: input caps on `annotate_map`, clear/new-topic
|
||||
cancellation, hardened Overpass + route proxies. Not much to see by hand (see §4).
|
||||
|
||||
---
|
||||
|
||||
## 1. Live aircraft tracking (Batch D) — the 3 issues you reported + z-order
|
||||
|
||||
> Needs a live plane. Track by **clicking a plane icon**; **Esc** (or click empty space) untracks.
|
||||
> If you see no planes, scroll out once — Texas airspace is busy.
|
||||
|
||||
**1a · Warm-up smoothness (your issue #1 → R14)**
|
||||
1. Click a moving plane. 📸 Snap the instant it locks, then watch ~2–3s.
|
||||
- ✅ Starts gliding smoothly *immediately*. ❌ *Was:* froze ~1s, then jumped forward.
|
||||
|
||||
**1b · Initial framing (your issue #2 → R15)**
|
||||
1. On the same lock-on, look at how the camera frames the plane. 📸 Snap the initial view.
|
||||
- ✅ Plane + label readable with context, no scrolling. ❌ *Was:* jammed in too tight.
|
||||
|
||||
**1c · Trail glued to the plane (your issue #3 → R16)**
|
||||
1. Keep tracking ~10s. 📸 Snap the cyan trail while it moves.
|
||||
- ✅ Trail tail stays attached to the plane; nothing pokes out *in front* of the nose; no ~1s lag.
|
||||
❌ *Was:* trail lagged a beat behind.
|
||||
|
||||
**1d · Military layer (same fixes, mirrored)**
|
||||
1. Enable the **Military** layer (left column). Track an **amber** plane. Repeat 1a–1c.
|
||||
- ✅ Identical smooth behavior.
|
||||
|
||||
**1e · Z-order: tracked plane stays on top (→ R13, trickiest to stage)**
|
||||
1. While tracking a plane over Austin, GEV MIC: *"annotate downtown Austin."*
|
||||
2. Orbit/zoom so the plane crosses the annotation's outline or label. 📸 Snap the overlap.
|
||||
- ✅ The annotation **dims** where it covers the plane/label — plane stays visible on top.
|
||||
❌ *Was:* the annotation drew over and hid the plane.
|
||||
|
||||
---
|
||||
|
||||
## 2. Voice annotation — scoping & honesty (Batch B)
|
||||
|
||||
> All from the Austin default view (no camera move needed). Click GEV MIC, then speak.
|
||||
|
||||
**2a · Scope: building vs region (→ R1)** — say each, watch what gets outlined:
|
||||
- *"annotate the Texas State Capitol"* → ✅ the **Capitol building**, not the whole state.
|
||||
- *"annotate Barton Creek"* → ✅ the **creek / greenbelt**, not Barton Creek *Mall* or the city.
|
||||
- *"annotate downtown Austin"* → ✅ the **downtown district**, a sensible neighborhood-size area.
|
||||
- *"annotate the University of Texas at Austin"* → ✅ the **campus**, not the city.
|
||||
- 📸 Snap each outline. ❌ *Was:* incidental names ballooned into giant county/state blobs.
|
||||
|
||||
**2b · Classic SF set (optional)** — first say *"take me to San Francisco,"* then:
|
||||
- *"annotate the Mission District"* → neighborhood. *"...the Presidio"* → the full former-base
|
||||
outline (not a single point, not a bay-spanning blob → R2). *"...Chinatown"* → the district.
|
||||
|
||||
**2c · Route honesty (→ R6)**
|
||||
1. *"draw a route from Austin-Bergstrom airport to the Texas Capitol."*
|
||||
- ✅ A real **road** route with a flowing dashed line + a labeled callout.
|
||||
- If a route ever comes back as a **straight** line, it should be **labeled** as a direct/approx
|
||||
line (not pretending to be a road). ❌ *Was:* silent fake straight-line "routes."
|
||||
|
||||
**2d · Partial-failure honesty (→ R12)**
|
||||
1. *"annotate the Texas Capitol and the Flibbergibbet Building."*
|
||||
- ✅ It annotates the Capitol **and tells you** it couldn't find the other one.
|
||||
❌ *Was:* reported success and silently dropped the missing one.
|
||||
|
||||
**2e · Clear / new-topic race (→ R4)**
|
||||
1. *"annotate downtown Austin"* and, right as it starts, *"actually, clear everything."*
|
||||
- ✅ Marks clear and **stay** cleared (the in-flight one doesn't pop back a second later).
|
||||
- Sanity in console: `window.__gevAnnotations.count()` → `0` after a clear.
|
||||
|
||||
---
|
||||
|
||||
## 3. Annotation rendering & motion (Batch C) — deterministic, no mic
|
||||
|
||||
**3a · Full experience (smoke test)**
|
||||
1. Console: `window.__gevAnnotations.tour()`. 📸 A couple of frames as it runs.
|
||||
- ✅ Camera flies to SF; Palace highlight → arrow → Presidio **draped** outline → ILM pin →
|
||||
Crissy Field **route** appear in sequence; marks stay glued as the camera moves.
|
||||
|
||||
**3b · Route dashes animate (→ R8)**
|
||||
1. During/after `tour()` (or `demo()`), watch the **route** line (Crissy Field shoreline).
|
||||
- ✅ The dashes **flow** along the path (animated), not static. ❌ *Was:* dashes never moved.
|
||||
|
||||
**3c · Draping & persistence**
|
||||
1. After `demo()`, orbit the camera around the Presidio outline.
|
||||
- ✅ The footprint **drapes onto** the 3D ground/buildings and conforms; callouts/rings/arrows
|
||||
are the hand-drawn SVG style and stay anchored to their real spot.
|
||||
|
||||
**3d · Large boundary, no freeze (→ R11)**
|
||||
1. Console:
|
||||
`window.__gevAnnotations.annotate([{type:'area',target:'Travis County, Texas',label:'Travis County',color:'green',footprint:true}],{flyTo:true,persist:true})`
|
||||
- ✅ The complex county boundary simplifies and draws **without** freezing/janking the UI.
|
||||
|
||||
---
|
||||
|
||||
## 4. Robustness / guards (Batch A) — mostly internal
|
||||
|
||||
These are server-side / defensive and don't have a clean visual tell. Light checks only:
|
||||
- Rapid-fire several annotate commands, then a clear — UI should stay responsive and end clean
|
||||
(`count()` → `0`).
|
||||
- Nothing here should change normal behavior; flag it only if something feels *broken* (stuck
|
||||
spinner, marks that won't clear, errors in the console).
|
||||
|
||||
---
|
||||
|
||||
## 5. General feel — open feedback
|
||||
|
||||
While recording, call out anything in these areas — this is the feedback I most want:
|
||||
- **Tracking feel:** does follow motion feel smooth/natural, or floaty/laggy/overshooting?
|
||||
- **Framing:** is the initial tracked view a good "hero" shot, or too close/far?
|
||||
- **Annotations:** do outlines land on the *right* thing? Are labels readable / well-placed /
|
||||
not overlapping? Does the hand-drawn style read well over photoreal tiles?
|
||||
- **Voice:** does it mark things *as it talks* (not after), and confirm only what actually
|
||||
happened? Any command it misunderstood?
|
||||
- **Anything that looks wrong, janky, or surprising** — screenshot it; that's the gold.
|
||||
|
||||
## If something looks off
|
||||
|
||||
- **Grey globe / slow tiles:** wait a few seconds after a camera flight; photoreal streams in.
|
||||
- **A voice mark didn't land:** the place may not geocode — try a more specific name
|
||||
(e.g. "Palace of Fine Arts, San Francisco").
|
||||
- **No planes:** OpenSky data may be momentarily sparse; scroll out or wait a poll cycle.
|
||||
- **No GEV MIC button / voice errors:** `OPENAI_API_KEY` didn't load — use the console API for
|
||||
the annotation tests and skip the voice-only ones (§2).
|
||||
|
Before Width: | Height: | Size: 567 KiB |
|
Before Width: | Height: | Size: 462 KiB |
|
Before Width: | Height: | Size: 672 KiB |
|
Before Width: | Height: | Size: 5.9 MiB |
|
Before Width: | Height: | Size: 511 KiB |
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,59 @@
|
||||
[
|
||||
{
|
||||
"id": "tokyo-shinjuku-east-1",
|
||||
"name": "Shinjuku Crossing East Cam",
|
||||
"city": "Tokyo",
|
||||
"cityId": "tokyo",
|
||||
"provider": "Pilot Feed Pack",
|
||||
"sourceKind": "pilot",
|
||||
"feedType": "mp4",
|
||||
"url": "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4",
|
||||
"lat": 35.689614,
|
||||
"lon": 139.700523,
|
||||
"headingDeg": 242,
|
||||
"pitchDeg": -19,
|
||||
"fovDeg": 66,
|
||||
"rangeM": 560,
|
||||
"mountHeightM": 29,
|
||||
"groundElevationM": 40,
|
||||
"license": "Demo sample stream for projection pipeline testing"
|
||||
},
|
||||
{
|
||||
"id": "tokyo-shinjuku-west-2",
|
||||
"name": "Shinjuku Station West Cam",
|
||||
"city": "Tokyo",
|
||||
"cityId": "tokyo",
|
||||
"provider": "Pilot Feed Pack",
|
||||
"sourceKind": "pilot",
|
||||
"feedType": "mp4",
|
||||
"url": "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4",
|
||||
"lat": 35.690976,
|
||||
"lon": 139.699237,
|
||||
"headingDeg": 128,
|
||||
"pitchDeg": -17,
|
||||
"fovDeg": 72,
|
||||
"rangeM": 600,
|
||||
"mountHeightM": 32,
|
||||
"groundElevationM": 40,
|
||||
"license": "Demo sample stream for projection pipeline testing"
|
||||
},
|
||||
{
|
||||
"id": "tokyo-shibuya-scramble",
|
||||
"name": "Shibuya Scramble North Cam",
|
||||
"city": "Tokyo",
|
||||
"cityId": "tokyo",
|
||||
"provider": "Pilot Feed Pack",
|
||||
"sourceKind": "pilot",
|
||||
"feedType": "mp4",
|
||||
"url": "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4",
|
||||
"lat": 35.659695,
|
||||
"lon": 139.700539,
|
||||
"headingDeg": 26,
|
||||
"pitchDeg": -20,
|
||||
"fovDeg": 74,
|
||||
"rangeM": 610,
|
||||
"mountHeightM": 30,
|
||||
"groundElevationM": 35,
|
||||
"license": "Demo sample stream for projection pipeline testing"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,108 @@
|
||||
# KNOWN ISSUES
|
||||
|
||||
Updated: July 8, 2026
|
||||
|
||||
This file tracks active runtime issues only.
|
||||
|
||||
This file records current known issues; historical planning material is not part
|
||||
of the public release.
|
||||
|
||||
---
|
||||
|
||||
## Open
|
||||
|
||||
### Street traffic can be slow/uneven when panning across dense city blocks
|
||||
Status: Open (partially mitigated)
|
||||
|
||||
Context:
|
||||
- Current traffic loader fetches one clamped viewport tile at a time (major pass, then full pass).
|
||||
- In dense cores, some visible roads can appear late after city jumps or fast pans.
|
||||
- Zooming into adjacent streets does not always immediately trigger higher-detail coverage for all visible roads.
|
||||
|
||||
Current mitigation in runtime:
|
||||
- Fair per-road dot budget allocation (reduces hard starvation under global `MAX_DOTS` cap).
|
||||
- Center-shift threshold (reduces stale overlap lock while panning).
|
||||
|
||||
Next iteration candidates:
|
||||
- Prioritize currently visible road segments inside the active viewport before off-center segments.
|
||||
- Add neighbor prefetch ring for nearby tiles after jump-to-city actions.
|
||||
- Add adaptive dot cap by frame time (coverage first, density second).
|
||||
- Promote sync chip from loading indicator to true multi-phase progress.
|
||||
|
||||
---
|
||||
|
||||
### CCTV panel can appear "missing" after layout refactors
|
||||
Status: Open (workaround available)
|
||||
|
||||
Context:
|
||||
- Panel positions are persisted in local storage and can restore off-screen after UI changes.
|
||||
|
||||
Workaround:
|
||||
- In browser console:
|
||||
- `localStorage.removeItem('godsEyeView.v6.panelPos.cctv-panel');`
|
||||
- `localStorage.removeItem('godsEyeView.v6.panelCollapsed.cctv-panel');`
|
||||
- `location.reload();`
|
||||
|
||||
Related keys (current versions):
|
||||
- Panel positions: `godsEyeView.v7.panelPos.<panel-id>` (re-versioned 2026-06-10)
|
||||
- Panel collapsed state: `godsEyeView.v6.panelCollapsed.<panel-id>`
|
||||
- CCTV calibration: `godsEyeView.cctv.calibration.v2`
|
||||
|
||||
---
|
||||
|
||||
### Height-datum residuals
|
||||
Status: Open (accepted 2026-07-08, documented)
|
||||
|
||||
- **Cold-start floor latency:** at a freshly-visited airport, grounded/low aircraft
|
||||
float low for ~1–2 poll cycles (30–60 s) and rise as terrain floors resolve;
|
||||
a few stragglers take one more poll.
|
||||
- **Born-grounded first poll:** a contact first seen on the ground with no altitude
|
||||
data renders at the geoid for ≤1 poll until its floor cell warms.
|
||||
- Full context, improvement ideas, and the verification oracle
|
||||
(`scripts/qa-floor-verify.mjs`):
|
||||
the height-datum section in `docs/CURRENT-STATE.md`.
|
||||
|
||||
---
|
||||
|
||||
## Closed / Intentional (for clarity)
|
||||
|
||||
### Proxy SSRF and error-surface hardening gaps
|
||||
Status: Closed as fixed on `main`
|
||||
|
||||
Context:
|
||||
- Proxy middleware previously allowed broader error/internal surface area and looser upstream handling.
|
||||
- Current `main` includes hardened proxy behavior in `vite.config.js`:
|
||||
- CCTV upstream URL no longer accepted from client query params.
|
||||
- Error payloads are sanitized.
|
||||
- OpenSky cache stores successful responses only.
|
||||
- OpenSky token refresh is coalesced.
|
||||
- GBFS/CCTV memory growth is bounded.
|
||||
|
||||
Validation target:
|
||||
- `vite.config.js`
|
||||
|
||||
---
|
||||
|
||||
### NVG vignette edge color bleed
|
||||
Status: Closed as fixed in current shader composite
|
||||
|
||||
Context:
|
||||
- Earlier builds leaked original scene colors near the NVG tube edge.
|
||||
- Current composite now masks NVG output with tube falloff before final blend, removing the color edge bleed.
|
||||
|
||||
Validation target:
|
||||
- `src/styles/surveillance.js`
|
||||
|
||||
---
|
||||
|
||||
### Wildfires layer unavailable / static bundled snapshot
|
||||
Status: Closed — live FIRMS integration shipped (2026-07-16)
|
||||
|
||||
Context:
|
||||
- Wildfires (NASA FIRMS) were removed from runtime in v0.5.3, returned June 2026 as a
|
||||
bundled-snapshot layer (`local-firms`, 2026-05-25 data, ~58 MB in-repo), and were
|
||||
converted to **live NASA FIRMS data** on 2026-07-16: the `/api/firms` proxy merges
|
||||
three VIIRS NRT sources (trailing 24 h, 30 min cache, serve-stale-on-failure) and the
|
||||
bundled snapshot was deleted. Requires a free server-side `FIRMS_MAP_KEY`; without it
|
||||
the layer shows a KEY REQUIRED state.
|
||||
- Weather radar is still held out of OSS v1 after QA found the previous overlay did not provide reliable visible value.
|
||||
@@ -0,0 +1,144 @@
|
||||
# Performance baseline
|
||||
|
||||
This page records one hardware-rendered Apple M5 comparison captured on 22
|
||||
August 2026 in Chrome 150 at 1440 x 900. It is not a minimum hardware
|
||||
specification and should not be used to predict performance on untested systems.
|
||||
The original capture artifacts are not included here, so this page records
|
||||
results rather than defining a runnable benchmark.
|
||||
|
||||
## Test context
|
||||
|
||||
The baseline was captured on 22 August 2026 with these conditions:
|
||||
|
||||
| Setting | Value |
|
||||
| --- | --- |
|
||||
| Renderer | Apple M5 Metal through the hardware ANGLE path |
|
||||
| Browser | Chrome 150 in a fresh isolated profile |
|
||||
| Viewport | 1440 x 900 at device pixel ratio 1 |
|
||||
| Focus | Page foregrounded for controlled scenes |
|
||||
| Scene sample | 5 seconds of scripted motion, then 5 seconds at rest |
|
||||
| Startup | Browser cache disabled; three samples |
|
||||
|
||||
The capture covered three startup samples, 16 cold layer scenarios with 14
|
||||
measurements, 23 controlled option and stress scenes, and five
|
||||
hardware-rendered overlay scenes.
|
||||
|
||||
## Startup
|
||||
|
||||
| Sample | App ready | Initial settle | Load event | Motion / rest | Used JS heap |
|
||||
| ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 1 | 784.980 ms | 2,035.082 ms | 439.5 ms | 60 / 60 FPS | 102.9 MiB |
|
||||
| 2 | 604.849 ms | 1,855.836 ms | 442.4 ms | 60 / 60 FPS | 111.6 MiB |
|
||||
| 3 | 558.527 ms | 1,809.592 ms | 438.8 ms | 60 / 60 FPS | 105.1 MiB |
|
||||
| Median | 604.849 ms | 1,855.836 ms | 439.5 ms | 60 / 60 FPS | 105.1 MiB |
|
||||
|
||||
The initial-settle measurement is the more useful launch reference because it
|
||||
includes the first visual and data settling window. All three samples reached
|
||||
the display ceiling during both motion and rest.
|
||||
|
||||
## Cold layer activation
|
||||
|
||||
Cold activation was measured separately from warm option switching. Live object
|
||||
counts are included so that future runs can compare source populations before
|
||||
attributing a difference to the client.
|
||||
|
||||
| Layer | Activation | Source count | Motion / rest | Used JS heap |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| CCTV city | 19,608.240 ms | 48 | 60 / 60 FPS | 192.7 MiB |
|
||||
| Space Missions (report label: Rocket missions) | 3,581.066 ms | 26 | 60 / 60 FPS | 131.3 MiB |
|
||||
| Radio | 3,458.709 ms | 750 | 60 / 60 FPS | 124.8 MiB |
|
||||
| Bikeshare | 2,069.498 ms | 633 | 60 / 60 FPS | 157.4 MiB |
|
||||
| Datacenters | 817.693 ms | 4,362 | 59.6 / 60 FPS | 328.2 MiB |
|
||||
| Flights | 667.671 ms | 247 | 60 / 60 FPS | 118.4 MiB |
|
||||
| Submarine cables | 614.727 ms | 2,629 | 60 / 60 FPS | 412.0 MiB |
|
||||
| Military Flights | 557.113 ms | 68 | 60 / 60 FPS | 118.4 MiB |
|
||||
|
||||
CCTV had the largest cold activation cost in this capture. Submarine cables
|
||||
used the most heap, followed by datacenters. Completed single-layer samples
|
||||
generally reached 60 FPS, so activation time and heap separate these cases more
|
||||
clearly than steady-state frame rate.
|
||||
|
||||
## Aircraft, detection, and Cockpit
|
||||
|
||||
| Scene | Motion / rest |
|
||||
| --- | ---: |
|
||||
| Idle globe | 60 / 60 FPS |
|
||||
| Flights, 2D | 60 / 60 FPS |
|
||||
| Flights, 3D proximity | 60 / 60 FPS |
|
||||
| Flights, all 3D models | 60 / 60 FPS |
|
||||
| Military Flights, all 3D models | 60 / 60 FPS |
|
||||
| Detection at 25% | 39.3 / 41.1 FPS |
|
||||
| Detection at 50% | 37.4 / 39.8 FPS |
|
||||
| Detection at 100% | 34.4 / 35.5 FPS |
|
||||
| Cockpit | 49.6 / 49.2 FPS |
|
||||
|
||||
The clean detection scenes processed 8,169 to 8,170 observations. Selected
|
||||
labels rose from 14 at 25% density to 28 at 50% and 56 at 100%. The aircraft
|
||||
rows came from an earlier loaded, foreground-controlled pass because the clean
|
||||
rerun received no live aircraft rows.
|
||||
|
||||
## Visual styles and combined stress
|
||||
|
||||
| Scene | Motion / rest |
|
||||
| --- | ---: |
|
||||
| Normal | 60 / 60 FPS |
|
||||
| CRT (report label: Retro) | 60 / 60 FPS |
|
||||
| NVG (report label: Surveillance) | 60 / 60 FPS |
|
||||
| FLIR (report label: Thermal) | 49 / 60 FPS |
|
||||
| Anime | 60 / 59.8 FPS |
|
||||
| Noir | 47 / 56.6 FPS |
|
||||
| Snow | 42.3 / 45.8 FPS |
|
||||
| Combined static | 57.6 / 60 FPS |
|
||||
| Combined operational | 39.9 / 43.1 FPS |
|
||||
|
||||
The combined static scene rendered 11,575 objects, used 872.2 MiB of JavaScript
|
||||
heap, and issued 48,665 text draws during motion and 54,106 at rest. The combined
|
||||
operational sample contained 3,909 observations and two selected labels, but its
|
||||
live aircraft and traffic rows were empty, so it remains a limited stress case.
|
||||
|
||||
Snow, Noir, dense detection, and text-heavy combined layers are the clearest
|
||||
controlled comparison points for later optimization work.
|
||||
|
||||
## Keyed live sources
|
||||
|
||||
NASA FIRMS, AISStream, and TomTom were captured in a separate hardware-rendered
|
||||
pass. The page was visible but was not the focused window, so these frame rates
|
||||
must not be compared directly with the foreground-controlled scenes above.
|
||||
|
||||
| Source | Point-in-time population | Activation or coverage | Motion / rest |
|
||||
| --- | ---: | --- | ---: |
|
||||
| NASA FIRMS | 100,430 detections in 3,557 cells | 30.0 s activation | 32.1 / 55.2 FPS |
|
||||
| AISStream | 12,000 vessels | 6.4 s activation | 22.1 / 29.8 FPS |
|
||||
| TomTom Traffic | 4,222 road dots | 70% coverage, 2 decoded tiles | 45.0 / 51.7 FPS |
|
||||
|
||||
These populations change continuously. A future comparison must record the
|
||||
live counts again and match the focus conditions.
|
||||
|
||||
## Controls for a future capture
|
||||
|
||||
Use the same controls before attributing a difference to the application:
|
||||
|
||||
1. Record the exact GPU renderer and reject software-rendered or unavailable GPU
|
||||
strings.
|
||||
2. Use a 1440 x 900 viewport at device pixel ratio 1 and keep the page focused.
|
||||
3. Measure cache-disabled startup separately from cold layer activation and warm
|
||||
option switching.
|
||||
4. Repeat startup three times and compare medians.
|
||||
5. Sample each option for 5 seconds in scripted motion and 5 seconds at rest.
|
||||
6. Record live object counts before attributing a difference to the client.
|
||||
7. Treat a live-source outage as missing coverage, not as evidence of low client
|
||||
rendering cost.
|
||||
|
||||
## What is not established yet
|
||||
|
||||
- This report does not establish Windows performance.
|
||||
- The report does not record machine memory capacity, so it cannot support a
|
||||
minimum-memory recommendation.
|
||||
- The report does not cover other GPU renderers or viewport configurations.
|
||||
- Military Installations is outside this comparison because it requires close
|
||||
camera context.
|
||||
- The keyed pass has no controlled rerun suitable for comparison with the
|
||||
option scenes.
|
||||
|
||||
Use this page as a regression baseline for one known hardware and browser
|
||||
configuration, not as a compatibility guarantee.
|
||||
|
After Width: | Height: | Size: 981 KiB |
|
After Width: | Height: | Size: 5.6 MiB |
|
After Width: | Height: | Size: 5.8 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 4.8 MiB |
|
After Width: | Height: | Size: 5.3 MiB |
|
After Width: | Height: | Size: 6.0 MiB |
|
After Width: | Height: | Size: 4.8 MiB |
|
After Width: | Height: | Size: 889 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
@@ -0,0 +1,40 @@
|
||||
# README media provenance
|
||||
|
||||
## Capture GIFs
|
||||
|
||||
Bilawal Sidhu created and owns the following 17 GIFs and has authorized their inclusion and redistribution as part of this public repository and its project documentation. Any appearance by Bilawal Sidhu in these captures is included with his permission.
|
||||
|
||||
- `hero-open-source-reveal.gif`
|
||||
- `06-cockpit-ar.gif`
|
||||
- `start-here/airport-ground-traffic-google-3d.gif`
|
||||
- `03-austin-cctv.gif`
|
||||
- `14-iss-over-ukraine.gif`
|
||||
- `01-style-sweep.gif`
|
||||
- `12-switch-aircraft-cockpit.gif`
|
||||
- `start-here/military-cockpit-dense-google-3d.gif`
|
||||
- `01-voice-annotate-zilker.gif`
|
||||
- `04-airport-distance.gif`
|
||||
- `15-global-radio-layer.gif`
|
||||
- `08-falcon9-replay.gif`
|
||||
- `09-undersea-cables.gif`
|
||||
- `07-helicopter-loops.gif`
|
||||
- `10-walking-route-flythrough.gif`
|
||||
- `08-boneyard.gif`
|
||||
- `05-traffic-to-cctv.gif`
|
||||
|
||||
Copyright © Bilawal Sidhu. These files are not covered by the project's MIT License. Permission is limited to their inclusion and redistribution with this repository and its project documentation. No permission is granted for standalone reuse or modification. Commercial reuse outside this repository requires separate permission. No share-alike license applies.
|
||||
|
||||
The GIFs are promotional captures of God's Eye View. They include Google Photorealistic 3D Tiles and public or third-party data layers with in-frame attribution. Bilawal Sidhu's ownership and permission cover the captures and his likeness; they do not replace the terms of Google Maps Platform or any displayed data provider. Keep the visible attribution intact.
|
||||
|
||||
## Public README PNGs
|
||||
|
||||
Bilawal Sidhu published the following PNGs in the existing public `bilawalsidhu/gods-eye-view` repository and authorized their continued inclusion and redistribution with this repository and its project documentation:
|
||||
|
||||
- `youtube-popular-videos.png`
|
||||
- `open-source-survey.png`
|
||||
|
||||
Source: [`assets/youtube-popular-videos.png`](https://github.com/bilawalsidhu/gods-eye-view/blob/7afb3beefb2dd39110ed117c8f06825e9ca2ada4/assets/youtube-popular-videos.png) and [`assets/open-source-survey.png`](https://github.com/bilawalsidhu/gods-eye-view/blob/7afb3beefb2dd39110ed117c8f06825e9ca2ada4/assets/open-source-survey.png) in public repository commit [`7afb3beefb2dd39110ed117c8f06825e9ca2ada4`](https://github.com/bilawalsidhu/gods-eye-view/commit/7afb3beefb2dd39110ed117c8f06825e9ca2ada4). The localized files preserve the source bytes unchanged.
|
||||
|
||||
These files are not covered by the project's MIT License. No permission is granted for standalone reuse or modification. Commercial reuse outside this repository requires separate permission. No share-alike license applies.
|
||||
|
||||
The authorization covers the PNGs as project promotional material. It does not claim ownership of platform interfaces, trademarks, avatars, data, or third-party imagery visible within the screenshots. Those elements remain subject to their respective owners' terms.
|
||||
|
After Width: | Height: | Size: 6.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 8.2 MiB |
@@ -0,0 +1,66 @@
|
||||
# OpenSky Auth Setup
|
||||
|
||||
God's Eye View uses explicit auth modes for `/api/opensky`:
|
||||
|
||||
- `OPENSKY_AUTH_MODE=oauth` (default, recommended)
|
||||
- `OPENSKY_AUTH_MODE=auto` (OAuth first, then Basic fallback)
|
||||
- `OPENSKY_AUTH_MODE=basic`
|
||||
- `OPENSKY_AUTH_MODE=anon`
|
||||
|
||||
Reference: OpenSky REST API docs recommend OAuth2 Client Credentials flow.
|
||||
|
||||
## Quick Start (OAuth Client JSON)
|
||||
|
||||
Import credentials from JSON (`clientId`/`clientSecret` or `client_id`/`client_secret`) into Keychain:
|
||||
|
||||
```bash
|
||||
./scripts/opensky-import-client.sh /path/to/credentials.json
|
||||
# or: npm run opensky:import -- /path/to/credentials.json
|
||||
```
|
||||
|
||||
Then launch:
|
||||
|
||||
```bash
|
||||
./scripts/dev-fresh.sh
|
||||
```
|
||||
|
||||
Expected startup lines:
|
||||
|
||||
- `OpenSky auth mode: oauth`
|
||||
- `OpenSky OAuth: configured`
|
||||
|
||||
## Optional: Launch With File (No Keychain Import)
|
||||
|
||||
```bash
|
||||
OPENSKY_CREDENTIALS_FILE=/path/to/credentials.json ./scripts/dev-fresh.sh
|
||||
```
|
||||
|
||||
Launchers resolve OAuth creds in this order:
|
||||
|
||||
1. `OPENSKY_CLIENT_ID` + `OPENSKY_CLIENT_SECRET`
|
||||
2. `OPENSKY_CREDENTIALS_FILE`
|
||||
3. Keychain (`opensky-network` / `client_id`, `client_secret`)
|
||||
|
||||
## Basic Auth (Legacy)
|
||||
|
||||
Basic auth is still supported for now, but OpenSky is moving away from it.
|
||||
|
||||
```bash
|
||||
OPENSKY_AUTH_MODE=basic ./scripts/dev-fresh.sh
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Inspect proxy headers:
|
||||
|
||||
```bash
|
||||
curl -si http://localhost:4173/api/opensky | grep -iE 'HTTP/|X-OpenSky-Auth'
|
||||
```
|
||||
|
||||
Useful reasons:
|
||||
|
||||
- `oauth_invalid_or_missing`: OAuth client not found/usable
|
||||
- `oauth_invalid_credentials`: OAuth client rejected by OpenSky
|
||||
- `basic_invalid_credentials`: username/password rejected
|
||||
- `missing_basic_creds`: basic mode missing user/pass
|
||||
- `rate_limited`: OpenSky throttling
|
||||
@@ -0,0 +1,899 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>God's Eye View</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Round" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="cesiumContainer"></div>
|
||||
<div id="world-overlay-root" aria-hidden="true">
|
||||
<canvas id="world-overlay-canvas"></canvas>
|
||||
</div>
|
||||
<div id="world-overlay-actions" class="world-overlay-accessibility" role="region" aria-label="Visible map targets">
|
||||
<div id="world-overlay-action-list"></div>
|
||||
<div id="world-overlay-status" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div id="title-bar">
|
||||
<div class="title-glow"></div>
|
||||
<h1><span class="title-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span> <span>GOD'S EYE <span class="title-accent">VIEW</span></span></h1>
|
||||
<p class="subtitle">NO PLACE LEFT BEHIND</p>
|
||||
</div>
|
||||
|
||||
<!-- Style indicator -->
|
||||
<div id="style-indicator">
|
||||
<span class="indicator-label">ACTIVE STYLE</span>
|
||||
<span class="indicator-value" id="active-style-name">NORMAL</span>
|
||||
</div>
|
||||
|
||||
<!-- Top-center camera and share actions -->
|
||||
<nav id="top-center-actions" aria-label="Globe actions">
|
||||
<button id="clear-selected-layers" type="button" aria-label="Clear selected data layers" title="Turn off all selected data layers">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">layers_clear</span>
|
||||
</button>
|
||||
<button id="share-btn" type="button" aria-label="Copy share link" title="Copy share link">
|
||||
<span class="share-icon" aria-hidden="true">🔗</span>
|
||||
</button>
|
||||
<button id="reset-globe-view" type="button" aria-label="Reset to full globe view" title="Reset camera and return to full globe view">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">public</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div id="global-loading-status" role="status" aria-live="polite" aria-atomic="true" hidden>
|
||||
<span id="global-loading-label">LOADING LIVE DATA</span>
|
||||
<span id="global-loading-detail"></span>
|
||||
</div>
|
||||
<div id="traffic-sync-chip" aria-live="polite">
|
||||
<span id="traffic-sync-label">syncing road network</span>
|
||||
<span id="traffic-sync-progress">0%</span>
|
||||
</div>
|
||||
<div id="cctv-sync-chip" aria-live="polite">
|
||||
<span id="cctv-sync-label">loading frames</span>
|
||||
<span id="cctv-sync-progress">0/0</span>
|
||||
</div>
|
||||
|
||||
<!-- Toast notification -->
|
||||
<div id="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
|
||||
<!-- Safe frame guides for recording composition -->
|
||||
<div id="safe-frame-overlay">
|
||||
<div id="safe-frame-box"></div>
|
||||
</div>
|
||||
|
||||
<!-- Aircraft first-person view. It can be entered from the visible view switcher. -->
|
||||
<section id="cockpit-hud" aria-label="Aircraft cockpit view" hidden>
|
||||
<div class="cockpit-visor-shell" aria-hidden="true">
|
||||
<div class="cockpit-roll-arc">
|
||||
<span style="--slot:-3;--depth:3">−30</span>
|
||||
<span style="--slot:-2;--depth:2">−20</span>
|
||||
<span style="--slot:-1;--depth:1">−10</span>
|
||||
<span class="active" style="--slot:0;--depth:0">0</span>
|
||||
<span style="--slot:1;--depth:1">+10</span>
|
||||
<span style="--slot:2;--depth:2">+20</span>
|
||||
<span style="--slot:3;--depth:3">+30</span>
|
||||
</div>
|
||||
<div class="cockpit-pitch-rail cockpit-pitch-rail-left">
|
||||
<span>+20</span><span>+10</span><span class="active">00</span><span>−10</span><span>−20</span>
|
||||
</div>
|
||||
<div class="cockpit-pitch-rail cockpit-pitch-rail-right">
|
||||
<span>+20</span><span>+10</span><span class="active">00</span><span>−10</span><span>−20</span>
|
||||
</div>
|
||||
<div class="cockpit-horizon-guide">
|
||||
<span></span><small>LEVEL</small><span></span>
|
||||
</div>
|
||||
<div id="cockpit-route-direction" class="cockpit-route-direction" hidden aria-label="Estimated destination direction">
|
||||
<svg class="cockpit-route-chevron" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M12 2 4.58 19.93c-.34.83.51 1.63 1.31 1.27L12 18.47l6.11 2.73c.8.36 1.65-.44 1.31-1.27L12 2Z" />
|
||||
</svg>
|
||||
<small id="cockpit-route-direction-label">DEST ---°</small>
|
||||
</div>
|
||||
<div class="cockpit-visor-status cockpit-visor-status-left">OPTICAL PLANE · 01</div>
|
||||
<div class="cockpit-visor-status cockpit-visor-status-right">VISOR LOCK · ACTIVE</div>
|
||||
</div>
|
||||
<div id="cockpit-speed-rim" class="cockpit-altitude-rim cockpit-speed-rim">
|
||||
<span class="cockpit-altitude-rim-label">GROUND SPEED · KTS</span>
|
||||
<div class="cockpit-altitude-rim-rail"></div>
|
||||
<div class="cockpit-altitude-rim-scale">
|
||||
<span data-speed-rim-tick><b></b><i></i></span>
|
||||
<span data-speed-rim-tick><b></b><i></i></span>
|
||||
<span data-speed-rim-tick><b></b><i></i></span>
|
||||
<span data-speed-rim-tick><b></b><i></i></span>
|
||||
<span data-speed-rim-tick><b></b><i></i></span>
|
||||
<span data-speed-rim-tick><b></b><i></i></span>
|
||||
<span data-speed-rim-tick><b></b><i></i></span>
|
||||
<span data-speed-rim-tick><b></b><i></i></span>
|
||||
<span data-speed-rim-tick><b></b><i></i></span>
|
||||
</div>
|
||||
<div class="cockpit-altitude-rim-pointer">
|
||||
<span class="material-symbols-outlined">arrow_right</span>
|
||||
<strong id="cockpit-speed-rim-value">---</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cockpit-altitude-rim" class="cockpit-altitude-rim">
|
||||
<span class="cockpit-altitude-rim-label">ALTITUDE · FT</span>
|
||||
<div class="cockpit-altitude-rim-rail"></div>
|
||||
<div class="cockpit-altitude-rim-scale">
|
||||
<span data-altitude-rim-tick><b></b><i></i></span>
|
||||
<span data-altitude-rim-tick><b></b><i></i></span>
|
||||
<span data-altitude-rim-tick><b></b><i></i></span>
|
||||
<span data-altitude-rim-tick><b></b><i></i></span>
|
||||
<span data-altitude-rim-tick><b></b><i></i></span>
|
||||
<span data-altitude-rim-tick><b></b><i></i></span>
|
||||
<span data-altitude-rim-tick><b></b><i></i></span>
|
||||
<span data-altitude-rim-tick><b></b><i></i></span>
|
||||
<span data-altitude-rim-tick><b></b><i></i></span>
|
||||
</div>
|
||||
<div class="cockpit-altitude-rim-pointer">
|
||||
<strong id="cockpit-altitude-rim-value">-----</strong>
|
||||
<span class="material-symbols-outlined">arrow_left</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cockpit-topline">
|
||||
<div>
|
||||
<span class="cockpit-kicker">FIRST PERSON</span>
|
||||
<strong id="cockpit-callsign">AIRCRAFT</strong>
|
||||
<span id="cockpit-aircraft-meta" class="cockpit-aircraft-meta">LIVE TRACK · COURSE ALIGNED</span>
|
||||
</div>
|
||||
<div class="cockpit-vision-controls" role="group" aria-label="Cockpit vision style">
|
||||
<button id="cockpit-vision-previous" class="cockpit-vision-nav" type="button" aria-label="Previous cockpit vision style" title="Previous vision style">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
|
||||
<small>PREV</small>
|
||||
</button>
|
||||
<button id="cockpit-vision-current" class="cockpit-vision-current" type="button" data-cockpit-vision="optical" aria-label="Current cockpit vision style: NORMAL. Activate for next style." title="Current style: NORMAL — click for next">
|
||||
<small>CURRENT</small>
|
||||
<strong id="cockpit-vision-current-label" aria-live="polite">NORMAL</strong>
|
||||
</button>
|
||||
<button id="cockpit-vision-next" class="cockpit-vision-nav" type="button" aria-label="Next cockpit vision style" title="Next vision style">
|
||||
<small>NEXT</small>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cockpit-reticle material-symbols-outlined" aria-hidden="true">my_location</div>
|
||||
<div class="cockpit-compass" aria-label="Current aircraft heading">
|
||||
<div class="cockpit-instrument-row">
|
||||
<div class="cockpit-readout cockpit-speed">
|
||||
<span class="cockpit-readout-label">GROUND SPEED</span>
|
||||
<strong id="cockpit-speed-value">---</strong>
|
||||
<span class="cockpit-readout-unit">KTS</span>
|
||||
</div>
|
||||
<div class="cockpit-heading-value"><span id="cockpit-heading-value">---</span><small>°</small></div>
|
||||
<div class="cockpit-readout cockpit-altitude">
|
||||
<span class="cockpit-readout-label">ALTITUDE</span>
|
||||
<strong id="cockpit-altitude-value">-----</strong>
|
||||
<span class="cockpit-readout-unit">FT</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="cockpit-heading-caret material-symbols-outlined" aria-hidden="true">arrow_drop_down</span>
|
||||
<div class="cockpit-compass-tape" id="cockpit-compass-tape" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="cockpit-position-readout">
|
||||
<span id="cockpit-clock">--:--:--Z</span>
|
||||
<span id="cockpit-position">--° · --°</span>
|
||||
</div>
|
||||
<aside id="cockpit-context" class="cockpit-context-window" aria-label="Contact cockpit summary" hidden>
|
||||
<header class="cockpit-context-header">
|
||||
<div>
|
||||
<span class="cockpit-context-kicker">CONTACT</span>
|
||||
<strong id="cockpit-context-subject">CONTACTS · 250 KM</strong>
|
||||
</div>
|
||||
<nav class="cockpit-context-actions" aria-label="Contact navigation">
|
||||
<button id="cockpit-context-previous" type="button" aria-label="Previous — prior visited contact in the 250 km window" title="Previous — prior visited contact in the 250 km window">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">skip_previous</span>
|
||||
</button>
|
||||
<button id="cockpit-context-next" type="button" aria-label="Next — nearest unvisited contact in the 250 km window" title="Next — nearest unvisited contact in the 250 km window">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">skip_next</span>
|
||||
</button>
|
||||
<button id="cockpit-context-toggle" type="button" aria-label="Collapse Contact panel" aria-expanded="true" title="Collapse contact panel">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
|
||||
</button>
|
||||
</nav>
|
||||
<span class="cockpit-context-qualifier">CONTEXT ONLY</span>
|
||||
</header>
|
||||
<div class="cockpit-context-cohorts" role="list" aria-label="Nearby cohort counts">
|
||||
<div role="listitem" data-context-cohort="flights"><span>FLT</span><strong>—</strong><small>OpenSky</small></div>
|
||||
<div role="listitem" data-context-cohort="military"><span>MIL</span><strong>—</strong><small>adsb.lol</small></div>
|
||||
<div role="listitem" data-context-cohort="ais-live-vessels"><span>AIS</span><strong>—</strong><small>AISStream</small></div>
|
||||
<div role="listitem" data-context-cohort="military-installations"><span>SITE</span><strong>—</strong><small>OpenStreetMap</small></div>
|
||||
</div>
|
||||
<div class="cockpit-context-nearest">
|
||||
<span id="cockpit-context-direction" class="material-symbols-outlined" aria-hidden="true">navigation</span>
|
||||
<div>
|
||||
<small>NEAREST OBSERVED / MAPPED</small>
|
||||
<strong id="cockpit-context-nearest-label">NO AVAILABLE EXAMPLE</strong>
|
||||
</div>
|
||||
<span id="cockpit-context-bearing">—</span>
|
||||
<span id="cockpit-context-distance">—</span>
|
||||
</div>
|
||||
<footer class="cockpit-context-footer">
|
||||
<span id="cockpit-context-uncertainty">AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR</span>
|
||||
<button
|
||||
id="cockpit-weather-toggle"
|
||||
class="cockpit-context-weather"
|
||||
type="button"
|
||||
aria-label="Enable cockpit weather effects"
|
||||
aria-pressed="false"
|
||||
title="Enable cockpit weather effects"
|
||||
>
|
||||
<span>WX</span>
|
||||
<strong id="cockpit-weather-state">OFF</strong>
|
||||
</button>
|
||||
<time id="cockpit-context-updated">--:--:--Z</time>
|
||||
</footer>
|
||||
</aside>
|
||||
<aside id="cockpit-signal-stream" class="cockpit-signal-window" aria-label="Cockpit briefing carousel" hidden>
|
||||
<section id="cockpit-route" class="cockpit-route-card" aria-label="Estimated flight plan" hidden>
|
||||
<div class="cockpit-route-heading">
|
||||
<span class="cockpit-route-kicker">ESTIMATED FLIGHT PLAN</span>
|
||||
<small id="cockpit-route-status">ROUTE DATA UNAVAILABLE</small>
|
||||
</div>
|
||||
<div class="cockpit-route-endpoints">
|
||||
<div class="cockpit-route-airport">
|
||||
<span>FROM</span>
|
||||
<strong id="cockpit-route-from">UNKNOWN</strong>
|
||||
</div>
|
||||
<span class="cockpit-route-arrow material-symbols-outlined" aria-hidden="true">east</span>
|
||||
<div class="cockpit-route-airport">
|
||||
<span>TO</span>
|
||||
<strong id="cockpit-route-to">UNKNOWN</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<header class="cockpit-signal-header">
|
||||
<div class="cockpit-brief-heading">
|
||||
<span id="cockpit-brief-kicker" class="cockpit-signal-kicker"><i aria-hidden="true"></i> LIVE SIGNALS</span>
|
||||
<strong id="cockpit-brief-subtitle">OBSERVED / MAPPED PINGS</strong>
|
||||
</div>
|
||||
<nav class="cockpit-brief-actions" aria-label="Cockpit briefing controls">
|
||||
<button id="cockpit-brief-previous" type="button" aria-label="Previous briefing page" title="Previous briefing page">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
|
||||
</button>
|
||||
<span id="cockpit-brief-position" aria-live="polite">1 / 3</span>
|
||||
<button id="cockpit-brief-next" type="button" aria-label="Next briefing page" title="Next briefing page">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">chevron_right</span>
|
||||
</button>
|
||||
<button
|
||||
id="cockpit-brief-auto"
|
||||
type="button"
|
||||
aria-label="CYCLE OFF"
|
||||
aria-pressed="false"
|
||||
title="Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way."
|
||||
>CYCLE OFF</button>
|
||||
<button id="cockpit-signal-toggle" type="button" aria-label="Collapse cockpit briefing panel" aria-expanded="true" title="Collapse briefing panel">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">right_panel_close</span>
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="cockpit-brief-stage">
|
||||
<section data-cockpit-brief-page="signals" aria-label="Live signals">
|
||||
<ol id="cockpit-signal-list" class="cockpit-signal-list" aria-live="polite"></ol>
|
||||
</section>
|
||||
<section data-cockpit-brief-page="news" aria-label="Latest regional news" hidden>
|
||||
<div id="cockpit-news-status" class="cockpit-brief-status">ACQUIRING REGIONAL NEWS</div>
|
||||
<ol id="cockpit-news-list" class="cockpit-news-list" aria-live="polite"></ol>
|
||||
</section>
|
||||
<section data-cockpit-brief-page="local" aria-label="Location-based information" hidden>
|
||||
<div class="cockpit-local-place">
|
||||
<strong id="cockpit-local-place">RESOLVING REGION</strong>
|
||||
<small id="cockpit-local-coordinates">--</small>
|
||||
</div>
|
||||
<div class="cockpit-local-grid">
|
||||
<div><span>TEMP</span><strong id="cockpit-local-temperature">—</strong><small>OPEN-METEO</small></div>
|
||||
<div><span>WIND</span><strong id="cockpit-local-wind">—</strong><small id="cockpit-local-wind-direction">—</small></div>
|
||||
<div><span>SKY</span><strong id="cockpit-local-condition">UNKNOWN</strong><small id="cockpit-local-cloud">—</small></div>
|
||||
<div><span>PRECIP</span><strong id="cockpit-local-precipitation">—</strong><small>MM</small></div>
|
||||
</div>
|
||||
<a class="cockpit-weather-credit" href="https://open-meteo.com/" target="_blank" rel="noopener">Weather data by Open-Meteo.com</a>
|
||||
</section>
|
||||
</div>
|
||||
<footer class="cockpit-signal-footer">
|
||||
<span id="cockpit-brief-source">SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS</span>
|
||||
<nav class="cockpit-brief-tabs" aria-label="Cockpit briefing pages">
|
||||
<button type="button" data-cockpit-brief-index="0" aria-label="Show Live Signals" aria-current="true">SIG</button>
|
||||
<button type="button" data-cockpit-brief-index="1" aria-label="Show Regional News">NEWS</button>
|
||||
<button type="button" data-cockpit-brief-index="2" aria-label="Show Local Info">LOCAL</button>
|
||||
</nav>
|
||||
</footer>
|
||||
</aside>
|
||||
<aside id="cockpit-utility-controls" class="cockpit-utility-controls" aria-label="Cockpit display and Radio controls">
|
||||
<div class="cockpit-utility-control">
|
||||
<div class="cockpit-utility-launcher" data-cockpit-launcher="display">
|
||||
<span>DISPLAY</span>
|
||||
<span class="cockpit-utility-divider" aria-hidden="true"></span>
|
||||
<button id="cockpit-display-toggle-btn" class="cockpit-utility-glyph" type="button" aria-expanded="false" aria-controls="cockpit-display-panel" aria-label="Expand Cockpit display options" title="Expand Cockpit display options">◀</button>
|
||||
</div>
|
||||
<div id="cockpit-display-panel" class="cockpit-utility-popover" role="group" aria-label="Cockpit display options" hidden>
|
||||
<div class="cockpit-display-slot" data-cockpit-display-slot="hud"></div>
|
||||
<div class="cockpit-display-slot" data-cockpit-display-slot="detection"></div>
|
||||
<div class="cockpit-display-slot" data-cockpit-display-slot="parameters"></div>
|
||||
<div class="cockpit-display-slot" data-cockpit-display-slot="models3d"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cockpit-utility-control">
|
||||
<div class="cockpit-utility-launcher" data-cockpit-launcher="radio">
|
||||
<span>RADIO</span>
|
||||
<span class="cockpit-utility-divider" aria-hidden="true"></span>
|
||||
<button id="cockpit-radio-toggle-btn" class="cockpit-utility-glyph" type="button" aria-expanded="false" aria-controls="cockpit-radio-panel" aria-label="Expand Cockpit Radio controls" title="Expand Cockpit Radio controls">◀</button>
|
||||
</div>
|
||||
<div id="cockpit-radio-panel" class="cockpit-utility-popover cockpit-radio-compact" role="group" aria-label="Cockpit compact Radio controls" hidden>
|
||||
<strong id="cockpit-radio-station" class="cockpit-radio-station">READY</strong>
|
||||
<button id="cockpit-radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false">ENABLE</button>
|
||||
<div class="cockpit-radio-transport">
|
||||
<button id="cockpit-radio-prev-btn" type="button" aria-label="Previous filtered radio station" disabled>◀◀</button>
|
||||
<button id="cockpit-radio-play-btn" type="button" aria-label="Play selected radio station" disabled>▶</button>
|
||||
<button id="cockpit-radio-next-btn" type="button" aria-label="Next filtered radio station" disabled>▶▶</button>
|
||||
</div>
|
||||
<label class="cockpit-radio-volume" for="cockpit-radio-volume">
|
||||
<span>VOLUME</span>
|
||||
<input id="cockpit-radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Cockpit Radio volume" disabled />
|
||||
<output id="cockpit-radio-volume-value" class="gev-slider-value" for="cockpit-radio-volume">80%</output>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="cockpit-help">ESC EXIT <span>·</span> C TOGGLE</div>
|
||||
</section>
|
||||
|
||||
<nav id="view-switcher" aria-label="View switcher">
|
||||
<button id="cockpit-reset-globe" type="button" aria-label="Reset cockpit to full globe view" title="Exit cockpit and return to full globe view" hidden>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">public</span>
|
||||
<span>RESET</span>
|
||||
</button>
|
||||
<button id="map-view-switch" type="button" aria-label="Exit cockpit view" aria-keyshortcuts="Escape C" title="Exit cockpit view" hidden>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">close_fullscreen</span>
|
||||
<span>EXIT COCKPIT</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Post-processing toggles (top-right) -->
|
||||
<div id="pp-toggles" class="panel-collapsible collapsed" data-panel-id="pp-toggles">
|
||||
<div class="compact pp-header-row">
|
||||
<span class="pp-header-label">DISPLAY</span>
|
||||
<span class="panel-divider" aria-hidden="true"></span>
|
||||
<button class="panel-collapse-btn pp-collapse-btn" data-collapse-target="pp-toggles" title="Collapse panel">◀</button>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="hud-toggle" title="Intelligence HUD (H)">
|
||||
<span class="pp-icon">◎</span>
|
||||
<span class="pp-label">HUD</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" id="hud-layout-row">
|
||||
<span class="pp-slider-mini-label">Layout</span>
|
||||
<select id="hud-layout-select" class="pp-select" aria-label="HUD layout">
|
||||
<option value="tactical">Tactical</option>
|
||||
<option value="operator">Operator</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="detection-toggle" title="Detection Overlay (D)" aria-label="Detection overlay" aria-pressed="false">
|
||||
<span class="pp-icon">▣</span>
|
||||
<span class="pp-label">DETECT</span>
|
||||
</button>
|
||||
<div class="pp-slider-row detection-slider-row" id="detection-slider-row">
|
||||
<label class="pp-slider-mini-label" for="detection-density-slider">Density</label>
|
||||
<input type="range" class="pp-slider" id="detection-density-slider" min="0" max="100" step="25" value="50" aria-label="Detection label density" />
|
||||
<span class="pp-slider-value" id="detection-density-value">50%</span>
|
||||
</div>
|
||||
<div class="pp-slider-row detection-allocation-row" id="detection-allocation-row">
|
||||
<span class="pp-slider-mini-label">Allocation</span>
|
||||
<div class="pp-mode-seg detection-allocation-seg" role="radiogroup" aria-label="Detection label allocation">
|
||||
<button class="pp-mode-btn active" id="detection-allocation-elastic" data-allocation="ELASTIC" role="radio" aria-checked="true">Elastic</button>
|
||||
<button class="pp-mode-btn" id="detection-allocation-weighted" data-allocation="WEIGHTED" role="radio" aria-checked="false">Weighted</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-slider-row detection-fade-row" id="detection-fade-row">
|
||||
<label class="pp-slider-mini-label" for="detection-fade-slider">Fade</label>
|
||||
<input type="range" class="pp-slider" id="detection-fade-slider" min="0" max="40" step="1" value="7" aria-label="Detection fade distance" title="World-overlay fade distance outside the keyhole as a percentage of its radius" />
|
||||
<span class="pp-slider-value" id="detection-fade-value">7%</span>
|
||||
</div>
|
||||
<div class="pp-slider-row detection-opacity-row" id="detection-opacity-row">
|
||||
<label class="pp-slider-mini-label" for="detection-opacity-slider">Outside</label>
|
||||
<input type="range" class="pp-slider" id="detection-opacity-slider" min="0" max="100" step="1" value="1" aria-label="Detection opacity outside the keyhole" title="World-overlay label and card opacity beyond the fade distance" />
|
||||
<span class="pp-slider-value" id="detection-opacity-value">1%</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- The one shared preset-parameter surface is inserted here at runtime. -->
|
||||
<div id="param-slider-panel">
|
||||
<div class="param-panel-header">
|
||||
<span class="param-panel-title">PARAMETERS</span>
|
||||
<span class="param-panel-divider"></span>
|
||||
<button class="panel-collapse-btn" id="param-panel-collapse-btn" data-collapse-target="param-slider-panel" title="Collapse panel">−</button>
|
||||
</div>
|
||||
<div id="param-sliders"></div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<!-- 3D aircraft is DEFAULT-ON in Proximity (product invariant 2026-08-22), so the
|
||||
button ships lit and the mode row ships open — the same markup-carries-the-default
|
||||
convention as #scope-toggle. ui.js re-syncs both from durable layer state, which
|
||||
is what flips them back for a share link or a stored session carrying 3D off. -->
|
||||
<button class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true" title="3D aircraft — flat icons zoomed out, 3D models up close">
|
||||
<span class="pp-icon">✈</span>
|
||||
<span class="pp-label">3D</span>
|
||||
</button>
|
||||
<div class="pp-slider-row visible" id="models3d-mode-row">
|
||||
<span class="pp-slider-mini-label">Models</span>
|
||||
<div class="pp-mode-seg" role="radiogroup" aria-label="3D model coverage">
|
||||
<button class="pp-mode-btn active" id="models3d-mode-proximity" data-mode="proximity" role="radio" aria-checked="true">Proximity</button>
|
||||
<button class="pp-mode-btn" id="models3d-mode-all" data-mode="all" role="radio" aria-checked="false">All</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn active" id="scope-toggle" title="Scope — the circular viewport mask" aria-pressed="true">
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">adjust</span>
|
||||
<span class="pp-label">Scope</span>
|
||||
</button>
|
||||
<div class="pp-slider-row visible" id="scope-slider-row">
|
||||
<span class="pp-slider-mini-label">Feather</span>
|
||||
<!-- Feather starts at an 11% soft scope-mask edge (final value
|
||||
2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings);
|
||||
mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather
|
||||
softens the black mask edge; label fading is the separate Fade
|
||||
slider. -->
|
||||
<input type="range" class="pp-slider" id="scope-feather-slider" min="0" max="100" value="11" title="Scope edge feather as a percentage of the keyhole radius" />
|
||||
<span class="pp-slider-value" id="scope-feather-value">11%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="celestial-toggle" title="Celestial ring — reveal the full globe" aria-pressed="false">
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">flare</span>
|
||||
<span class="pp-label">Celestial</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="pp-toggle-btn" id="clean-view-toggle" title="Hide UI chrome">
|
||||
<span class="pp-icon">□</span>
|
||||
<span class="pp-label">Clean UI</span>
|
||||
</button>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="bloom-toggle" title="Bloom / Glow">
|
||||
<span class="pp-icon">✨</span>
|
||||
<span class="pp-label">Bloom</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" id="bloom-slider-row">
|
||||
<input type="range" class="pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" />
|
||||
<span class="pp-slider-value" id="bloom-intensity-value">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="sharpen-toggle" title="Sharpening">
|
||||
<span class="pp-icon">🔍</span>
|
||||
<span class="pp-label">Sharpen</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" id="sharpen-slider-row">
|
||||
<input type="range" class="pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" />
|
||||
<span class="pp-slider-value" id="sharpen-intensity-value">49%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="clean-view-exit" title="Return UI controls">EXIT CLEAN VIEW</button>
|
||||
|
||||
<!-- Unified bottom command dock: locations, voice (injected at runtime), visual presets -->
|
||||
<div id="command-dock" aria-label="Navigation, voice, and visual preset controls">
|
||||
<!-- Control panel -->
|
||||
<div id="control-panel" class="panel-collapsible collapsed">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="panel-inner">
|
||||
<button id="control-panel-toggle" class="panel-header dock-tray-toggle" type="button"
|
||||
data-dock-toggle-target="control-panel" aria-expanded="false"
|
||||
aria-controls="control-panel-popover" aria-label="Expand Visual Presets">
|
||||
<span class="panel-title"><span class="dock-label-icon dock-label-icon-presets" aria-hidden="true"></span>VISUAL PRESETS</span>
|
||||
<span class="panel-divider"></span>
|
||||
</button>
|
||||
<div id="control-panel-popover" class="dock-popover-content">
|
||||
<button class="dock-pin-btn" data-pin-target="control-panel" type="button" aria-label="Pin visual presets" aria-pressed="false" title="Keep visual presets open"><img src="/pin.svg" alt="" /></button>
|
||||
<div id="style-buttons" class="button-grid">
|
||||
<button class="style-btn active" data-style="normal">
|
||||
<span class="btn-icon">◯</span>
|
||||
<span class="btn-label">Normal</span>
|
||||
<span class="btn-key">1</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="retro">
|
||||
<span class="btn-icon">▦</span>
|
||||
<span class="btn-label">CRT</span>
|
||||
<span class="btn-key">2</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="surveillance">
|
||||
<span class="btn-icon">🌙</span>
|
||||
<span class="btn-label">NVG</span>
|
||||
<span class="btn-key">3</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="thermal">
|
||||
<span class="btn-icon">🌡️</span>
|
||||
<span class="btn-label">FLIR</span>
|
||||
<span class="btn-key">4</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="anime">
|
||||
<span class="btn-icon">✦</span>
|
||||
<span class="btn-label">Anime</span>
|
||||
<span class="btn-key">5</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="noir">
|
||||
<span class="btn-icon">◐</span>
|
||||
<span class="btn-label">Noir</span>
|
||||
<span class="btn-key">6</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="snow">
|
||||
<span class="btn-icon">❄</span>
|
||||
<span class="btn-label">Snow</span>
|
||||
<span class="btn-key">7</span>
|
||||
</button>
|
||||
</div>
|
||||
<section class="map-source-section" aria-labelledby="map-source-label">
|
||||
<div class="map-source-heading">
|
||||
<span id="map-source-label">MAP SOURCE</span>
|
||||
<span id="map-stack-status" class="pp-stack-status" aria-live="polite">3D</span>
|
||||
</div>
|
||||
<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source"></div>
|
||||
</section>
|
||||
</div>
|
||||
<div id="style-mini-status" class="panel-mini-status" aria-live="polite">
|
||||
<span class="panel-mini-label">Style</span>
|
||||
<span id="style-mini-value" class="panel-mini-value">NORMAL</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location bar -->
|
||||
<div id="location-bar" class="panel-collapsible collapsed">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="location-inner">
|
||||
<div class="location-toolbar">
|
||||
<span class="location-toolbar-label"><span class="dock-label-icon dock-label-icon-location" aria-hidden="true"></span>LOCATION</span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="location-bar" title="Collapse panel">+</button>
|
||||
</div>
|
||||
<div id="location-mini-status" class="location-mini-status" aria-live="polite">
|
||||
<div id="location-mini-city" class="location-mini-city">📍 Location: --</div>
|
||||
<div id="location-mini-poi" class="location-mini-poi">Landmark: --</div>
|
||||
</div>
|
||||
<div class="dock-popover-content">
|
||||
<button class="dock-pin-btn" data-pin-target="location-bar" type="button" aria-label="Pin location tray" aria-pressed="false" title="Keep location tray open"><img src="/pin.svg" alt="" /></button>
|
||||
<div id="poi-row" class="poi-row-container"></div>
|
||||
<div class="location-bar-divider" id="location-bar-divider"></div>
|
||||
<div class="location-city-row">
|
||||
<div id="location-pills" class="location-pills"></div>
|
||||
<div class="location-search-wrap">
|
||||
<button id="search-toggle" class="search-toggle-btn" title="Search any location">
|
||||
<span>🔎</span>
|
||||
</button>
|
||||
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="left-panel-stack">
|
||||
<!-- Data Layer Toggle Panel -->
|
||||
<div id="data-panel" class="panel-collapsible collapsed active" data-panel-id="data-panel">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="data-panel-inner">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">DATA LAYERS</span>
|
||||
<span class="panel-divider"></span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="data-panel" title="Collapse panel">+</button>
|
||||
</div>
|
||||
<div id="data-toggles" class="data-toggle-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CCTV Coverage Panel -->
|
||||
<div id="cctv-panel" class="panel-collapsible collapsed" data-panel-id="cctv-panel">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="cctv-panel-inner">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">CCTV</span>
|
||||
<span class="panel-divider"></span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="cctv-panel" title="Collapse panel">+</button>
|
||||
</div>
|
||||
<div id="cctv-frame-wrap" class="cctv-frame-wrap">
|
||||
<img id="cctv-frame" alt="CCTV feed frame" />
|
||||
</div>
|
||||
<div id="cctv-source-badge" role="status" aria-live="polite">SOURCE · UNKNOWN</div>
|
||||
<div id="cctv-meta">Enable CCTV to load camera intersections</div>
|
||||
<div class="cctv-controls">
|
||||
<button id="cctv-enable-btn" class="scene-btn">CCTV OFF</button>
|
||||
<button id="cctv-nearest-btn" class="scene-btn">NEAREST</button>
|
||||
</div>
|
||||
<div class="cctv-controls">
|
||||
<button id="cctv-prev-btn" class="scene-btn">PREV</button>
|
||||
<select id="cctv-camera-select" aria-label="CCTV camera"></select>
|
||||
<button id="cctv-next-btn" class="scene-btn">NEXT</button>
|
||||
</div>
|
||||
<div class="cctv-controls">
|
||||
<button id="cctv-focus-btn" class="scene-btn">FOCUS</button>
|
||||
<button id="cctv-coverage-btn" class="scene-btn">COVERAGE OFF</button>
|
||||
<button id="cctv-auto-hop-btn" class="scene-btn">AUTO HOP OFF</button>
|
||||
</div>
|
||||
<div class="cctv-controls">
|
||||
<button id="cctv-projection-btn" class="scene-btn">PROJECTION ON</button>
|
||||
<span id="cctv-quality-chip">CAL · --</span>
|
||||
</div>
|
||||
<div class="cctv-calibration-block">
|
||||
<div class="cctv-calibration-header">
|
||||
<div class="cctv-summary-label">CALIBRATION</div>
|
||||
<button id="cctv-adjust-btn" class="scene-btn" title="Drag the camera in the world: rings rotate, arrows move, handles set range/FOV">ADJUST</button>
|
||||
</div>
|
||||
<div id="cctv-cal-readout" class="cctv-cal-readout" aria-label="Camera pose — click a value to type">
|
||||
<button class="cctv-cal-value" data-cal-field="heading" title="Heading (compass °) — click to type">HDG --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="pitch" title="Pitch (° up/down) — click to type">PITCH --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="fov" title="Horizontal FOV (°) — click to type">FOV --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="range" title="Range / monitor-plane distance (m) — click to type">RANGE --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="height" title="Mount height above ground (m) — click to type">HGT --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="north" title="North offset from catalog position (m) — click to type">ΔN --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="east" title="East offset from catalog position (m) — click to type">ΔE --</button>
|
||||
</div>
|
||||
<div class="cctv-controls cctv-calibration-actions">
|
||||
<button id="cctv-calib-save-btn" class="scene-btn">SAVE CAL</button>
|
||||
<button id="cctv-calib-reset-btn" class="scene-btn">RESET CAL</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cctv-summary-label">SCENE SUMMARY</div>
|
||||
<div id="cctv-summary">Enable CCTV to start camera-linked intelligence summaries.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene Director Panel -->
|
||||
<div id="scene-panel" class="panel-collapsible collapsed" data-panel-id="scene-panel">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="scene-panel-inner">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">SCENES</span>
|
||||
<span class="panel-divider"></span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="scene-panel" title="Collapse panel">+</button>
|
||||
</div>
|
||||
<div class="scene-controls">
|
||||
<select id="scene-select" aria-label="Scene recipe"></select>
|
||||
<button id="scene-new-btn" class="scene-btn">NEW</button>
|
||||
<button id="scene-delete-btn" class="scene-btn scene-btn-danger">DEL</button>
|
||||
</div>
|
||||
<div class="scene-controls">
|
||||
<button id="scene-capture-btn" class="scene-btn">CAPTURE SHOT</button>
|
||||
<button id="scene-update-shot-btn" class="scene-btn">UPDATE SHOT</button>
|
||||
</div>
|
||||
<div id="scene-shot-list" class="scene-shot-list"></div>
|
||||
<div class="scene-controls">
|
||||
<button id="scene-start-btn" class="scene-btn">START</button>
|
||||
<button id="scene-stop-btn" class="scene-btn scene-btn-danger">STOP</button>
|
||||
<button id="scene-next-btn" class="scene-btn">NEXT</button>
|
||||
</div>
|
||||
<div class="scene-controls">
|
||||
<button id="scene-export-btn" class="scene-btn">EXPORT PRESETS</button>
|
||||
<button id="scene-import-btn" class="scene-btn">IMPORT</button>
|
||||
<button id="scene-download-btn" class="scene-btn">RUN LOG</button>
|
||||
<input type="file" id="scene-import-file" accept="application/json,.json" hidden />
|
||||
</div>
|
||||
<div class="scene-progress">
|
||||
<div id="scene-progress-fill">0%</div>
|
||||
</div>
|
||||
<div id="scene-status">Ready</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Global Context is deliberately independent from the left layer accordion. -->
|
||||
<aside id="right-context-rail">
|
||||
<div id="global-context-panel" class="panel-collapsible collapsed" data-panel-id="global-context-panel">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="global-context-panel-inner">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">CONTEXT</span>
|
||||
<span class="panel-divider"></span>
|
||||
<div id="context-radio-dock" class="context-radio-dock">
|
||||
<button id="context-radio-toggle-btn" class="context-radio-toggle" type="button" aria-expanded="false" aria-controls="context-radio-mini" aria-label="Open compact Radio controls" title="Open compact Radio controls">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">radio</span>
|
||||
</button>
|
||||
<div id="context-radio-mini" class="context-radio-mini" role="group" aria-label="Compact Radio controls" hidden>
|
||||
<div class="context-radio-mini-heading">
|
||||
<span>RADIO</span>
|
||||
<strong id="context-radio-mini-station">RADIO READY</strong>
|
||||
<span class="context-radio-mini-actions">
|
||||
<button id="context-radio-details-btn" type="button" aria-label="Open detailed Radio controls" aria-expanded="false" aria-controls="radio-panel" title="Open detailed Radio controls">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">open_in_full</span>
|
||||
</button>
|
||||
<button id="context-radio-mini-close-btn" type="button" aria-label="Close compact Radio controls" title="Close compact Radio controls">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">close</span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<button id="context-radio-mini-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false">ENABLE</button>
|
||||
<div class="context-radio-mini-transport">
|
||||
<button id="context-radio-mini-prev-btn" type="button" aria-label="Previous filtered radio station" title="Previous station" disabled>◀◀</button>
|
||||
<button id="context-radio-mini-play-btn" type="button" aria-label="Play selected radio station" title="Play" disabled>▶</button>
|
||||
<button id="context-radio-mini-next-btn" type="button" aria-label="Next filtered radio station" title="Next station" disabled>▶▶</button>
|
||||
</div>
|
||||
<label class="context-radio-mini-volume" for="context-radio-mini-volume">
|
||||
<span>VOLUME</span>
|
||||
<input id="context-radio-mini-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Compact Radio volume" disabled />
|
||||
<output id="context-radio-mini-volume-value" class="gev-slider-value" for="context-radio-mini-volume">80%</output>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="panel-collapse-btn" data-collapse-target="global-context-panel" title="Expand panel">▶</button>
|
||||
</div>
|
||||
<div class="global-context-modes" role="tablist" aria-label="Context mode">
|
||||
<button id="global-context-flights-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" aria-controls="context-flights-view" aria-label="CONTACTS" title="Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
|
||||
<span>CONTACTS</span>
|
||||
</button>
|
||||
<button id="global-context-missions-btn" class="context-mode-button" type="button" role="tab" tabindex="-1" aria-selected="false" aria-controls="context-missions-view">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span>
|
||||
<span>SPACE MISSIONS</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="context-mode-standby" class="context-mode-standby">
|
||||
<strong>SELECT CONTEXT</strong>
|
||||
<span>CONTACTS — nearest planes · vessels · sites<br>SPACE MISSIONS — launches & orbital assets</span>
|
||||
</div>
|
||||
<section id="context-flights-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-flights-btn" hidden>
|
||||
<div class="global-context-actions" aria-label="Contact Context actions">
|
||||
<button id="cockpit-entry" type="button" hidden>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">flight</span>
|
||||
<span>COCKPIT</span>
|
||||
</button>
|
||||
<button id="installations-search-btn" class="panel-layer-toggle" type="button">SEARCH NEARBY SITES</button>
|
||||
<button id="tr3b-toggle" class="tr3b-toggle" type="button" aria-pressed="false" hidden aria-label="Reclassify tracked contact as TR-3B" title="Reclassify as TR-3B">🛸</button>
|
||||
</div>
|
||||
<aside id="military-awareness-panel">
|
||||
<div class="military-awareness-standby">
|
||||
<strong>CONTACTS CONTEXT OFF</strong>
|
||||
<span>SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY</span>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
<section id="context-missions-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-missions-btn" hidden>
|
||||
<div id="space-mission-panel-host">
|
||||
<div id="space-mission-roster" class="space-mission-roster" aria-label="Available Space Missions">
|
||||
<div class="space-mission-roster-header">
|
||||
<div>
|
||||
<strong>AVAILABLE MISSIONS</strong>
|
||||
<span>SELECT A MISSION TO INSPECT</span>
|
||||
</div>
|
||||
<output data-mission-roster-count>—</output>
|
||||
</div>
|
||||
<div class="space-mission-roster-list" data-mission-roster-list>
|
||||
<div class="space-mission-roster-empty">LOADING 30-DAY MISSION INDEX</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="radio-panel" class="panel-collapsible collapsed" data-panel-id="radio-panel" aria-label="Internet radio companion">
|
||||
<div class="radio-panel-inner">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">RADIO</span>
|
||||
<span id="radio-layer-state" class="radio-layer-state">OFF</span>
|
||||
<span class="panel-divider"></span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="radio-panel" title="Expand Radio" aria-label="Expand Radio section">+</button>
|
||||
</div>
|
||||
<div class="radio-directory-row">
|
||||
<button id="radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false">ENABLE</button>
|
||||
<label class="radio-filter-label" for="radio-filter">STATION TAG</label>
|
||||
<select id="radio-filter" aria-label="Filter stations by station tag" disabled>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="radio-station-card" class="radio-station-card" aria-live="polite">
|
||||
<strong id="radio-station-name">NO STATION SELECTED</strong>
|
||||
<span id="radio-station-meta">Enable Radio, then choose a globe marker or use next.</span>
|
||||
<span id="radio-station-tags"></span>
|
||||
</div>
|
||||
<div id="radio-tuner" class="radio-tuner" hidden>
|
||||
<div class="radio-tuner-header">
|
||||
<span id="radio-tuner-band-label">DIRECTORY BAND</span>
|
||||
<output id="radio-tuner-value" for="radio-tuner-slider">DRAG TO TUNE</output>
|
||||
</div>
|
||||
<div class="radio-tuner-dial">
|
||||
<div class="radio-tuner-scale" aria-hidden="true"></div>
|
||||
<div id="radio-tuner-needle" class="radio-tuner-needle" aria-hidden="true"></div>
|
||||
<input id="radio-tuner-slider" type="range" min="0" max="0" step="1" value="0"
|
||||
aria-label="Tune available internet radio stations" aria-valuetext="No station available" disabled />
|
||||
</div>
|
||||
<div class="radio-tuner-readout">
|
||||
<span id="radio-tuner-station">ALL · DRAG THE NEEDLE</span>
|
||||
<span>SNAPS TO AVAILABLE STATIONS</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="radio-transport" role="group" aria-label="Radio playback">
|
||||
<button id="radio-prev-btn" type="button" aria-label="Previous filtered station" disabled>PREV</button>
|
||||
<button id="radio-play-btn" type="button" aria-label="Play selected station" disabled>PLAY</button>
|
||||
<button id="radio-next-btn" type="button" aria-label="Next filtered station" disabled>NEXT</button>
|
||||
<button id="radio-stop-btn" type="button" aria-label="Stop radio playback" disabled>STOP</button>
|
||||
</div>
|
||||
<label class="radio-volume-row" for="radio-volume">
|
||||
<span>VOLUME</span>
|
||||
<input id="radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Radio volume" />
|
||||
<output id="radio-volume-value" class="gev-slider-value" for="radio-volume">80%</output>
|
||||
</label>
|
||||
<div id="radio-playback-state" class="radio-playback-state" role="status" aria-live="polite">Radio off</div>
|
||||
<div class="radio-links">
|
||||
<a id="radio-station-homepage" href="#" target="_blank" rel="noopener noreferrer" hidden>STATION SITE</a>
|
||||
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer">DIRECTORY: RADIO BROWSER</a>
|
||||
</div>
|
||||
<p class="radio-privacy">Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div id="scene-runtime"></div>
|
||||
|
||||
<!-- Explicit mission choice on a fresh session. Live feeds are not auto-enabled
|
||||
until the visitor asks; shared links skip this surface entirely. It returns
|
||||
every session until "Don't show this again" is ticked — see
|
||||
src/firstRunExperience.js for the full show policy. -->
|
||||
<aside id="first-run-launcher" role="dialog" aria-labelledby="first-run-title" aria-describedby="first-run-description" hidden>
|
||||
<div class="first-run-scanline" aria-hidden="true"></div>
|
||||
<header class="first-run-header">
|
||||
<span class="first-run-kicker">MISSION CONTROL · FIRST LAUNCH</span>
|
||||
</header>
|
||||
<h2 id="first-run-title">Choose your first view</h2>
|
||||
<!-- The card's one persuasive line, final and VERBATIM (2026-08-23)
|
||||
— including the unspaced em dash. The arc it has to carry: this looks
|
||||
like something you should not have access to → the sources are all
|
||||
public → the data is real. Tile subcopy stays purely functional. -->
|
||||
<p id="first-run-description">It feels like a forbidden cockpit—then you realize the sources are public and the data is real.</p>
|
||||
<div class="first-run-choices">
|
||||
<button type="button" data-first-run-choice="contacts">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
|
||||
<span><strong>LIVE CONTACTS</strong><small>Aircraft, vessels and nearby intelligence</small></span>
|
||||
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
|
||||
</button>
|
||||
<button type="button" data-first-run-choice="space-missions">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span>
|
||||
<span><strong>SPACE MISSIONS</strong><small>Launches, spacecraft and orbital context</small></span>
|
||||
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
|
||||
</button>
|
||||
<button type="button" data-first-run-choice="environmental">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">local_fire_department</span>
|
||||
<!-- Title text is painted from ENVIRONMENTAL_LABEL_CHOICE at init. -->
|
||||
<!-- Subcopy names BOTH feeds this tile turns on, because the launcher
|
||||
optimizes for the fully configured experience (product decision,
|
||||
2026-08-23) — see the mission table in src/firstRunExperience.js. -->
|
||||
<span><strong data-first-run-environmental-title>ENVIRONMENTAL</strong><small>Live earthquakes and active fires, from USGS and NASA</small></span>
|
||||
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
|
||||
</button>
|
||||
<button type="button" class="first-run-explore" data-first-run-choice="explore">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">public</span>
|
||||
<span><strong>EXPLORE MANUALLY</strong><small>Begin with a clean globe</small></span>
|
||||
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="first-run-footer">
|
||||
<label class="first-run-suppress">
|
||||
<input type="checkbox" data-first-run-suppress />
|
||||
<span>Don't show this again</span>
|
||||
</label>
|
||||
<span>ESC to dismiss</span>
|
||||
</div>
|
||||
<!-- Kept out of the footer row: it swaps to mission progress and errors, and
|
||||
a live region that also holds layout makes the row jump as it speaks. -->
|
||||
<p class="first-run-note" data-first-run-status role="status" aria-live="polite">Tip: the GEV MIC button in the dock lets you talk to the map.</p>
|
||||
</aside>
|
||||
|
||||
<!-- Intelligence HUD Overlay -->
|
||||
<div id="intel-hud"></div>
|
||||
|
||||
<!-- Loading screen -->
|
||||
<div id="loading-screen">
|
||||
<div class="loader-content">
|
||||
<span class="loader-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span>
|
||||
<h2>GOD'S EYE <span class="title-accent">VIEW</span></h2>
|
||||
<p class="loader-status">Initializing photorealistic world...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "gods-eye-view",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Bilawal Sidhu",
|
||||
"homepage": "https://github.com/bilawalsidhu/gods-eye-view#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/bilawalsidhu/gods-eye-view.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/bilawalsidhu/gods-eye-view/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.14.0 <25 || >=26 <27"
|
||||
},
|
||||
"keywords": [
|
||||
"cesium",
|
||||
"3d-globe",
|
||||
"geospatial",
|
||||
"osint",
|
||||
"spatial-intelligence",
|
||||
"photorealistic-3d-tiles",
|
||||
"adsb",
|
||||
"ais",
|
||||
"satellites",
|
||||
"voice-control",
|
||||
"openai-realtime",
|
||||
"visualization"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:secure": "./scripts/dev-secure.sh",
|
||||
"opensky:import": "./scripts/opensky-import-client.sh",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "node scripts/run-unit-tests.mjs",
|
||||
"test:track": "node scripts/track-regression.mjs",
|
||||
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mapbox/vector-tile": "^3.0.0",
|
||||
"cesium": "^1.124.0",
|
||||
"egm96-universal": "^1.1.1",
|
||||
"mgrs": "^2.1.0",
|
||||
"pbf": "^5.1.2",
|
||||
"satellite.js": "^6.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"puppeteer": "^24.37.5",
|
||||
"sharp": "^0.34.5",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-cesium": "^1.2.23",
|
||||
"ws": "^8.21.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#36dcff" aria-hidden="true">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
|
||||
<circle cx="12" cy="10" r="3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 325 B |
@@ -0,0 +1,43 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="180 120 775 520" role="img" aria-labelledby="logo-title">
|
||||
<title id="logo-title">God's Eye View</title>
|
||||
<defs>
|
||||
<style>
|
||||
.cls-4 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.cls-2, .cls-4 {
|
||||
stroke: #000;
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.cls-1 {
|
||||
fill: #4454727a;
|
||||
stroke: #48b;
|
||||
stroke-width: 10px;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2, .cls-3, .cls-4 {
|
||||
fill-rule: evenodd;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #00f6ff;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
stroke: #00f6ff;
|
||||
stroke-linecap: round;
|
||||
stroke-width: 6.99px;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
opacity: 0.63;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path id="outer_eye" class="cls-1" d="M567.5,623c-163.183,0-303.026-99.084-361.823-239.922C257.508,238.76,401.037,135,570,135c166.451,0,308.222,100.7,361.954,241.667C874.771,520.905,733.152,623,567.5,623ZM567,156c124.816,0,226,101.184,226,226S691.816,608,567,608,341,506.816,341,382,442.184,156,567,156ZM816,256.213A272.836,272.836,0,0,1,846,381M793.574,488.677a249.545,249.545,0,0,0,3.239-208.3A303.384,303.384,0,0,0,816,256.213a274.468,274.468,0,0,1-1.516,252.475q-0.927,1.758-1.88,3.5-1.632-2.238-3.306-4.446Q801.826,497.884,793.574,488.677ZM345.5,490.85q-8.055,9.164-15.37,18.966a274.353,274.353,0,0,1-3.748-250.343q7.347,10.012,15.469,19.393A249.512,249.512,0,0,0,345.5,490.85Z"/>
|
||||
<path id="inner_eye" class="cls-2" d="M568,179.985c107.678,0,194.968,88.655,194.968,198.015S675.678,576.015,568,576.015,373.032,487.36,373.032,378,460.322,179.985,568,179.985Z"/>
|
||||
<path id="globe" class="cls-3" d="M569.5,499.831A120.331,120.331,0,1,1,689.831,379.5,120.33,120.33,0,0,1,569.5,499.831Zm65.1-50.585c1.225-.4,3.268-2.858,4.492-3.677s3.268-4.083,3.675-5.717,1.227-4.495,1.634-6.126a36.015,36.015,0,0,0,.818-6.135,14.639,14.639,0,0,0-.407-2.855l-3.266,6.127s-4.9,2.04-5.315,3.674-0.817,4.088-.817,4.088a61.248,61.248,0,0,0-2.45,6.133C632.554,446.388,633.372,449.66,634.6,449.246ZM461.011,377.072c0.72-1.105,1.928-6.276,1.6-7.894-0.411-2.037,1.225-6.94,1.225-9.392s4.083-7.358,4.9-9.4,4.491-7.349,6.535-10.623-0.4-2.038-2.449-.809,1.225-3.274,2.449-4.086,8.172-5.72,8.172-5.72,2.449-2.454-.409-7.762c-2.211-4.1,0-4.5,0-6.128a2.43,2.43,0,0,0-1.28-.223c2.129-2.952,4.408-5.788,6.811-8.512A108.871,108.871,0,0,0,494.612,458.6a0.454,0.454,0,0,1,.683-0.363c2.043,0.819,2.449-.4,4.085-0.4s4.086-2.043,4.086-2.043l-1.641-4.9,1.641-6.126,2.041-5.717s0-4.095-2.041-4.5-5.722-4.081-6.946-4.081a10.018,10.018,0,0,1-4.491-1.636c-1.227-.813-3.677-1.634-4.5-2.857a31.144,31.144,0,0,1-2.858-5.314c-0.816-2.039-.409-2.039-3.681-5.715s-2.452-2.863-4.9-4.093-3.265-2.443-5.308-3.263a13.378,13.378,0,0,1-4.5-2.857,4.884,4.884,0,0,0-2.711-1.519,110.041,110.041,0,0,1-2.595-23.808C460.98,378.618,461,377.84,461.011,377.072Zm35.743-78.7a13.8,13.8,0,0,0-1.051,2.589c-0.408,1.638,2.864.416,2.864,0.416L502.239,303l4.5-1.631a13.741,13.741,0,0,1,4.083-4.5c2.857-2.043,2.454-2.452,3.677-4.084s0-.006,5.314-5.732,8.167-5.718,8.167-5.718,1.915-2.1,3.244-3.895A108.986,108.986,0,0,0,496.754,298.368Zm41.024,56.1c-1.224.82-5.715,3.272-6.944,5.315a22.681,22.681,0,0,0-2.859,6.535,31.042,31.042,0,0,0-.408,6.538c0,2.854-.41,4.5,0,6.949s4.5,8.568,6.128,10.623c1.63,2.038,3.674,4.483,5.719,5.713a47.235,47.235,0,0,1,4.9,3.674,4.03,4.03,0,0,0,4.9,1.223c3.272-1.223,6.538-1.223,9.4-2.853s7.353-3.685,7.353-1.231,1.632,4.084,4.082,3.272,6.539-2.858,6.13,0S577,406.764,577,408.4s-0.4,3.265.818,4.495,4.5,3.265,4.5,4.49,0.409,5.311,1.229,6.946,1.639,4.08.823,5.724-2.434,6.123-2.434,6.123-0.779,3.676-.37,4.9a12.683,12.683,0,0,0,3.332,4.906c2.043,2.042,1.771,4.492,2.994,6.124s2.993,6.539,2.993,6.539l1.236,4.088v3.677s7.966-1.231,9.591-1.64a30.673,30.673,0,0,0,8.618-4.9c1.636-1.632,1.311-2.856,3.349-4.488s5.762-4.5,5.762-4.5l-0.387-6.126,4.914-6.544,3.684-3.677s3.267-4.484,2.865-6.128c-0.413-1.628-1.231-9.8-1.231-9.8a37.972,37.972,0,0,1,2.863-5.313c1.224-1.634,1.227-3.678,3.679-5.719a33.109,33.109,0,0,0,5.306-6.126c1.229-1.634,3.681-4.9,4.5-6.127a21.871,21.871,0,0,0,1.635-4.894l-7.355,1.216c-2.45-4.082-9.392-13.073-10.621-15.112a37.04,37.04,0,0,1-2.861-7.76c-0.4-1.634-2.445-6.131-2.856-7.356s-3.27-4.908-4.088-6.128-3.675-2.043-5.309-3.268-11.843-3.27-11.843-3.27-3.129-1.991-4.088-1.226c-4.083,3.269-13.48,1.226-15.523.415s-3.269-2.041-3.269-3.272-1.835-7.143-1.835-7.143-3.985.705-5.213,0.705a20.333,20.333,0,0,1-4.442-1.276s-4.055.379-5.693,1.2a61.205,61.205,0,0,1-6.115,2.438,36.488,36.488,0,0,1-4.488.814c-3.671-1.636-6.529,2.45-7.352,4.079s-2.445,2.454-2.445,4.084S539,353.659,537.778,354.471Zm18.191-54.739-3.128-4.5-3.727,2.045V304.5l-3.839-1.5-1.715,3.674,1.685,2.452L549.114,305v4.129l8.478-1.636,3.164-2.449Zm71.15-12.676c-1.669.869-4.808,3.094-6,1.894-1.227-1.218-4.086-3.423-4.086-3.423,2.448-1.636-4.9-5.212-4.9-5.212l-8.576-1.022h-6.125c-1.227,0-3.681.825-5.723,0.825s-6.943,3.583-8.577,3.994-5.314,2.5-6.944,2.5-4.088,3.086-4.088,3.086-2.856,5.218-.4,4.4,5.717-.858,6.122.367,1.227,3.242,1.636,4.471,6.131,1.618,6.131,1.618a18.551,18.551,0,0,1,2.038-3.683c1.227-1.634,2.452-3.675,2.452-3.675s2.044,4.082,2.452,5.3-0.818,4.908-.818,4.908-4.491,2.852-5.716,2.852-8.175-6.126-8.175-6.126a25.4,25.4,0,0,0-4.489,2.043c-2.856,2.861-5.717,5.31-5.717,5.31a17.057,17.057,0,0,0-4.081,2.046c-1.629,1.222-5.138,1.487-6.117,2.854-2.039,2.863.84,4.9,0.84,4.9l-2.408,3.271-1.548,1.225-5.553,1.221-2.751.616c0.4,1.227,1.258,5.105,1.258,5.105s-1.87,6.943,0,6.943c0,0,4.276,1.64,5.5,1.64s2.625,0.4,4.265-1.227a59.861,59.861,0,0,0,5.156-6.538,8.047,8.047,0,0,1,4.49-3.267c2.454-.82,3-2.047,4.231-2.452s6.47-1.229,6.47-1.229,3.709,2.043,4.932,2.861c0.573,0.386-1.393,1.658,6.086,2.907V331.6l3.7-.411-4.207-3.264-0.44-.1-4.778-4.8-0.419-3.672s4.861,2.862,6.493,3.672a43.671,43.671,0,0,1,4.81,3.272l1.783,5.719,2.4,4.5s-1.87-5.722,0-5.722c0,0,5.158.405,6.381,0a36.253,36.253,0,0,0,5.237-1.632c1.636-.815-0.648-3.68.986-5.721a10.385,10.385,0,0,1,4.981-3.27c1.23-.405,5.35-2.447,5.35-2.447l4.522,0.82c-2.041,1.627-4.486,4.077-2.85,3.676s5.313,1.63,6.949,2.855,0.413,4.087.413,4.087-4.5-.411-7.355-0.815a29.263,29.263,0,0,0-7.351.4l-3.677,2.043s-2.454.816-2.045,2.861,2.045,4.5,3.679,6.535,2.858,4.5,4.085,3.675,5.309-3.265,5.309-3.265a12.784,12.784,0,0,0-.409,4.9,30.6,30.6,0,0,1,.409,4.9s-4.082,0-2.861.822,2.967,3.905,3.681,4.9c4.083,5.717,12.664,18.383,13.482,20.015s0.44,4.126,1.631,5.31c2.454,2.45,7.352,0,8.578-.817s5.311-2.863,6.536-4.082,0.818-.822,3.272-2.454,4.079-2.856,2.859-4.085-3.68-5.725-3.68-5.725c-3.676.825-7.351-1.222-7.351-2.445s-3.269-6.124-3.269-6.124,0.407-.82,3.269.809,5.306,4.908Line truncated
|
||||
<path id="globe_cage" class="cls-4" d="M569.5,576.491c-106.968,0-193.988-89.044-193.988-198.491S462.532,179.509,569.5,179.509,763.488,268.553,763.488,378,676.468,576.491,569.5,576.491ZM578.4,368.9h76.237a314.2,314.2,0,0,0-11.945-78.026H578.4V368.9ZM654.634,387.1H578.4V464.94h64.345A314.251,314.251,0,0,0,654.634,387.1Zm90.813,0H671.861a333.4,333.4,0,0,1-11.206,77.836h63.112A182.117,182.117,0,0,0,745.447,387.1Zm-32.528,95.444H655.363a288.421,288.421,0,0,1-32.3,67.191A177.214,177.214,0,0,0,712.919,482.548Zm-115.67,73.48a263.416,263.416,0,0,0,39.828-73.48H578.4v75.476A173.846,173.846,0,0,0,597.249,556.028ZM560.6,558.036V482.548H497.674a263.837,263.837,0,0,0,39.135,72.575A174.279,174.279,0,0,0,560.6,558.036ZM510.434,547.83a289.038,289.038,0,0,1-31.078-65.282H426.1A177.5,177.5,0,0,0,510.434,547.83Zm-95.208-82.89H474.1A333.353,333.353,0,0,1,462.878,387.1H393.555A182.127,182.127,0,0,0,415.226,464.94ZM393.555,368.9h69.323a333.4,333.4,0,0,1,11.268-78.026H415.332A182.13,182.13,0,0,0,393.555,368.9Zm32.676-95.633h53.185a289.064,289.064,0,0,1,31-65.087A177.487,177.487,0,0,0,426.231,273.263Zm134.372-75.3a174.068,174.068,0,0,0-23.8,2.914,263.8,263.8,0,0,0-39.06,72.384H560.6v-75.3Zm0,92.905H492.051A314.088,314.088,0,0,0,480.106,368.9h80.5V290.87Zm-80.5,96.234A314.1,314.1,0,0,0,492,464.94h68.6V387.1h-80.5ZM578.4,197.977v75.286h58.612a263.412,263.412,0,0,0-39.751-73.289A174.011,174.011,0,0,0,578.4,197.977Zm44.672,8.286a288.6,288.6,0,0,1,32.231,67h57.491A177.213,177.213,0,0,0,623.069,206.263Zm100.6,84.607H660.6A333.3,333.3,0,0,1,671.861,368.9h73.586A182.129,182.129,0,0,0,723.667,290.87Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.2 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
|
||||
<title id="title">Microphone</title>
|
||||
<g fill="none" stroke="#36dcff" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="23" y="10" width="18" height="30" rx="9" stroke-width="3"/>
|
||||
<path d="M16 31v2c0 9 7 16 16 16s16-7 16-16v-2" stroke-width="3"/>
|
||||
<path d="M32 49v8M24 57h16" stroke-width="3"/>
|
||||
<path d="M28 17h8M28 23h8M28 29h8" stroke-width="2" opacity=".65"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 498 B |
@@ -0,0 +1,21 @@
|
||||
# Bundled 3D Model Attribution
|
||||
|
||||
The model files in this directory are third-party visual assets. They are not
|
||||
covered by the repository's MIT source-code license; each remains available
|
||||
under the license listed below.
|
||||
|
||||
| File | Original work and creator | Source | License | Project modifications |
|
||||
|---|---|---|---|---|
|
||||
| `airplane.glb` | “boeing 747” by [zairiq-123](https://sketchfab.com/zairiq-123) | [Sketchfab model](https://sketchfab.com/3d-models/boeing-747-9b16672038ba48f98e6d80a159044ed9) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Substantially modified and optimized for God's Eye View, including geometry/material simplification and coordinate/orientation preparation. The former 24× runtime calibration is baked into the mesh; location, rotation, and scale are applied; the bounding-box centre is at the origin; and the model uses glTF +Y-up with its nose toward local −X. Geometry remains uncompressed so the aircraft asset does not compete with photogrammetry for Draco worker capacity. |
|
||||
| `jet.glb` | “Private Jet” by [Nick the Name](https://sketchfab.com/Nick_The_Name) | [Sketchfab model](https://sketchfab.com/3d-models/private-jet-cbdd1de6ced9461e950eafaa302cc82b) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Repackaged as a glTF binary for the project's military-flight visualization. Imported hierarchy transforms are baked into the meshes; location, rotation, and scale are applied; the bounding-box centre is at the origin; and the model uses meter scale, glTF +Y-up, and the shared nose −X convention. Existing materials are preserved. |
|
||||
| `ship.glb` | “Low Poly Cargo Ship” by [Javier_Fernandez](https://sketchfab.com/Javier.Fernandez) | [Sketchfab model](https://sketchfab.com/3d-models/low-poly-cargo-ship-4c22cbaf01c1427f8ab60b3a07b1b32c) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized and repackaged as a glTF binary for the project's vessel visualization. |
|
||||
| `bell206.glb` | “Bell 206 JetRanger” by [terran4627](https://sketchfab.com/terran4627) | [Sketchfab model](https://sketchfab.com/3d-models/bell-206-jetranger-d2f7ba1d671549d4b26aaf834139a1dd) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. |
|
||||
| `c172.glb` | “Cessna 172” by [e737](https://sketchfab.com/e0057537) | [Sketchfab model](https://sketchfab.com/3d-models/cessna-172-64cddaee5aff470682659a8c08525046) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. |
|
||||
| `citation2.glb` | “1990 Cessna Citation, Texture Detailed, Exterior” by [BlenderCommunityHead](https://sketchfab.com/aboodgoudagad) | [Sketchfab model](https://sketchfab.com/3d-models/1990-cessna-citation-texture-detailed-exterior-a78839624fe64900a8352cb23462350a) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. |
|
||||
| `mq9.glb` | “MQ-9” by [IProZenoN](https://sketchfab.com/IProZenoN) | [Sketchfab model](https://sketchfab.com/3d-models/mq-9-fabe963feb354c5584b51f9c470c3f7e) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. |
|
||||
| `b789.glb` | “Boeing 787-9” by [Nobilis 2](https://sketchfab.com/nobilishornet2) | [Sketchfab model](https://sketchfab.com/3d-models/boeing-787-9-b6711e2e698e4e469675c1154a50b7a3) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. |
|
||||
| `atr72.glb` | “ATR 72 - 600” by [Oyan3D](https://sketchfab.com/oyan3D) | [Sketchfab model](https://sketchfab.com/3d-models/atr-72-600-1e1a7186f7444d288675262fcee44744) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures removed with dominant material colors baked into PBR factors (flat “abstracted” style), orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. |
|
||||
|
||||
CC BY 4.0 permits sharing and adaptation, including commercial use, provided
|
||||
appropriate credit is retained, the license is linked, and modifications are
|
||||
identified. These credits do not imply endorsement by the original creators.
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#36dcff" role="img" aria-labelledby="title">
|
||||
<title id="title">Pin panel</title>
|
||||
<line x1="12" y1="17" x2="12" y2="22"/>
|
||||
<path d="M5 17h14v-1.76a2 2 0 0 0-.44-1.24l-2.12-2.65A2 2 0 0 1 16 10.11V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v5.11a2 2 0 0 1-.44 1.24L5.44 14a2 2 0 0 0-.44 1.24Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 489 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#36dcff" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<path d="M3 16.5L12 10L21 16.5"/>
|
||||
<path d="M3 11L12 4.5L21 11"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 351 B |
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build src/data/local_data/neighborhoods/san-francisco.json from the official
|
||||
* public-domain DataSF "Analysis Neighborhoods" dataset.
|
||||
*
|
||||
* Source (map view): https://data.sfgov.org/Geographic-Locations-and-Boundaries/Analysis-Neighborhoods-Map/p5b7-5n3h
|
||||
* Underlying dataset: https://data.sfgov.org/d/j2bu-swwd (columns: the_geom, nhood)
|
||||
* Fetched from: https://data.sfgov.org/resource/j2bu-swwd.geojson?$limit=100
|
||||
* License: Open Data Commons Public Domain Dedication and License (PDDL 1.0)
|
||||
* (dataset metadata `licenseId: "PDDL"` at
|
||||
* https://data.sfgov.org/api/views/j2bu-swwd.json)
|
||||
*
|
||||
* Transform (deterministic):
|
||||
* 1. `properties.nhood` → `properties.name` (no renames — DataSF names kept verbatim).
|
||||
* 2. Douglas-Peucker simplification per ring, tolerance 2e-5° (~2 m) — shaves
|
||||
* hyper-detailed shoreline vertices without visibly moving boundaries.
|
||||
* 3. Coordinates rounded to 6 decimals (~0.1 m); consecutive duplicates dropped;
|
||||
* rings re-closed; rings that collapse below 4 points dropped.
|
||||
* 4. Single-part MultiPolygons collapsed to Polygon.
|
||||
* 5. Features sorted by name for a stable diff.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/build-sf-neighborhoods.mjs [raw.geojson]
|
||||
* With no argument it downloads the live dataset; with an argument it reads the
|
||||
* given raw GeoJSON file (the exact bytes retrieved on 2026-07-30 in our case).
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SOURCE_URL = 'https://data.sfgov.org/resource/j2bu-swwd.geojson?$limit=100';
|
||||
const OUT = path.join(path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..', 'src', 'data', 'local_data', 'neighborhoods', 'san-francisco.json');
|
||||
const TOLERANCE = 2e-5; // degrees, ~2 m
|
||||
const DECIMALS = 6;
|
||||
|
||||
/** Perpendicular distance from point p to segment a-b (in degrees, planar — fine at this scale). */
|
||||
function segDist(p, a, b) {
|
||||
let [x, y] = p; const [x1, y1] = a; const [x2, y2] = b;
|
||||
let dx = x2 - x1; let dy = y2 - y1;
|
||||
if (dx !== 0 || dy !== 0) {
|
||||
const t = ((x - x1) * dx + (y - y1) * dy) / (dx * dx + dy * dy);
|
||||
if (t > 1) { x -= x2; y -= y2; return Math.hypot(x, y); }
|
||||
if (t > 0) { x -= x1 + dx * t; y -= y1 + dy * t; return Math.hypot(x, y); }
|
||||
}
|
||||
return Math.hypot(x - x1, y - y1);
|
||||
}
|
||||
|
||||
/** Iterative Douglas-Peucker on an open point list. */
|
||||
function douglasPeucker(points, tolerance) {
|
||||
if (points.length <= 2) return points.slice();
|
||||
const keep = new Uint8Array(points.length);
|
||||
keep[0] = keep[points.length - 1] = 1;
|
||||
const stack = [[0, points.length - 1]];
|
||||
while (stack.length) {
|
||||
const [first, last] = stack.pop();
|
||||
let maxDist = 0; let index = -1;
|
||||
for (let i = first + 1; i < last; i++) {
|
||||
const d = segDist(points[i], points[first], points[last]);
|
||||
if (d > maxDist) { maxDist = d; index = i; }
|
||||
}
|
||||
if (maxDist > tolerance && index !== -1) {
|
||||
keep[index] = 1;
|
||||
stack.push([first, index], [index, last]);
|
||||
}
|
||||
}
|
||||
return points.filter((_, i) => keep[i]);
|
||||
}
|
||||
|
||||
const round = (v) => Number(v.toFixed(DECIMALS));
|
||||
|
||||
/** Simplify + round one ring; returns null if it degenerates. */
|
||||
function processRing(ring) {
|
||||
// GeoJSON rings are closed (first == last); simplify the open part.
|
||||
const open = ring.length > 1
|
||||
&& ring[0][0] === ring[ring.length - 1][0] && ring[0][1] === ring[ring.length - 1][1]
|
||||
? ring.slice(0, -1) : ring.slice();
|
||||
const simplified = douglasPeucker(open, TOLERANCE).map(([lon, lat]) => [round(lon), round(lat)]);
|
||||
const out = [];
|
||||
for (const pt of simplified) {
|
||||
const prev = out[out.length - 1];
|
||||
if (prev && prev[0] === pt[0] && prev[1] === pt[1]) continue;
|
||||
out.push(pt);
|
||||
}
|
||||
if (out.length < 3) return null;
|
||||
out.push([out[0][0], out[0][1]]); // re-close
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let rawText;
|
||||
if (process.argv[2]) {
|
||||
rawText = fs.readFileSync(process.argv[2], 'utf8');
|
||||
console.log(`read ${process.argv[2]}`);
|
||||
} else {
|
||||
console.log(`fetching ${SOURCE_URL}`);
|
||||
const res = await fetch(SOURCE_URL);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} from DataSF`);
|
||||
rawText = await res.text();
|
||||
}
|
||||
const raw = JSON.parse(rawText);
|
||||
if (raw.type !== 'FeatureCollection' || !Array.isArray(raw.features)) {
|
||||
throw new Error('unexpected payload: not a FeatureCollection');
|
||||
}
|
||||
|
||||
let inVerts = 0; let outVerts = 0;
|
||||
const features = [];
|
||||
for (const f of raw.features) {
|
||||
const name = f.properties && f.properties.nhood;
|
||||
if (!name || !f.geometry) throw new Error(`feature missing nhood/geometry: ${JSON.stringify(f.properties)}`);
|
||||
const polysIn = f.geometry.type === 'MultiPolygon' ? f.geometry.coordinates
|
||||
: f.geometry.type === 'Polygon' ? [f.geometry.coordinates]
|
||||
: null;
|
||||
if (!polysIn) throw new Error(`unexpected geometry type ${f.geometry.type} for ${name}`);
|
||||
const polys = [];
|
||||
for (const poly of polysIn) {
|
||||
const rings = [];
|
||||
for (const ring of poly) {
|
||||
inVerts += ring.length;
|
||||
const r = processRing(ring);
|
||||
if (r) { rings.push(r); outVerts += r.length; }
|
||||
}
|
||||
if (rings.length && poly[0] && rings[0]) polys.push(rings);
|
||||
}
|
||||
if (!polys.length) throw new Error(`geometry collapsed for ${name}`);
|
||||
const geometry = polys.length === 1
|
||||
? { type: 'Polygon', coordinates: polys[0] }
|
||||
: { type: 'MultiPolygon', coordinates: polys };
|
||||
features.push({ type: 'Feature', properties: { name }, geometry });
|
||||
}
|
||||
features.sort((a, b) => a.properties.name.localeCompare(b.properties.name));
|
||||
|
||||
const out = {
|
||||
type: 'FeatureCollection',
|
||||
city: 'San Francisco',
|
||||
source: 'DataSF "Analysis Neighborhoods" (dataset j2bu-swwd, map view p5b7-5n3h) — PDDL 1.0 (public domain); see SOURCE.md',
|
||||
features,
|
||||
};
|
||||
fs.writeFileSync(OUT, `${JSON.stringify(out)}\n`);
|
||||
const kb = (fs.statSync(OUT).size / 1024).toFixed(0);
|
||||
console.log(`wrote ${OUT}: ${features.length} features, ${inVerts} → ${outVerts} vertices, ${kb} KB`);
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error(err); process.exit(1); });
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
PORT="${PORT:-4173}"
|
||||
HOST="${HOST:-0.0.0.0}"
|
||||
CCTV_SOURCES_FILE="${CCTV_SOURCES_FILE:-config/cctv_sources.austin.json}"
|
||||
CCTV_PREFER_AUSTIN="${CCTV_PREFER_AUSTIN:-1}"
|
||||
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-36}"
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-48}"
|
||||
|
||||
if ! command -v npm >/dev/null 2>&1; then
|
||||
echo "error: npm not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GOOGLE_MAPS_API_KEY_ENV="${GOOGLE_MAPS_API_KEY:-}"
|
||||
GOOGLE_MAPS_API_KEY_KEYCHAIN=""
|
||||
GOOGLE_MAPS_API_KEY_SOURCE=""
|
||||
if command -v security >/dev/null 2>&1; then
|
||||
for acct in "api-key" "default" "key"; do
|
||||
GOOGLE_MAPS_API_KEY_KEYCHAIN="$(security find-generic-password -s "google-maps-api" -a "${acct}" -w 2>/dev/null || true)"
|
||||
if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY_SOURCE="keychain:${acct}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_KEYCHAIN}"
|
||||
elif [[ -n "${GOOGLE_MAPS_API_KEY_ENV}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_ENV}"
|
||||
GOOGLE_MAPS_API_KEY_SOURCE="env"
|
||||
else
|
||||
GOOGLE_MAPS_API_KEY=""
|
||||
fi
|
||||
if [[ -z "${GOOGLE_MAPS_API_KEY}" ]]; then
|
||||
echo "error: Google Maps API key missing."
|
||||
echo "set GOOGLE_MAPS_API_KEY in env, or add Keychain item: service=google-maps-api account=api-key"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read_keychain_secret() {
|
||||
local service="$1"
|
||||
local account="$2"
|
||||
security find-generic-password -s "$service" -a "$account" -w 2>/dev/null || true
|
||||
}
|
||||
|
||||
load_opensky_oauth_from_file() {
|
||||
local file_path="$1"
|
||||
if [[ -z "${file_path}" || ! -f "${file_path}" ]]; then
|
||||
return
|
||||
fi
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "warning: node not found; cannot parse OPENSKY_CREDENTIALS_FILE"
|
||||
return
|
||||
fi
|
||||
|
||||
local parsed
|
||||
parsed="$(node -e "const fs=require('fs');const p=process.argv[1];try{const raw=JSON.parse(fs.readFileSync(p,'utf8'));const id=String(raw.clientId??raw.client_id??'').trim();const secret=String(raw.clientSecret??raw.client_secret??'').trim();process.stdout.write(id+'\\t'+secret);}catch{process.exit(1)}" "${file_path}" 2>/dev/null || true)"
|
||||
if [[ "${parsed}" != *$'\t'* ]]; then
|
||||
return
|
||||
fi
|
||||
local parsed_client_id="${parsed%%$'\t'*}"
|
||||
local parsed_client_secret="${parsed#*$'\t'}"
|
||||
|
||||
if [[ -z "${OPENSKY_CLIENT_ID}" && -n "${parsed_client_id}" ]]; then
|
||||
OPENSKY_CLIENT_ID="${parsed_client_id}"
|
||||
fi
|
||||
if [[ -z "${OPENSKY_CLIENT_SECRET}" && -n "${parsed_client_secret}" ]]; then
|
||||
OPENSKY_CLIENT_SECRET="${parsed_client_secret}"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_opensky_credentials() {
|
||||
OPENSKY_AUTH_MODE="$(printf '%s' "${OPENSKY_AUTH_MODE:-oauth}" | tr '[:upper:]' '[:lower:]')"
|
||||
OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE:-}"
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic|oauth|auto|anon) ;;
|
||||
*)
|
||||
echo "warning: invalid OPENSKY_AUTH_MODE='${OPENSKY_AUTH_MODE}', defaulting to 'oauth'"
|
||||
OPENSKY_AUTH_MODE="oauth"
|
||||
;;
|
||||
esac
|
||||
|
||||
OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID:-}"
|
||||
OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET:-}"
|
||||
OPENSKY_USERNAME="${OPENSKY_USERNAME:-}"
|
||||
OPENSKY_PASSWORD="${OPENSKY_PASSWORD:-}"
|
||||
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_USERNAME}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "username" "user" "login" "email" "default"; do
|
||||
OPENSKY_USERNAME="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_USERNAME}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_PASSWORD}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "password" "pass" "token" "secret" "default"; do
|
||||
OPENSKY_PASSWORD="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
load_opensky_oauth_from_file "${OPENSKY_CREDENTIALS_FILE}"
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_ID}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "client_id" "client-id" "client" "api-key"; do
|
||||
OPENSKY_CLIENT_ID="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "client_secret" "client-secret" "secret"; do
|
||||
OPENSKY_CLIENT_SECRET="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic)
|
||||
OPENSKY_CLIENT_ID=""
|
||||
OPENSKY_CLIENT_SECRET=""
|
||||
;;
|
||||
oauth)
|
||||
OPENSKY_USERNAME=""
|
||||
OPENSKY_PASSWORD=""
|
||||
;;
|
||||
anon)
|
||||
OPENSKY_CLIENT_ID=""
|
||||
OPENSKY_CLIENT_SECRET=""
|
||||
OPENSKY_USERNAME=""
|
||||
OPENSKY_PASSWORD=""
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
resolve_opensky_credentials
|
||||
|
||||
echo "Starting God's Eye View with CCTV source pack..."
|
||||
echo "URL: http://localhost:${PORT}/"
|
||||
echo "Google Maps key source: ${GOOGLE_MAPS_API_KEY_SOURCE}"
|
||||
if [[ -f "${CCTV_SOURCES_FILE}" ]]; then
|
||||
echo "CCTV_SOURCES_FILE: ${CCTV_SOURCES_FILE}"
|
||||
else
|
||||
echo "CCTV_SOURCES_FILE: ${CCTV_SOURCES_FILE} (missing, using Austin open-data feed discovery)"
|
||||
fi
|
||||
echo "OpenSky auth mode: ${OPENSKY_AUTH_MODE}"
|
||||
if [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
if [[ -f "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE}"
|
||||
else
|
||||
echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE} (missing)"
|
||||
fi
|
||||
fi
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic)
|
||||
if [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
echo "OpenSky basic auth: configured"
|
||||
else
|
||||
echo "OpenSky basic auth: missing credentials"
|
||||
fi
|
||||
;;
|
||||
oauth)
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
echo "OpenSky OAuth: configured"
|
||||
else
|
||||
echo "OpenSky OAuth: missing client credentials"
|
||||
fi
|
||||
;;
|
||||
auto)
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
echo "OpenSky auto auth: OAuth configured"
|
||||
elif [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
echo "OpenSky auto auth: basic configured"
|
||||
else
|
||||
echo "OpenSky auto auth: no credentials found"
|
||||
fi
|
||||
;;
|
||||
anon)
|
||||
echo "OpenSky auth: disabled (anonymous mode)"
|
||||
;;
|
||||
esac
|
||||
|
||||
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY}" \
|
||||
CCTV_SOURCES_FILE="${CCTV_SOURCES_FILE}" \
|
||||
CCTV_PREFER_AUSTIN="${CCTV_PREFER_AUSTIN}" \
|
||||
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES}" \
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES}" \
|
||||
OPENSKY_AUTH_MODE="${OPENSKY_AUTH_MODE}" \
|
||||
OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE}" \
|
||||
OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID}" \
|
||||
OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET}" \
|
||||
OPENSKY_USERNAME="${OPENSKY_USERNAME}" \
|
||||
OPENSKY_PASSWORD="${OPENSKY_PASSWORD}" \
|
||||
npm run dev -- --host "${HOST}" --port "${PORT}" --force
|
||||
@@ -0,0 +1,362 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
PORT="${PORT:-4173}"
|
||||
# Local-only by default: the dev server brokers configured API keys, so it
|
||||
# should not be reachable from the network unless explicitly requested.
|
||||
# Set HOST=0.0.0.0 to opt in to LAN exposure (a warning is printed).
|
||||
HOST="${HOST:-localhost}"
|
||||
# CCTV source packs (all keyless): Austin (~815 live upstream), Caltrans
|
||||
# districts 4,7,11,3 = SF/LA/San Diego/Sacramento (~1,860 live upstream),
|
||||
# TfL London JamCams (~870 live upstream). Caps keep the densest cores per
|
||||
# pack; override per-run for lighter/heavier loads. Kill switches:
|
||||
# CCTV_CALTRANS_DISTRICTS='' and CCTV_TFL_ENABLED=0.
|
||||
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-250}"
|
||||
# Use `-` not `:-` so an explicit empty string (the documented kill switch)
|
||||
# is preserved rather than replaced by the default. Still set-u-safe when unset.
|
||||
CCTV_CALTRANS_DISTRICTS="${CCTV_CALTRANS_DISTRICTS-4,7,11,3}"
|
||||
CCTV_CALTRANS_MAX_SOURCES="${CCTV_CALTRANS_MAX_SOURCES:-300}"
|
||||
CCTV_TFL_ENABLED="${CCTV_TFL_ENABLED:-1}"
|
||||
CCTV_TFL_MAX_SOURCES="${CCTV_TFL_MAX_SOURCES:-250}"
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-900}"
|
||||
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
DEV_COMMAND=(npm run dev --)
|
||||
elif command -v pnpm >/dev/null 2>&1; then
|
||||
DEV_COMMAND=(pnpm run dev)
|
||||
else
|
||||
echo "error: neither npm nor pnpm found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read_dotenv_value() {
|
||||
local variable_name="$1"
|
||||
if [[ ! -f ".env" ]]; then
|
||||
return
|
||||
fi
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "warning: node not found; cannot parse .env" >&2
|
||||
return
|
||||
fi
|
||||
node scripts/read-dotenv-value.mjs "${variable_name}"
|
||||
}
|
||||
|
||||
# Vite loads .env for browser build-time configuration, but this launcher needs
|
||||
# the Maps key before Vite starts. Preserve a shell-provided value; otherwise
|
||||
# read the project-local .env without executing it as shell code.
|
||||
GOOGLE_MAPS_API_KEY_ENV="${GOOGLE_MAPS_API_KEY:-}"
|
||||
GOOGLE_MAPS_API_KEY_ENV_SOURCE="env"
|
||||
if [[ -z "${GOOGLE_MAPS_API_KEY_ENV}" && -f ".env" ]]; then
|
||||
GOOGLE_MAPS_API_KEY_ENV="$(read_dotenv_value "GOOGLE_MAPS_API_KEY")"
|
||||
GOOGLE_MAPS_API_KEY_ENV_SOURCE=".env"
|
||||
fi
|
||||
GOOGLE_MAPS_API_KEY_KEYCHAIN=""
|
||||
GOOGLE_MAPS_API_KEY_SOURCE=""
|
||||
if command -v security >/dev/null 2>&1; then
|
||||
for acct in "api-key" "default" "key"; do
|
||||
GOOGLE_MAPS_API_KEY_KEYCHAIN="$(security find-generic-password -s "google-maps-api" -a "${acct}" -w 2>/dev/null || true)"
|
||||
if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY_SOURCE="keychain:${acct}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_KEYCHAIN}"
|
||||
elif [[ -n "${GOOGLE_MAPS_API_KEY_ENV}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_ENV}"
|
||||
GOOGLE_MAPS_API_KEY_SOURCE="${GOOGLE_MAPS_API_KEY_ENV_SOURCE}"
|
||||
else
|
||||
GOOGLE_MAPS_API_KEY=""
|
||||
fi
|
||||
if [[ -z "${GOOGLE_MAPS_API_KEY}" ]]; then
|
||||
echo "error: Google Maps API key missing."
|
||||
echo "set GOOGLE_MAPS_API_KEY in env, or add Keychain item: service=google-maps-api account=api-key"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read_keychain_secret() {
|
||||
local service="$1"
|
||||
local account="$2"
|
||||
security find-generic-password -s "$service" -a "$account" -w 2>/dev/null || true
|
||||
}
|
||||
|
||||
load_opensky_oauth_from_file() {
|
||||
local file_path="$1"
|
||||
if [[ -z "${file_path}" || ! -f "${file_path}" ]]; then
|
||||
return
|
||||
fi
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "warning: node not found; cannot parse OPENSKY_CREDENTIALS_FILE"
|
||||
return
|
||||
fi
|
||||
|
||||
local parsed
|
||||
parsed="$(node -e "const fs=require('fs');const p=process.argv[1];try{const raw=JSON.parse(fs.readFileSync(p,'utf8'));const id=String(raw.clientId??raw.client_id??'').trim();const secret=String(raw.clientSecret??raw.client_secret??'').trim();process.stdout.write(id+'\\t'+secret);}catch{process.exit(1)}" -- "${file_path}" 2>/dev/null || true)"
|
||||
if [[ "${parsed}" != *$'\t'* ]]; then
|
||||
return
|
||||
fi
|
||||
local parsed_client_id="${parsed%%$'\t'*}"
|
||||
local parsed_client_secret="${parsed#*$'\t'}"
|
||||
|
||||
if [[ -z "${OPENSKY_CLIENT_ID}" && -n "${parsed_client_id}" ]]; then
|
||||
OPENSKY_CLIENT_ID="${parsed_client_id}"
|
||||
fi
|
||||
if [[ -z "${OPENSKY_CLIENT_SECRET}" && -n "${parsed_client_secret}" ]]; then
|
||||
OPENSKY_CLIENT_SECRET="${parsed_client_secret}"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_opensky_credentials() {
|
||||
OPENSKY_AUTH_MODE="${OPENSKY_AUTH_MODE:-$(read_dotenv_value "OPENSKY_AUTH_MODE")}"
|
||||
OPENSKY_AUTH_MODE="$(printf '%s' "${OPENSKY_AUTH_MODE:-oauth}" | tr '[:upper:]' '[:lower:]')"
|
||||
OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE:-$(read_dotenv_value "OPENSKY_CREDENTIALS_FILE")}"
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic|oauth|auto|anon) ;;
|
||||
*)
|
||||
echo "warning: invalid OPENSKY_AUTH_MODE='${OPENSKY_AUTH_MODE}', defaulting to 'oauth'"
|
||||
OPENSKY_AUTH_MODE="oauth"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Explicit env wins, then .env, then the credentials file / Keychain below.
|
||||
OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID:-$(read_dotenv_value "OPENSKY_CLIENT_ID")}"
|
||||
OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET:-$(read_dotenv_value "OPENSKY_CLIENT_SECRET")}"
|
||||
OPENSKY_USERNAME="${OPENSKY_USERNAME:-$(read_dotenv_value "OPENSKY_USERNAME")}"
|
||||
OPENSKY_PASSWORD="${OPENSKY_PASSWORD:-$(read_dotenv_value "OPENSKY_PASSWORD")}"
|
||||
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_USERNAME}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "username" "user" "login" "email" "default"; do
|
||||
OPENSKY_USERNAME="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_USERNAME}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_PASSWORD}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "password" "pass" "token" "secret" "default"; do
|
||||
OPENSKY_PASSWORD="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
load_opensky_oauth_from_file "${OPENSKY_CREDENTIALS_FILE}"
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_ID}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "client_id" "client-id" "client" "api-key"; do
|
||||
OPENSKY_CLIENT_ID="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "client_secret" "client-secret" "secret"; do
|
||||
OPENSKY_CLIENT_SECRET="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic)
|
||||
OPENSKY_CLIENT_ID=""
|
||||
OPENSKY_CLIENT_SECRET=""
|
||||
;;
|
||||
oauth)
|
||||
OPENSKY_USERNAME=""
|
||||
OPENSKY_PASSWORD=""
|
||||
;;
|
||||
anon)
|
||||
OPENSKY_CLIENT_ID=""
|
||||
OPENSKY_CLIENT_SECRET=""
|
||||
OPENSKY_USERNAME=""
|
||||
OPENSKY_PASSWORD=""
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
resolve_opensky_credentials
|
||||
|
||||
# Optional keys: explicit env wins, followed by .env and Keychain fallback.
|
||||
# Add to Keychain with e.g.:
|
||||
# security add-generic-password -U -s "openai-api" -a "api-key" -w
|
||||
OPENAI_API_KEY="${OPENAI_API_KEY:-$(read_dotenv_value "OPENAI_API_KEY")}"
|
||||
AISSTREAM_API_KEY="${AISSTREAM_API_KEY:-$(read_dotenv_value "AISSTREAM_API_KEY")}"
|
||||
CESIUM_ION_TOKEN="${CESIUM_ION_TOKEN:-$(read_dotenv_value "CESIUM_ION_TOKEN")}"
|
||||
LL2_API_TOKEN="${LL2_API_TOKEN:-$(read_dotenv_value "LL2_API_TOKEN")}"
|
||||
TOMTOM_API_KEY="${TOMTOM_API_KEY:-$(read_dotenv_value "TOMTOM_API_KEY")}"
|
||||
FIRMS_MAP_KEY="${FIRMS_MAP_KEY:-$(read_dotenv_value "FIRMS_MAP_KEY")}"
|
||||
OPENAI_API_KEY="${OPENAI_API_KEY:-$(read_keychain_secret "openai-api" "api-key")}"
|
||||
AISSTREAM_API_KEY="${AISSTREAM_API_KEY:-$(read_keychain_secret "aisstream-api" "api-key")}"
|
||||
CESIUM_ION_TOKEN="${CESIUM_ION_TOKEN:-$(read_keychain_secret "cesium-ion" "token")}"
|
||||
TOMTOM_API_KEY="${TOMTOM_API_KEY:-$(read_keychain_secret "tomtom-api" "api-key")}"
|
||||
FIRMS_MAP_KEY="${FIRMS_MAP_KEY:-$(read_keychain_secret "firms-map" "map-key")}"
|
||||
|
||||
if [[ ! -f "src/data/cctv.js" ]]; then
|
||||
echo "error: expected CCTV layer file missing: src/data/cctv.js"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q "dataManager.register(cctvLayer)" src/main.js; then
|
||||
echo "error: CCTV layer not wired in src/main.js"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Stopping all existing God's Eye View dev servers..."
|
||||
pkill -f "${ROOT_DIR}/node_modules/.bin/vite" >/dev/null 2>&1 || true
|
||||
pkill -f "${ROOT_DIR}/node_modules/vite/bin/vite.js" >/dev/null 2>&1 || true
|
||||
|
||||
# Also clear the requested port in case it is held by a stale wrapper or a
|
||||
# server started through a different package-manager command.
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
PIDS="$(lsof -tiTCP:${PORT} -sTCP:LISTEN 2>/dev/null || true)"
|
||||
if [[ -n "${PIDS}" ]]; then
|
||||
echo "${PIDS}" | xargs kill -9 >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Clearing Vite cache..."
|
||||
rm -rf node_modules/.vite
|
||||
|
||||
echo "Starting fresh God's Eye View dev server..."
|
||||
case "${HOST}" in
|
||||
localhost|127.0.0.1|::1)
|
||||
echo "Local-only mode: reachable at http://localhost:${PORT}/ (set HOST=0.0.0.0 for LAN)"
|
||||
;;
|
||||
*)
|
||||
LAN_IP=""
|
||||
if command -v ipconfig >/dev/null 2>&1; then
|
||||
# macOS: first active interface wins
|
||||
for iface in en0 en1; do
|
||||
LAN_IP="$(ipconfig getifaddr "${iface}" 2>/dev/null || true)"
|
||||
[[ -n "${LAN_IP}" ]] && break
|
||||
done
|
||||
elif command -v hostname >/dev/null 2>&1; then
|
||||
# Linux: hostname -I lists addresses; take the first
|
||||
LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
|
||||
fi
|
||||
echo ""
|
||||
echo "!! =============================================================="
|
||||
echo "!! WARNING: HOST=${HOST} — network-exposed mode."
|
||||
echo "!! This dev server brokers your configured API keys (OpenAI,"
|
||||
echo "!! OpenSky, AISStream, TomTom, FIRMS, LL2, Google) to ANYONE who can"
|
||||
echo "!! reach it on the network. Use only on networks you trust."
|
||||
echo "!! Consider the opt-in per-IP throttles GEV_RATELIMIT_OPENAI_PER_MIN"
|
||||
echo "!! and GEV_RATELIMIT_GOOGLE_PER_MIN (see .env.example) — and note"
|
||||
echo "!! they are NOT billing caps; set provider-side budget alerts too."
|
||||
if [[ -n "${LAN_IP}" ]]; then
|
||||
echo "!! LAN URL: http://${LAN_IP}:${PORT}/"
|
||||
else
|
||||
echo "!! LAN URL: http://<this-machine-ip>:${PORT}/"
|
||||
fi
|
||||
echo "!! =============================================================="
|
||||
echo ""
|
||||
echo "URL (this machine): http://localhost:${PORT}/"
|
||||
;;
|
||||
esac
|
||||
echo "Google Maps key source: ${GOOGLE_MAPS_API_KEY_SOURCE}"
|
||||
echo "Tip: after server starts, hard refresh browser (Cmd+Shift+R)."
|
||||
echo "If panels are still missing, run this once in browser console:"
|
||||
echo "localStorage.removeItem('godsEyeView.v6.panelPos.cctv-panel'); location.reload();"
|
||||
echo "OpenSky auth mode: ${OPENSKY_AUTH_MODE}"
|
||||
if [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
if [[ -f "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE}"
|
||||
else
|
||||
echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE} (missing)"
|
||||
fi
|
||||
fi
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic)
|
||||
if [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
echo "OpenSky basic auth: configured"
|
||||
else
|
||||
echo "OpenSky basic auth: missing credentials"
|
||||
fi
|
||||
;;
|
||||
oauth)
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
echo "OpenSky OAuth: configured"
|
||||
else
|
||||
echo "OpenSky OAuth: missing client credentials"
|
||||
fi
|
||||
;;
|
||||
auto)
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
echo "OpenSky auto auth: OAuth configured"
|
||||
elif [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
echo "OpenSky auto auth: basic configured"
|
||||
else
|
||||
echo "OpenSky auto auth: no credentials found"
|
||||
fi
|
||||
;;
|
||||
anon)
|
||||
echo "OpenSky auth: disabled (anonymous mode)"
|
||||
;;
|
||||
esac
|
||||
[[ -n "${OPENAI_API_KEY}" ]] && echo "OpenAI key (voice + HUD summary): configured" || echo "OpenAI key (voice + HUD summary): not set — GEV MIC disabled"
|
||||
[[ -n "${AISSTREAM_API_KEY}" ]] && echo "AISStream key (live vessels): configured" || echo "AISStream key (live vessels): not set — ships layer empty"
|
||||
[[ -n "${CESIUM_ION_TOKEN}" ]] && echo "Cesium ion token (Bing map stacks): configured" || echo "Cesium ion token (Bing map stacks): not set — Google 3D/OSM only"
|
||||
[[ -n "${TOMTOM_API_KEY}" ]] && echo "TomTom key (live traffic flow): configured" || echo "TomTom key (live traffic flow): not set — simulated traffic"
|
||||
[[ -n "${FIRMS_MAP_KEY}" ]] && echo "NASA FIRMS key (live fires): configured" || echo "NASA FIRMS key (live fires): not set — fires layer requires a key"
|
||||
[[ -n "${LL2_API_TOKEN}" ]] && echo "Launch Library 2 token: configured" || echo "Launch Library 2 token: not set — using public access"
|
||||
|
||||
# Build the dev server environment explicitly. A value that resolved to
|
||||
# nothing is left UNSET instead of being exported empty: Vite backfills its
|
||||
# own .env values only for variables it finds undefined, so an empty export
|
||||
# would shadow a key the user did configure in .env.
|
||||
DEV_ENV=()
|
||||
DEV_UNSET=()
|
||||
put_env() {
|
||||
DEV_ENV+=("$1=$2")
|
||||
}
|
||||
put_env_if_set() {
|
||||
if [[ -n "$2" ]]; then
|
||||
DEV_ENV+=("$1=$2")
|
||||
else
|
||||
# Leaving it out is not enough: the child inherits this shell's
|
||||
# environment, so an empty export made in the PARENT would pass straight
|
||||
# through and shadow .env just the same. Remove it from the child outright.
|
||||
DEV_UNSET+=(-u "$1")
|
||||
fi
|
||||
}
|
||||
|
||||
put_env GOOGLE_MAPS_API_KEY "${GOOGLE_MAPS_API_KEY}"
|
||||
put_env CCTV_AUSTIN_MAX_SOURCES "${CCTV_AUSTIN_MAX_SOURCES}"
|
||||
# Empty is the documented Caltrans kill switch, so this one is passed as-is.
|
||||
put_env CCTV_CALTRANS_DISTRICTS "${CCTV_CALTRANS_DISTRICTS}"
|
||||
put_env CCTV_CALTRANS_MAX_SOURCES "${CCTV_CALTRANS_MAX_SOURCES}"
|
||||
put_env CCTV_TFL_ENABLED "${CCTV_TFL_ENABLED}"
|
||||
put_env CCTV_TFL_MAX_SOURCES "${CCTV_TFL_MAX_SOURCES}"
|
||||
put_env_if_set TFL_APP_KEY "${TFL_APP_KEY:-}"
|
||||
put_env CCTV_MAX_SOURCES "${CCTV_MAX_SOURCES}"
|
||||
put_env OPENSKY_AUTH_MODE "${OPENSKY_AUTH_MODE}"
|
||||
put_env_if_set OPENSKY_CREDENTIALS_FILE "${OPENSKY_CREDENTIALS_FILE}"
|
||||
put_env_if_set OPENSKY_CLIENT_ID "${OPENSKY_CLIENT_ID}"
|
||||
put_env_if_set OPENSKY_CLIENT_SECRET "${OPENSKY_CLIENT_SECRET}"
|
||||
put_env_if_set OPENSKY_USERNAME "${OPENSKY_USERNAME}"
|
||||
put_env_if_set OPENSKY_PASSWORD "${OPENSKY_PASSWORD}"
|
||||
put_env_if_set OPENAI_API_KEY "${OPENAI_API_KEY}"
|
||||
put_env_if_set AISSTREAM_API_KEY "${AISSTREAM_API_KEY}"
|
||||
put_env_if_set CESIUM_ION_TOKEN "${CESIUM_ION_TOKEN}"
|
||||
put_env_if_set TOMTOM_API_KEY "${TOMTOM_API_KEY}"
|
||||
put_env_if_set FIRMS_MAP_KEY "${FIRMS_MAP_KEY}"
|
||||
put_env_if_set LL2_API_TOKEN "${LL2_API_TOKEN}"
|
||||
|
||||
env ${DEV_UNSET[@]+"${DEV_UNSET[@]}"} "${DEV_ENV[@]}" "${DEV_COMMAND[@]}" --host "${HOST}" --port "${PORT}" --force
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
PORT="${PORT:-4173}"
|
||||
# Local-only by default; set HOST=0.0.0.0 explicitly to expose on the LAN.
|
||||
HOST="${HOST:-localhost}"
|
||||
|
||||
GOOGLE_MAPS_API_KEY_ENV="${GOOGLE_MAPS_API_KEY:-}"
|
||||
GOOGLE_MAPS_API_KEY_KEYCHAIN=""
|
||||
GOOGLE_MAPS_API_KEY_SOURCE=""
|
||||
if command -v security >/dev/null 2>&1; then
|
||||
for acct in "api-key" "default" "key"; do
|
||||
GOOGLE_MAPS_API_KEY_KEYCHAIN="$(security find-generic-password -s "google-maps-api" -a "${acct}" -w 2>/dev/null || true)"
|
||||
if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY_SOURCE="keychain:${acct}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_KEYCHAIN}"
|
||||
elif [[ -n "${GOOGLE_MAPS_API_KEY_ENV}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_ENV}"
|
||||
GOOGLE_MAPS_API_KEY_SOURCE="env"
|
||||
else
|
||||
GOOGLE_MAPS_API_KEY=""
|
||||
fi
|
||||
if [[ -z "${GOOGLE_MAPS_API_KEY}" ]]; then
|
||||
echo "error: Google Maps API key missing."
|
||||
echo "set GOOGLE_MAPS_API_KEY in env, or add Keychain item: service=google-maps-api account=api-key"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read_keychain_secret() {
|
||||
local service="$1"
|
||||
local account="$2"
|
||||
security find-generic-password -s "$service" -a "$account" -w 2>/dev/null || true
|
||||
}
|
||||
|
||||
load_opensky_oauth_from_file() {
|
||||
local file_path="$1"
|
||||
if [[ -z "${file_path}" || ! -f "${file_path}" ]]; then
|
||||
return
|
||||
fi
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "warning: node not found; cannot parse OPENSKY_CREDENTIALS_FILE"
|
||||
return
|
||||
fi
|
||||
|
||||
local parsed
|
||||
parsed="$(node -e "const fs=require('fs');const p=process.argv[1];try{const raw=JSON.parse(fs.readFileSync(p,'utf8'));const id=String(raw.clientId??raw.client_id??'').trim();const secret=String(raw.clientSecret??raw.client_secret??'').trim();process.stdout.write(id+'\\t'+secret);}catch{process.exit(1)}" "${file_path}" 2>/dev/null || true)"
|
||||
if [[ "${parsed}" != *$'\t'* ]]; then
|
||||
return
|
||||
fi
|
||||
local parsed_client_id="${parsed%%$'\t'*}"
|
||||
local parsed_client_secret="${parsed#*$'\t'}"
|
||||
|
||||
if [[ -z "${OPENSKY_CLIENT_ID}" && -n "${parsed_client_id}" ]]; then
|
||||
OPENSKY_CLIENT_ID="${parsed_client_id}"
|
||||
fi
|
||||
if [[ -z "${OPENSKY_CLIENT_SECRET}" && -n "${parsed_client_secret}" ]]; then
|
||||
OPENSKY_CLIENT_SECRET="${parsed_client_secret}"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_opensky_credentials() {
|
||||
OPENSKY_AUTH_MODE="$(printf '%s' "${OPENSKY_AUTH_MODE:-oauth}" | tr '[:upper:]' '[:lower:]')"
|
||||
OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE:-}"
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic|oauth|auto|anon) ;;
|
||||
*)
|
||||
echo "warning: invalid OPENSKY_AUTH_MODE='${OPENSKY_AUTH_MODE}', defaulting to 'oauth'"
|
||||
OPENSKY_AUTH_MODE="oauth"
|
||||
;;
|
||||
esac
|
||||
|
||||
OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID:-}"
|
||||
OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET:-}"
|
||||
OPENSKY_USERNAME="${OPENSKY_USERNAME:-}"
|
||||
OPENSKY_PASSWORD="${OPENSKY_PASSWORD:-}"
|
||||
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_USERNAME}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "username" "user" "login" "email" "default"; do
|
||||
OPENSKY_USERNAME="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_USERNAME}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_PASSWORD}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "password" "pass" "token" "secret" "default"; do
|
||||
OPENSKY_PASSWORD="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
load_opensky_oauth_from_file "${OPENSKY_CREDENTIALS_FILE}"
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_ID}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "client_id" "client-id" "client" "api-key"; do
|
||||
OPENSKY_CLIENT_ID="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "client_secret" "client-secret" "secret"; do
|
||||
OPENSKY_CLIENT_SECRET="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic)
|
||||
OPENSKY_CLIENT_ID=""
|
||||
OPENSKY_CLIENT_SECRET=""
|
||||
;;
|
||||
oauth)
|
||||
OPENSKY_USERNAME=""
|
||||
OPENSKY_PASSWORD=""
|
||||
;;
|
||||
anon)
|
||||
OPENSKY_CLIENT_ID=""
|
||||
OPENSKY_CLIENT_SECRET=""
|
||||
OPENSKY_USERNAME=""
|
||||
OPENSKY_PASSWORD=""
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
resolve_opensky_credentials
|
||||
|
||||
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-36}"
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-48}"
|
||||
|
||||
echo "Starting God's Eye View dev server..."
|
||||
echo "URL: http://localhost:${PORT}/"
|
||||
echo "Google Maps key source: ${GOOGLE_MAPS_API_KEY_SOURCE}"
|
||||
echo "OpenSky auth mode: ${OPENSKY_AUTH_MODE}"
|
||||
if [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
if [[ -f "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE}"
|
||||
else
|
||||
echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE} (missing)"
|
||||
fi
|
||||
fi
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic)
|
||||
if [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
echo "OpenSky basic auth: configured"
|
||||
else
|
||||
echo "OpenSky basic auth: missing credentials"
|
||||
fi
|
||||
;;
|
||||
oauth)
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
echo "OpenSky OAuth: configured"
|
||||
else
|
||||
echo "OpenSky OAuth: missing client credentials"
|
||||
fi
|
||||
;;
|
||||
auto)
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
echo "OpenSky auto auth: OAuth configured"
|
||||
elif [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
echo "OpenSky auto auth: basic configured"
|
||||
else
|
||||
echo "OpenSky auto auth: no credentials found"
|
||||
fi
|
||||
;;
|
||||
anon)
|
||||
echo "OpenSky auth: disabled (anonymous mode)"
|
||||
;;
|
||||
esac
|
||||
|
||||
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY}" \
|
||||
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES}" \
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES}" \
|
||||
OPENSKY_AUTH_MODE="${OPENSKY_AUTH_MODE}" \
|
||||
OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE}" \
|
||||
OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID}" \
|
||||
OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET}" \
|
||||
OPENSKY_USERNAME="${OPENSKY_USERNAME}" \
|
||||
OPENSKY_PASSWORD="${OPENSKY_PASSWORD}" \
|
||||
npm run dev -- --host "${HOST}" --port "${PORT}"
|
||||
@@ -0,0 +1,7 @@
|
||||
# Voice QA fixture
|
||||
|
||||
`full-globe-turn-on-radio.wav` is an owner-generated fake-microphone fixture for the credentialed AI-to-Radio acceptance test. Its embedded WAV metadata is the provenance record for the generator, model, voice, spoken text, usage terms, and audio settings; inspect it with `ffprobe -show_entries format_tags -of json scripts/fixtures/voice/full-globe-turn-on-radio.wav`.
|
||||
|
||||
- Provided and approved for repository QA use by the user on 2026-08-05.
|
||||
- SHA-256: `b57af70db1922b72fec2c6c58348ccd3309e10aa1e8edec2890277dff26cc7bb`
|
||||
- Run: `node scripts/qa-voice-wav.mjs http://localhost:4189`
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
advanceSpriteFocus,
|
||||
} from '../src/data/focusDeemphasis.js';
|
||||
import {
|
||||
applyAircraftBillboardTreatment,
|
||||
} from '../src/data/aircraftRecession.js';
|
||||
|
||||
if (typeof global.gc !== 'function') {
|
||||
throw new Error('focus-allocation-check.mjs requires node --expose-gc');
|
||||
}
|
||||
|
||||
const CALLS = 100_000;
|
||||
const TRIALS = 9;
|
||||
const WARMUP_CALLS = 200_000;
|
||||
const PURE_CALL_BUDGET = 16;
|
||||
const PRODUCTION_SPRITE_TICK_BUDGET = 212;
|
||||
|
||||
function median(values) {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.floor(sorted.length / 2)];
|
||||
}
|
||||
|
||||
function bytesPerCall(fn) {
|
||||
for (let index = 0; index < WARMUP_CALLS; index += 1) fn();
|
||||
const samples = [];
|
||||
for (let trial = 0; trial < TRIALS; trial += 1) {
|
||||
global.gc();
|
||||
const before = process.memoryUsage().heapUsed;
|
||||
let checksum = 0;
|
||||
for (let index = 0; index < CALLS; index += 1) checksum += fn();
|
||||
const after = process.memoryUsage().heapUsed;
|
||||
// Keep the returned scalar observable so V8 cannot delete the calls.
|
||||
assert.ok(Number.isFinite(checksum));
|
||||
samples.push((after - before) / CALLS);
|
||||
}
|
||||
const rawMedian = median(samples);
|
||||
return { rawMedian, samples };
|
||||
}
|
||||
|
||||
function netOfLoopBaseline(observed, baseline) {
|
||||
// V8's callback/checksum loop itself retains roughly one boxed-number word
|
||||
// per call until the next collection. Subtract the identically measured
|
||||
// no-op loop so this gate reports allocation attributable to the treatment.
|
||||
const rawMedian = Math.max(0, observed.rawMedian - baseline.rawMedian);
|
||||
return {
|
||||
rawMedian,
|
||||
roundedMedian: Math.round(rawMedian),
|
||||
observedRawMedian: observed.rawMedian,
|
||||
baselineRawMedian: baseline.rawMedian,
|
||||
samples: observed.samples,
|
||||
};
|
||||
}
|
||||
|
||||
const target = {
|
||||
screenRect: { left: 0, top: 0, right: 10, bottom: 10 },
|
||||
paddingPx: 18,
|
||||
cameraDistance: 1000,
|
||||
};
|
||||
const sprite = {};
|
||||
const focusInput = {
|
||||
screenPosition: { x: 5, y: 5 },
|
||||
cameraDistance: 1200,
|
||||
nowMs: 1000,
|
||||
target,
|
||||
};
|
||||
|
||||
const baseColor = { withAlpha: (alpha) => ({ alpha }) };
|
||||
const billboard = { scale: 1, color: { alpha: 1 } };
|
||||
const aircraftInput = {
|
||||
billboard,
|
||||
baseScale: 1,
|
||||
baseAlpha: 1,
|
||||
baseColor,
|
||||
focusFactor: 1,
|
||||
cameraDistanceM: 100,
|
||||
cameraHeightM: 1000,
|
||||
};
|
||||
|
||||
const baseline = bytesPerCall(() => focusInput.nowMs);
|
||||
const focus = netOfLoopBaseline(
|
||||
bytesPerCall(() => advanceSpriteFocus(sprite, focusInput).factor),
|
||||
baseline,
|
||||
);
|
||||
const aircraft = netOfLoopBaseline(
|
||||
bytesPerCall(() => applyAircraftBillboardTreatment(aircraftInput).alpha),
|
||||
baseline,
|
||||
);
|
||||
const productionSpriteTick = netOfLoopBaseline(
|
||||
bytesPerCall(() => {
|
||||
// Match the hot civilian/military sprite path: the caller constructs fresh
|
||||
// option literals, consumes the shared focus result immediately, and then
|
||||
// constructs the composed aircraft-treatment options for the same sprite.
|
||||
const focusResult = advanceSpriteFocus(sprite, {
|
||||
screenPosition: focusInput.screenPosition,
|
||||
cameraDistance: focusInput.cameraDistance,
|
||||
nowMs: focusInput.nowMs,
|
||||
target,
|
||||
});
|
||||
const treatment = applyAircraftBillboardTreatment({
|
||||
billboard,
|
||||
baseScale: aircraftInput.baseScale,
|
||||
baseAlpha: aircraftInput.baseAlpha,
|
||||
baseColor,
|
||||
focusFactor: focusResult.factor,
|
||||
cameraDistanceM: aircraftInput.cameraDistanceM,
|
||||
cameraHeightM: aircraftInput.cameraHeightM,
|
||||
});
|
||||
return focusResult.factor + treatment.alpha;
|
||||
}),
|
||||
baseline,
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
focus.roundedMedian <= PURE_CALL_BUDGET,
|
||||
`advanceSpriteFocus allocated ${focus.rawMedian.toFixed(3)} B/call median`,
|
||||
);
|
||||
assert.ok(
|
||||
aircraft.roundedMedian <= PURE_CALL_BUDGET,
|
||||
`applyAircraftBillboardTreatment allocated ${aircraft.rawMedian.toFixed(3)} B/call median`,
|
||||
);
|
||||
assert.ok(
|
||||
productionSpriteTick.roundedMedian <= PRODUCTION_SPRITE_TICK_BUDGET,
|
||||
`production sprite tick allocated ${productionSpriteTick.rawMedian.toFixed(3)} B/call median`,
|
||||
);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
callsPerTrial: CALLS,
|
||||
trials: TRIALS,
|
||||
budgets: {
|
||||
pureCall: PURE_CALL_BUDGET,
|
||||
productionSpriteTick: PRODUCTION_SPRITE_TICK_BUDGET,
|
||||
},
|
||||
loopBaseline: baseline,
|
||||
advanceSpriteFocus: focus,
|
||||
applyAircraftBillboardTreatment: aircraft,
|
||||
productionSpriteTick,
|
||||
}));
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
if ! command -v security >/dev/null 2>&1; then
|
||||
echo "error: macOS 'security' command not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "error: node is required to parse JSON credentials"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CREDENTIALS_FILE="${1:-${OPENSKY_CREDENTIALS_FILE:-}}"
|
||||
if [[ -z "${CREDENTIALS_FILE}" ]]; then
|
||||
echo "usage: ./scripts/opensky-import-client.sh /path/to/credentials.json"
|
||||
echo "or set OPENSKY_CREDENTIALS_FILE and run ./scripts/opensky-import-client.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${CREDENTIALS_FILE}" ]]; then
|
||||
echo "error: credentials file not found: ${CREDENTIALS_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PARSED="$(node -e "const fs=require('fs');const p=process.argv[1];const raw=JSON.parse(fs.readFileSync(p,'utf8'));const id=String(raw.clientId??raw.client_id??'').trim();const secret=String(raw.clientSecret??raw.client_secret??'').trim();if(!id||!secret){process.exit(2)};process.stdout.write(id+'\t'+secret);" "${CREDENTIALS_FILE}" 2>/dev/null || true)"
|
||||
if [[ "${PARSED}" != *$'\t'* ]]; then
|
||||
echo "error: could not parse clientId/clientSecret from ${CREDENTIALS_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CLIENT_ID="${PARSED%%$'\t'*}"
|
||||
CLIENT_SECRET="${PARSED#*$'\t'}"
|
||||
if [[ -z "${CLIENT_ID}" || -z "${CLIENT_SECRET}" ]]; then
|
||||
echo "error: credentials file is missing client id or client secret"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
security add-generic-password -U -s "opensky-network" -a "client_id" -w "${CLIENT_ID}" >/dev/null
|
||||
security add-generic-password -U -s "opensky-network" -a "client_secret" -w "${CLIENT_SECRET}" >/dev/null
|
||||
|
||||
echo "OpenSky OAuth client credentials stored in Keychain:"
|
||||
echo " service=opensky-network account=client_id"
|
||||
echo " service=opensky-network account=client_secret"
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* qa-attribution-b12.mjs — visual + state proof for Batch 12 (data attribution).
|
||||
*
|
||||
* Public attribution checks:
|
||||
* H10 — the Google/Cesium credit MUST stay visible in clean-view AND
|
||||
* recording modes (those are the modes used to record demos).
|
||||
* H11 — every data layer's required attribution must surface in the
|
||||
* expandable bottom-left "Data attribution" lightbox.
|
||||
*
|
||||
* This script drives the REAL app headless and proves:
|
||||
* (i) per-layer credits are registered in viewer.creditDisplay (H11),
|
||||
* and appear in the "Data attribution" lightbox when opened;
|
||||
* (ii) enabling datacenters + submarine cables keeps their credits present;
|
||||
* (iii) toggling clean-view keeps #cesium-credits visible (screenshot);
|
||||
* (iv) toggling recording-mode keeps #cesium-credits visible (screenshot).
|
||||
*
|
||||
* Run: node scripts/qa-attribution-b12.mjs --url http://localhost:4300
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const argv = process.argv.slice(2);
|
||||
function getOpt(flag, def) {
|
||||
const i = argv.indexOf(flag);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : def;
|
||||
}
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4300');
|
||||
const APP_ORIGIN = new URL(APP_URL).origin;
|
||||
const SHOT_DIR = resolve(__dirname, '..', 'qa-shots', 'b12');
|
||||
mkdirSync(SHOT_DIR, { recursive: true });
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
return CHROME_EXECUTABLE_CANDIDATES.find((candidate) => {
|
||||
try { return existsSync(candidate); } catch { return false; }
|
||||
}) || null;
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function check(name, ok, detail) {
|
||||
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
if (ok) passed++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
// Substrings that MUST be present across the registered per-layer credits.
|
||||
const REQUIRED_CREDIT_SUBSTRINGS = [
|
||||
'OpenStreetMap contributors', // ODbL — datacenters/dams/roads
|
||||
'adsb.lol', // ODbL — military traces
|
||||
'TeleGeography', // CC BY-NC-SA — cables
|
||||
'NASA FIRMS', // fires
|
||||
'CelesTrak', // satellites
|
||||
'U.S. Geological Survey', // earthquakes
|
||||
'OpenSky Network', // flights
|
||||
'AISStream', // vessels
|
||||
'City of Austin', // CCTV
|
||||
'Radio Browser', // internet-radio directory
|
||||
];
|
||||
|
||||
async function main() {
|
||||
const chromeExecutable = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.origin === APP_ORIGIN && url.pathname === '/api/openai/hud-summary') {
|
||||
request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ summary: 'QA globe ready' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
request.continue();
|
||||
});
|
||||
|
||||
const consoleErrors = [];
|
||||
const failedResponses = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
const sourceUrl = msg.location()?.url || '';
|
||||
consoleErrors.push(sourceUrl ? `${msg.text()} [${sourceUrl}]` : msg.text());
|
||||
}
|
||||
});
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 500) {
|
||||
failedResponses.push(`HTTP ${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n qa-attribution-b12 → ${APP_URL}\n`);
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
|
||||
// Wait for the app + viewer.creditDisplay to be live.
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.viewer.creditDisplay,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
// Give Cesium a few frames to render the on-screen credit line (logo + link).
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
|
||||
// ── (i) H11: static credits registered ────────────────────────────
|
||||
console.log('H11 — per-layer credits registered in viewer.creditDisplay');
|
||||
const registeredHtml = await page.evaluate(() => {
|
||||
const cd = window.__godsEyeView.viewer.creditDisplay;
|
||||
const statics = cd._staticCredits || [];
|
||||
return statics.map((c) => c.html);
|
||||
});
|
||||
check(
|
||||
'creditDisplay has static per-layer credits',
|
||||
registeredHtml.length >= 10,
|
||||
`${registeredHtml.length} static credits registered`,
|
||||
);
|
||||
for (const sub of REQUIRED_CREDIT_SUBSTRINGS) {
|
||||
const found = registeredHtml.some((h) => h.includes(sub));
|
||||
check(`credit present: "${sub}"`, found, found ? '' : 'MISSING');
|
||||
}
|
||||
|
||||
// ── (i) H11: credits render in the "Data attribution" lightbox ─────
|
||||
console.log('\nH11 — "Data attribution" lightbox surfaces the credits');
|
||||
// Force a render frame so the credit display flushes, then open the lightbox.
|
||||
const lightboxState = await page.evaluate(async () => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.scene.requestRender();
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
const cd = viewer.creditDisplay;
|
||||
cd.showLightbox();
|
||||
viewer.scene.requestRender();
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
const box = document.querySelector('.cesium-credit-lightbox');
|
||||
const list = box?.querySelector(':scope > ul');
|
||||
const title = box?.querySelector('.cesium-credit-lightbox-title');
|
||||
const close = box?.querySelector('.cesium-credit-lightbox-close');
|
||||
const boxRect = box?.getBoundingClientRect();
|
||||
const listRect = list?.getBoundingClientRect();
|
||||
const overlay = document.querySelector('.cesium-credit-lightbox-overlay');
|
||||
const worldOverlay = document.getElementById('world-overlay-root');
|
||||
document.querySelector('[data-qa-attribution-stack-probe]')?.remove();
|
||||
const stackProbe = document.createElement('div');
|
||||
stackProbe.dataset.qaAttributionStackProbe = 'true';
|
||||
stackProbe.textContent = 'QA WORLD LABEL — STACK PROBE';
|
||||
Object.assign(stackProbe.style, {
|
||||
position: 'absolute',
|
||||
left: `${Math.max(8, (boxRect?.left || 0) - 90)}px`,
|
||||
top: `${Math.max(8, (boxRect?.top || 0) + 150)}px`,
|
||||
width: '180px',
|
||||
padding: '5px 8px',
|
||||
color: '#8ffcff',
|
||||
background: 'rgba(4, 18, 28, 0.94)',
|
||||
borderLeft: '3px solid #19d9e8',
|
||||
font: '11px monospace',
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
worldOverlay?.appendChild(stackProbe);
|
||||
const stackProbeRect = stackProbe.getBoundingClientRect();
|
||||
const lastItem = list?.lastElementChild;
|
||||
if (list) list.scrollTop = list.scrollHeight;
|
||||
const lastRect = lastItem?.getBoundingClientRect();
|
||||
return {
|
||||
html: box?.innerHTML || '',
|
||||
itemCount: list?.children.length || 0,
|
||||
linkCount: list?.querySelectorAll('a[href]').length || 0,
|
||||
everyLinkHasDestination: [...(list?.querySelectorAll('a') || [])]
|
||||
.every((link) => Boolean(link.getAttribute('href'))),
|
||||
boxHeight: boxRect?.height || 0,
|
||||
viewportHeight: window.innerHeight,
|
||||
listClientHeight: list?.clientHeight || 0,
|
||||
listScrollHeight: list?.scrollHeight || 0,
|
||||
listClientWidth: list?.clientWidth || 0,
|
||||
listScrollWidth: list?.scrollWidth || 0,
|
||||
listOverflowY: list ? getComputedStyle(list).overflowY : '',
|
||||
titleVisible: Boolean(title?.getBoundingClientRect().height),
|
||||
closeVisible: Boolean(close?.getBoundingClientRect().height),
|
||||
overlayZIndex: Number(getComputedStyle(overlay).zIndex),
|
||||
worldOverlayZIndex: Number(getComputedStyle(worldOverlay).zIndex),
|
||||
stackProbeIntersectsModal: Boolean(boxRect
|
||||
&& stackProbeRect.left < boxRect.left
|
||||
&& stackProbeRect.right > boxRect.left
|
||||
&& stackProbeRect.top < boxRect.bottom
|
||||
&& stackProbeRect.bottom > boxRect.top),
|
||||
stackProbeExtendsOutsideModal: Boolean(boxRect && stackProbeRect.left < boxRect.left),
|
||||
lastItemReachable: Boolean(lastRect && listRect
|
||||
&& lastRect.bottom <= listRect.bottom + 1
|
||||
&& lastRect.top >= listRect.top - 1),
|
||||
};
|
||||
});
|
||||
const lightboxHtml = lightboxState.html;
|
||||
const lightboxHits = REQUIRED_CREDIT_SUBSTRINGS.filter((s) => lightboxHtml.includes(s));
|
||||
check(
|
||||
'lightbox renders the per-layer credits',
|
||||
lightboxHits.length >= REQUIRED_CREDIT_SUBSTRINGS.length - 1,
|
||||
`${lightboxHits.length}/${REQUIRED_CREDIT_SUBSTRINGS.length} required strings visible in lightbox`,
|
||||
);
|
||||
check(
|
||||
'desktop lightbox stays compact with a fixed title and close control',
|
||||
lightboxState.boxHeight > 0
|
||||
&& lightboxState.boxHeight <= lightboxState.viewportHeight * 0.71
|
||||
&& lightboxState.titleVisible
|
||||
&& lightboxState.closeVisible,
|
||||
`box=${Math.round(lightboxState.boxHeight)}px viewport=${lightboxState.viewportHeight}px`,
|
||||
);
|
||||
check(
|
||||
'complete credit list scrolls vertically without horizontal overflow',
|
||||
lightboxState.itemCount >= registeredHtml.length
|
||||
&& lightboxState.linkCount > 0
|
||||
&& lightboxState.everyLinkHasDestination
|
||||
&& lightboxState.listScrollHeight > lightboxState.listClientHeight
|
||||
&& /auto|scroll/.test(lightboxState.listOverflowY)
|
||||
&& lightboxState.listScrollWidth <= lightboxState.listClientWidth + 1
|
||||
&& lightboxState.lastItemReachable,
|
||||
`${lightboxState.itemCount} items, ${lightboxState.linkCount} links, list=${lightboxState.listClientHeight}/${lightboxState.listScrollHeight}px`,
|
||||
);
|
||||
check(
|
||||
'attribution modal stacks above shared world labels',
|
||||
Number.isFinite(lightboxState.overlayZIndex)
|
||||
&& Number.isFinite(lightboxState.worldOverlayZIndex)
|
||||
&& lightboxState.overlayZIndex > lightboxState.worldOverlayZIndex
|
||||
&& lightboxState.stackProbeIntersectsModal
|
||||
&& lightboxState.stackProbeExtendsOutsideModal,
|
||||
`modal z=${lightboxState.overlayZIndex}, world labels z=${lightboxState.worldOverlayZIndex}, overlap=${lightboxState.stackProbeIntersectsModal}`,
|
||||
);
|
||||
await page.evaluate(() => window.__godsEyeView.viewer.creditDisplay.hideLightbox());
|
||||
await page.screenshot({ path: resolve(SHOT_DIR, 'attribution-stacking-before-desktop.png') });
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.viewer.creditDisplay.showLightbox();
|
||||
const list = document.querySelector('.cesium-credit-lightbox > ul');
|
||||
if (list) list.scrollTop = 0;
|
||||
});
|
||||
await page.screenshot({ path: resolve(SHOT_DIR, 'attribution-lightbox-desktop.png') });
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.viewer.creditDisplay.hideLightbox();
|
||||
document.querySelector('[data-qa-attribution-stack-probe]')?.remove();
|
||||
});
|
||||
|
||||
await page.setViewport({ width: 560, height: 760, deviceScaleFactor: 1 });
|
||||
await page.evaluate(async () => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.resize();
|
||||
viewer.scene.requestRender();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
});
|
||||
const mobileLightbox = await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.creditDisplay.showLightbox();
|
||||
const box = document.querySelector('.cesium-credit-lightbox');
|
||||
const list = box?.querySelector(':scope > ul');
|
||||
const rect = box?.getBoundingClientRect();
|
||||
if (list) list.scrollTop = list.scrollHeight;
|
||||
const listRect = list?.getBoundingClientRect();
|
||||
const lastRect = list?.lastElementChild?.getBoundingClientRect();
|
||||
return {
|
||||
left: rect?.left || 0,
|
||||
right: rect?.right || 0,
|
||||
top: rect?.top || 0,
|
||||
bottom: rect?.bottom || 0,
|
||||
viewportWidth: innerWidth,
|
||||
viewportHeight: innerHeight,
|
||||
lastItemReachable: Boolean(lastRect && listRect
|
||||
&& lastRect.bottom <= listRect.bottom + 1),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'mobile lightbox remains full-screen edge-to-edge and reaches the final credit',
|
||||
Math.abs(mobileLightbox.left) <= 1
|
||||
&& Math.abs(mobileLightbox.right - mobileLightbox.viewportWidth) <= 1
|
||||
&& Math.abs(mobileLightbox.top) <= 1
|
||||
&& Math.abs(mobileLightbox.bottom - mobileLightbox.viewportHeight) <= 1
|
||||
&& mobileLightbox.lastItemReachable,
|
||||
`box=${Math.round(mobileLightbox.left)},${Math.round(mobileLightbox.top)}–${Math.round(mobileLightbox.right)},${Math.round(mobileLightbox.bottom)}`,
|
||||
);
|
||||
await page.screenshot({ path: resolve(SHOT_DIR, 'attribution-lightbox-mobile.png') });
|
||||
await page.evaluate(() => window.__godsEyeView.viewer.creditDisplay.hideLightbox());
|
||||
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
|
||||
|
||||
// ── (ii) enable datacenters + cables; credits still present ────────
|
||||
console.log('\nH11 — enabling datacenters + submarine cables');
|
||||
const layerIds = await page.evaluate(() => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
return [...dm.layers.keys()];
|
||||
});
|
||||
// Find the datacenter + cable layer ids by fuzzy match on the id string.
|
||||
const dcId = layerIds.find((id) => /datacenter/i.test(id));
|
||||
const cableId = layerIds.find((id) => /cable|submarine|telegeo/i.test(id));
|
||||
check('found datacenter + cable layer ids', !!dcId && !!cableId, `dc=${dcId} cable=${cableId}`);
|
||||
if (dcId) await page.evaluate((id) => window.__godsEyeView.dataManager.setEnabled(id, true), dcId);
|
||||
if (cableId) await page.evaluate((id) => window.__godsEyeView.dataManager.setEnabled(id, true), cableId);
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
const afterEnableHtml = await page.evaluate(() =>
|
||||
(window.__godsEyeView.viewer.creditDisplay._staticCredits || []).map((c) => c.html),
|
||||
);
|
||||
check(
|
||||
'datacenter credit (OSM/ODbL) still present after enable',
|
||||
afterEnableHtml.some((h) => h.includes('OpenStreetMap contributors')),
|
||||
'',
|
||||
);
|
||||
check(
|
||||
'cable credit (TeleGeography) still present after enable',
|
||||
afterEnableHtml.some((h) => h.includes('TeleGeography')),
|
||||
'',
|
||||
);
|
||||
|
||||
// helper: is #cesium-credits visible (line rendered, not display:none)?
|
||||
const creditVisibility = async () =>
|
||||
page.evaluate(() => {
|
||||
const el = document.getElementById('cesium-credits');
|
||||
if (!el) return { present: false };
|
||||
const cs = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
present: true,
|
||||
display: cs.display,
|
||||
visibility: cs.visibility,
|
||||
opacity: cs.opacity,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
text: (el.textContent || '').trim().slice(0, 120),
|
||||
hasLogo: !!el.querySelector('.cesium-credit-logoContainer, img'),
|
||||
};
|
||||
});
|
||||
|
||||
// ── (iii) clean-view: credit line STILL visible ───────────────────
|
||||
console.log('\nH10 — clean-view keeps the credit line visible');
|
||||
await page.evaluate(() => document.body.classList.add('ui-clean-view'));
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
const cleanVis = await creditVisibility();
|
||||
await page.screenshot({ path: resolve(SHOT_DIR, 'clean-view.png') });
|
||||
check(
|
||||
'clean-view: #cesium-credits not display:none',
|
||||
cleanVis.present && cleanVis.display !== 'none' && cleanVis.visibility !== 'hidden',
|
||||
`display=${cleanVis.display} visibility=${cleanVis.visibility} w=${Math.round(cleanVis.width)} h=${Math.round(cleanVis.height)}`,
|
||||
);
|
||||
check(
|
||||
'clean-view: "Data attribution" link to per-layer popover still present',
|
||||
!!cleanVis.text && /Data attribution/i.test(cleanVis.text),
|
||||
`credit text = "${cleanVis.text}"`,
|
||||
);
|
||||
await page.evaluate(() => document.body.classList.remove('ui-clean-view'));
|
||||
|
||||
// ── (iv) recording-mode: credit line STILL visible ────────────────
|
||||
console.log('\nH10 — recording-mode keeps the credit line visible');
|
||||
await page.evaluate(() => document.body.classList.add('recording-mode'));
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
const recVis = await creditVisibility();
|
||||
await page.screenshot({ path: resolve(SHOT_DIR, 'recording-mode.png') });
|
||||
check(
|
||||
'recording-mode: #cesium-credits not display:none',
|
||||
recVis.present && recVis.display !== 'none' && recVis.visibility !== 'hidden',
|
||||
`display=${recVis.display} visibility=${recVis.visibility} w=${Math.round(recVis.width)} h=${Math.round(recVis.height)}`,
|
||||
);
|
||||
await page.evaluate(() => document.body.classList.remove('recording-mode'));
|
||||
|
||||
// baseline (normal) screenshot for comparison
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
await page.screenshot({ path: resolve(SHOT_DIR, 'normal.png') });
|
||||
|
||||
check(
|
||||
'no console errors during QA',
|
||||
consoleErrors.length === 0,
|
||||
consoleErrors.slice(0, 3).join(' | ') || 'clean',
|
||||
);
|
||||
check(
|
||||
'no HTTP 5xx responses during QA',
|
||||
failedResponses.length === 0,
|
||||
failedResponses.slice(0, 3).join(' | ') || 'clean',
|
||||
);
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log('\n────────────────────────────────────────────────────────────');
|
||||
console.log(` RESULT: ${passed} passed, ${failed} failed`);
|
||||
console.log(` Screenshots: ${SHOT_DIR}`);
|
||||
console.log('────────────────────────────────────────────────────────────\n');
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('qa-attribution-b12 crashed:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-cables-overlay — submarine-cable label path measurement + contract gate.
|
||||
*
|
||||
* Two jobs:
|
||||
* 1. MEASURE (both builds, `--legacy` on the pre-migration build): with the
|
||||
* cables layer ON at a fixed mid-Atlantic camera, capture
|
||||
* a. per-frame `scene.render` cost over a ~10 s driven-orbit window
|
||||
* (mean / p50 / p95 / max — relative numbers, SwiftShader surface),
|
||||
* b. toggle-ON → first-labels-visible latency,
|
||||
* c. entity / native-label / host-entry counts,
|
||||
* d. parked-idle postRender fires over 5 s (render-governor honesty).
|
||||
* 2. GATE (unified build only): assert the migration contract —
|
||||
* zero native `LabelGraphics` on cable reference entities, host entries
|
||||
* present for the source, idle stays near-zero, and a clean OFF→ON cycle
|
||||
* (no orphan host entries after disable).
|
||||
*
|
||||
* Usage: node scripts/qa-cables-overlay.mjs [--url http://localhost:4214] [--legacy]
|
||||
* Requires a running dev server. Headless; rAF throttling disabled so the
|
||||
* frame clock is honest (hidden-pane gotcha).
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv;
|
||||
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214';
|
||||
const legacy = argv.includes('--legacy');
|
||||
// --control: never enable the layer; measure the empty-scene orbit cost so the
|
||||
// cables layer's share of frame time is attributable.
|
||||
const control = argv.includes('--control');
|
||||
const LAYER_ID = 'telegeography-submarine-cables';
|
||||
|
||||
const results = [];
|
||||
function check(name, pass, detail) {
|
||||
results.push({ name, pass });
|
||||
const tag = pass ? 'PASS' : 'FAIL';
|
||||
console.log(` [${tag}] ${name}${detail !== undefined ? ` — ${JSON.stringify(detail)}` : ''}`);
|
||||
}
|
||||
function report(name, detail) {
|
||||
console.log(` [MEAS] ${name} — ${JSON.stringify(detail)}`);
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
protocolTimeout: 300_000,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--window-size=1440,900',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-background-timer-throttling',
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 860 });
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 });
|
||||
await new Promise((r) => setTimeout(r, 12_000)); // boot flyTo + deferred init
|
||||
|
||||
// Park mid-Atlantic (many cables + both coasts' landings in range) and
|
||||
// disable every layer so the cables layer is measured in isolation.
|
||||
await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
const v = gev.viewer;
|
||||
v.camera.cancelFlight();
|
||||
const ell = v.scene.globe.ellipsoid;
|
||||
v.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
longitude: -40 * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
for (const [id, entry] of gev.dataManager.layers) {
|
||||
if (entry.enabled) { try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* measured via counts */ } }
|
||||
}
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 6_000)); // tiles + fades settle
|
||||
|
||||
// ── b. toggle-ON → labels visible ─────────────────────────────────────
|
||||
if (!control) {
|
||||
const labelLatency = await page.evaluate(async (layerId, isLegacy) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const t0 = performance.now();
|
||||
await gev.dataManager.setEnabled(layerId, true, { origin: 'user' });
|
||||
const deadline = t0 + 60_000;
|
||||
const labelsUp = () => {
|
||||
if (isLegacy) {
|
||||
const stats = gev.dataManager.layers.get(layerId)?.module?.getStats?.() || {};
|
||||
return (stats.referenceLabelCount || 0) > 0;
|
||||
}
|
||||
const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
|
||||
return (diag.paintedBySource?.[layerId] || 0) > 0;
|
||||
};
|
||||
while (performance.now() < deadline) {
|
||||
if (labelsUp()) return { ms: Math.round(performance.now() - t0), timedOut: false };
|
||||
gev.viewer.scene.requestRender?.();
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return { ms: 60_000, timedOut: true };
|
||||
}, LAYER_ID, legacy);
|
||||
report('toggle-ON -> labels visible (ms)', labelLatency);
|
||||
check('labels became visible after enable', labelLatency.timedOut === false, labelLatency);
|
||||
await new Promise((r) => setTimeout(r, 3_000)); // let load/labels settle
|
||||
}
|
||||
|
||||
// ── c. entity / label / host counts ───────────────────────────────────
|
||||
const counts = await page.evaluate((layerId) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const v = gev.viewer;
|
||||
const out = {
|
||||
dataSources: {}, nativeLabels: 0, dynamicPositionEntities: 0,
|
||||
referenceEntities: 0, hostEntries: 0, hostPainted: 0,
|
||||
};
|
||||
for (let i = 0; i < v.dataSources.length; i++) {
|
||||
const ds = v.dataSources.get(i);
|
||||
if (!/TeleGeography/.test(ds.name || '')) continue;
|
||||
const list = ds.entities.values;
|
||||
out.dataSources[ds.name] = list.length;
|
||||
for (const e of list) {
|
||||
if (e.label !== undefined) out.nativeLabels++;
|
||||
if (e.position && e.position.isConstant === false) out.dynamicPositionEntities++;
|
||||
if (e.polyline?.positions && e.polyline.positions.isConstant === false) {
|
||||
out.dynamicPositionEntities++;
|
||||
}
|
||||
}
|
||||
if (/References/.test(ds.name)) out.referenceEntities = list.length;
|
||||
}
|
||||
const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
|
||||
out.hostEntries = diag.entriesBySource?.[layerId] || 0;
|
||||
out.hostPainted = diag.paintedBySource?.[layerId] || 0;
|
||||
const stats = gev.dataManager.layers.get(layerId)?.module?.getStats?.() || {};
|
||||
out.referenceLabelCount = stats.referenceLabelCount ?? null;
|
||||
return out;
|
||||
}, LAYER_ID);
|
||||
report(control ? 'counts (control, cables OFF)' : 'counts with cables ON', counts);
|
||||
|
||||
// ── a. per-frame scene.render cost over a ~10 s driven orbit ─────────
|
||||
const frameCost = await page.evaluate(() => new Promise((resolve) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const scene = v.scene;
|
||||
const durations = [];
|
||||
const originalRender = scene.render;
|
||||
scene.render = function patchedRender(...args) {
|
||||
const started = performance.now();
|
||||
const result = originalRender.apply(this, args);
|
||||
durations.push(performance.now() - started);
|
||||
return result;
|
||||
};
|
||||
const t0 = performance.now();
|
||||
const tick = () => {
|
||||
// Slow orbit: every frame is a camera change, so Cesium renders every
|
||||
// rAF — the honest "user is interacting" cost of the layer.
|
||||
v.camera.rotateRight(0.0004);
|
||||
if (performance.now() - t0 < 10_000) requestAnimationFrame(tick);
|
||||
else {
|
||||
scene.render = originalRender;
|
||||
durations.sort((a, b) => a - b);
|
||||
const n = durations.length;
|
||||
const sum = durations.reduce((s, d) => s + d, 0);
|
||||
resolve({
|
||||
frames: n,
|
||||
meanMs: +(sum / Math.max(1, n)).toFixed(2),
|
||||
p50Ms: +(durations[Math.floor(n * 0.5)] || 0).toFixed(2),
|
||||
p95Ms: +(durations[Math.floor(n * 0.95)] || 0).toFixed(2),
|
||||
maxMs: +(durations[n - 1] || 0).toFixed(2),
|
||||
effectiveFps: +(n / 10).toFixed(1),
|
||||
});
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}));
|
||||
report(control
|
||||
? 'scene.render cost, control (cables OFF), 10s orbit'
|
||||
: 'scene.render cost, cables ON, 10s orbit', frameCost);
|
||||
|
||||
// ── d. parked idle honesty (labels must not force continuous render) ──
|
||||
await new Promise((r) => setTimeout(r, 4_000)); // orbit stop + fades settle
|
||||
const idle = await page.evaluate(() => new Promise((resolve) => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
let renders = 0;
|
||||
const remove = scene.postRender.addEventListener(() => { renders += 1; });
|
||||
setTimeout(() => { remove(); resolve({ renders }); }, 5_000);
|
||||
}));
|
||||
report(control
|
||||
? 'parked idle, control (postRender fires / 5s)'
|
||||
: 'parked idle with cables ON (postRender fires / 5s)', idle);
|
||||
|
||||
if (!legacy && !control) {
|
||||
// ── unified-build contract gates ────────────────────────────────────
|
||||
check('zero native LabelGraphics on cable entities', counts.nativeLabels === 0, counts.nativeLabels);
|
||||
check('zero per-frame CallbackProperty cable entities', counts.dynamicPositionEntities === 0, counts.dynamicPositionEntities);
|
||||
check('host has cable entries', counts.hostEntries > 0, counts.hostEntries);
|
||||
check('host painted cable labels', counts.hostPainted > 0, counts.hostPainted);
|
||||
// The cables source never republishes an unchanged cohort (unit pin), and
|
||||
// since the 2026-08-18 host fix the occluder observer is scoped (body
|
||||
// childList filtered to inventory chrome; attribute observation
|
||||
// element-only) and the right-rail allocator writes-if-changed — so a
|
||||
// parked camera measures 0 postRender fires / 5 s with cables ON, equal
|
||||
// to the empty-scene control (pre-fix: ~56-61). The headroom below covers
|
||||
// an occasional genuine chrome transition (loading-chip flip + its 100 ms
|
||||
// occluder-refresh echo) landing inside the window; ticking churn (~10)
|
||||
// or the old observer leak (~56+) must fail.
|
||||
check('parked idle stays near zero (≤6 / 5s)', idle.renders <= 6, idle);
|
||||
|
||||
// OFF must clear the host source (no orphan labels), ON must restore.
|
||||
const cycle = await page.evaluate(async (layerId) => {
|
||||
const gev = window.__godsEyeView;
|
||||
await gev.dataManager.setEnabled(layerId, false, { origin: 'user' });
|
||||
gev.viewer.scene.requestRender?.();
|
||||
await new Promise((r) => setTimeout(r, 1_200));
|
||||
const offDiag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
|
||||
const offEntries = offDiag.entriesBySource?.[layerId] || 0;
|
||||
const offPainted = offDiag.paintedBySource?.[layerId] || 0;
|
||||
await gev.dataManager.setEnabled(layerId, true, { origin: 'user' });
|
||||
const t0 = performance.now();
|
||||
let onPainted = 0;
|
||||
while (performance.now() - t0 < 20_000) {
|
||||
const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
|
||||
onPainted = diag.paintedBySource?.[layerId] || 0;
|
||||
if (onPainted > 0) break;
|
||||
gev.viewer.scene.requestRender?.();
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return { offEntries, offPainted, onPainted };
|
||||
}, LAYER_ID);
|
||||
check('disable clears host entries (no orphans)', cycle.offEntries === 0 && cycle.offPainted === 0, cycle);
|
||||
check('re-enable restores painted labels', cycle.onPainted > 0, cycle);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const passed = results.filter((r) => r.pass).length;
|
||||
console.log(`\nqa-cables-overlay: ${passed}/${results.length} passed${legacy ? ' (legacy measurement mode)' : ''}${control ? ' (control mode)' : ''}`);
|
||||
console.log(`RESULT: ${passed} passed, ${results.length - passed} failed, 0 skipped`);
|
||||
process.exit(passed === results.length ? 0 : 1);
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-cables-render-probe — replicates the owner-side perf probe methodology
|
||||
* for the cables layer (relative numbers; SwiftShader when run headless):
|
||||
*
|
||||
* 1. PARKED: timed full `viewer.render()` loop (40 calls) with cables ON,
|
||||
* then OFF, at the fixed mid-Atlantic camera. Reports mean/max.
|
||||
* 2. PAN-STOP: 3 cycles of { setView to an offset, raise camera.moveEnd,
|
||||
* wait 650 ms, 20 timed `viewer.render()` calls }. Reports per-cycle
|
||||
* mean/max and the worst frame across cycles, cables ON vs OFF.
|
||||
*
|
||||
* Usage: node scripts/qa-cables-render-probe.mjs [--url http://localhost:4216]
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv;
|
||||
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4216';
|
||||
const LAYER_ID = 'telegeography-submarine-cables';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
protocolTimeout: 300_000,
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900',
|
||||
'--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding',
|
||||
'--disable-background-timer-throttling',
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 860 });
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 });
|
||||
await new Promise((r) => setTimeout(r, 12_000));
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
gev.viewer.camera.cancelFlight();
|
||||
const ell = gev.viewer.scene.globe.ellipsoid;
|
||||
gev.viewer.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
longitude: -40 * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
for (const [id, entry] of gev.dataManager.layers) {
|
||||
if (entry.enabled) { try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* probe */ } }
|
||||
}
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 5_000));
|
||||
|
||||
const stats = (values) => ({
|
||||
mean: +(values.reduce((s, v) => s + v, 0) / Math.max(1, values.length)).toFixed(2),
|
||||
max: +Math.max(...values).toFixed(2),
|
||||
});
|
||||
|
||||
const measure = async (label, enabled) => {
|
||||
await page.evaluate(async (id, on) => {
|
||||
const gev = window.__godsEyeView;
|
||||
await gev.dataManager.setEnabled(id, on, { origin: 'user' });
|
||||
}, LAYER_ID, enabled);
|
||||
await new Promise((r) => setTimeout(r, enabled ? 6_000 : 3_000)); // load/teardown + settle
|
||||
|
||||
// 1. PARKED timed renders.
|
||||
const parked = await page.evaluate(() => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const durations = [];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const t0 = performance.now();
|
||||
v.render();
|
||||
durations.push(performance.now() - t0);
|
||||
}
|
||||
return durations;
|
||||
});
|
||||
|
||||
// 2. PAN-STOP cycles: move, raise moveEnd, wait 650 ms, 20 timed renders.
|
||||
const cycles = [];
|
||||
for (let cycle = 0; cycle < 3; cycle++) {
|
||||
const frames = await page.evaluate(async (step) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const ell = v.scene.globe.ellipsoid;
|
||||
v.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
longitude: (-40 + step * 3) * Math.PI / 180,
|
||||
latitude: 35 * Math.PI / 180,
|
||||
height: 4_500_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
v.camera.moveEnd.raiseEvent();
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
const durations = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const t0 = performance.now();
|
||||
v.render();
|
||||
durations.push(performance.now() - t0);
|
||||
}
|
||||
return durations;
|
||||
}, cycle + 1);
|
||||
cycles.push(stats(frames));
|
||||
}
|
||||
|
||||
const worst = Math.max(...cycles.map((c) => c.max));
|
||||
console.log(` [${label}] parked render: ${JSON.stringify(stats(parked))}`);
|
||||
console.log(` [${label}] pan-stop cycles: ${JSON.stringify(cycles)} worstFrame=${worst}`);
|
||||
return { parked: stats(parked), cycles, worst };
|
||||
};
|
||||
|
||||
const on = await measure('cables ON ', true);
|
||||
const off = await measure('cables OFF', false);
|
||||
console.log(`\nparked delta (ON-OFF mean): ${(on.parked.mean - off.parked.mean).toFixed(2)}ms`);
|
||||
console.log(`pan-stop worst: ON ${on.worst}ms vs OFF ${off.worst}ms`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-cables-shot — capture the cables layer at two fixed cameras for
|
||||
* before/after visual-identity comparison. Writes to gitignored qa-shots/.
|
||||
* Usage: node scripts/qa-cables-shot.mjs [--url http://localhost:4214] [--tag before]
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
|
||||
const argv = process.argv;
|
||||
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214';
|
||||
const tag = argv.includes('--tag') ? argv[argv.indexOf('--tag') + 1] : 'shot';
|
||||
const LAYER_ID = 'telegeography-submarine-cables';
|
||||
mkdirSync(new URL('../qa-shots', import.meta.url), { recursive: true });
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
protocolTimeout: 300_000,
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900',
|
||||
'--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding',
|
||||
'--disable-background-timer-throttling',
|
||||
],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 860 });
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 });
|
||||
await new Promise((r) => setTimeout(r, 12_000));
|
||||
await page.evaluate(async (layerId) => {
|
||||
const gev = window.__godsEyeView;
|
||||
gev.viewer.camera.cancelFlight();
|
||||
for (const [id, entry] of gev.dataManager.layers) {
|
||||
if (entry.enabled && id !== layerId) {
|
||||
try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* shot only */ }
|
||||
}
|
||||
}
|
||||
await gev.dataManager.setEnabled(layerId, true, { origin: 'user' });
|
||||
}, LAYER_ID);
|
||||
|
||||
const views = [
|
||||
{ name: 'atlantic', lon: -40, lat: 35, height: 4_500_000 },
|
||||
{ name: 'ny-coast', lon: -73.5, lat: 40.4, height: 500_000 },
|
||||
];
|
||||
for (const view of views) {
|
||||
await page.evaluate((v) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const ell = viewer.scene.globe.ellipsoid;
|
||||
viewer.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
longitude: v.lon * Math.PI / 180, latitude: v.lat * Math.PI / 180, height: v.height,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
}, view);
|
||||
// Let the sweep + labels settle with frames flowing.
|
||||
await page.evaluate(() => new Promise((resolve) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
let ticks = 0;
|
||||
const tick = () => {
|
||||
v.scene.requestRender?.();
|
||||
if (++ticks < 240) requestAnimationFrame(tick); else resolve();
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}));
|
||||
await new Promise((r) => setTimeout(r, 1_000));
|
||||
const path = new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url).pathname;
|
||||
await page.screenshot({ path });
|
||||
console.log(`saved ${path}`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Rendered proof for backdrop-selective callout plates at GROUND LEVEL.
|
||||
*
|
||||
* The owner's scenario: a cockpit sitting at JFK, where the geoid runs ~34 m
|
||||
* under the ellipsoid so the HUD honestly reads a NEGATIVE altitude. Every
|
||||
* label against the open sky came back with a full dark plate, because a
|
||||
* sub-ellipsoid camera fell into `skyBackdropFactor`'s degenerate guard.
|
||||
*
|
||||
* WHAT THIS ASSERTS, AND WHY IT IS NOT A UNIT TEST IN DISGUISE. An earlier
|
||||
* version of this harness called `skyBackdropFactor` directly and would have
|
||||
* passed with the painter stubbed out — the number was right and nothing was
|
||||
* drawn. Every assertion here now comes from the RENDERED path instead:
|
||||
*
|
||||
* ids/counts — `styleManager.getDetectionDiagnostics()` (src/ui.js), the
|
||||
* overlay's own published `visibleCount` / `labeledKeys`, so a contact only
|
||||
* counts once the sweep, the arbiter and the placement solve have all run.
|
||||
* plate scale — tapped off `CanvasRenderingContext2D` while the frame paints.
|
||||
* `paintDetectionCallout` fills the plate at `alpha * plateScale` and the
|
||||
* accent bar at `alpha`, in that order, then draws the callsign, so the
|
||||
* ratio of the two fills IS the plate scale and the callsign attributes it
|
||||
* to a named contact. Nothing is recomputed; this reads what was painted.
|
||||
* pixels — the plate rect comes from the same tap (`roundRect` immediately
|
||||
* precedes its `fill`), and the harness reads back mean alpha inside that
|
||||
* rect on `#world-overlay-canvas`, the real compositing surface.
|
||||
*
|
||||
* `--teeth` proves those assertions have teeth: it stubs the canvas paint calls
|
||||
* the callout painter uses to no-ops, so nothing reaches the framebuffer, and
|
||||
* the plate-scale and pixel checks must go RED. Run it after any change here.
|
||||
*
|
||||
* Three poses over one synthetic contact field:
|
||||
* level — cockpit on the ramp, nose on the horizon. This is the frame
|
||||
* the owner shot: approach traffic against open sky.
|
||||
* tilted-down — same cockpit, pitched down over the apron. Asserts that NO
|
||||
* ground contact is labelled, which is not this fix's doing:
|
||||
* from a camera under the ellipsoid Cesium's
|
||||
* `EllipsoidalOccluder` culls every point below the local
|
||||
* horizontal before detection ever sees it.
|
||||
* lifted — the same view a moment after rotate, at +40 m of ellipsoid
|
||||
* height. The apron contacts are back, and they keep their
|
||||
* FULL plates: the proof that sky selectivity did not quietly
|
||||
* become "no plates anywhere".
|
||||
*
|
||||
* Run (dev server already up):
|
||||
* node scripts/qa-cockpit-plates.mjs --url http://localhost:4268
|
||||
* node scripts/qa-cockpit-plates.mjs --url http://localhost:4268 --tag before
|
||||
* node scripts/qa-cockpit-plates.mjs --url http://localhost:4268 --teeth
|
||||
*
|
||||
* Defaults to the real GPU (ANGLE/Metal). `--swiftshader` selects software GL,
|
||||
* which is deterministic but is NOT real-GPU evidence; the banner says which
|
||||
* backend actually ran and the run records it alongside the results.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, fallback) => {
|
||||
const index = argv.indexOf(name);
|
||||
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
||||
};
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const APP_ORIGIN = new URL(APP_URL).origin;
|
||||
const TAG = getOpt('--tag', 'after');
|
||||
const HEADFUL = argv.includes('--headful');
|
||||
const TEETH = argv.includes('--teeth');
|
||||
const SWIFTSHADER = argv.includes('--swiftshader');
|
||||
const SHOT_DIR = path.resolve('qa-shots/cockpitplates');
|
||||
|
||||
/** Mirrors `SKY_PLATE_SCALE` in src/overlays/worldOverlayTokens.js. */
|
||||
const SKY_PLATE_SCALE = 0.22;
|
||||
/** Painted alpha is a ratio of two floats; this is generous slack on it. */
|
||||
const PLATE_SCALE_TOLERANCE = 0.02;
|
||||
|
||||
// JFK. Ramp ~4 m MSL with a geoid height of about -34 m, so ellipsoid height
|
||||
// on the deck is ~-30 m and a widebody cockpit reads ALT -18 m: the owner's
|
||||
// exact HUD number, and a camera genuinely INSIDE the ellipsoid.
|
||||
const JFK_LON = -73.7781;
|
||||
const JFK_LAT = 40.6413;
|
||||
const COCKPIT_H = -18;
|
||||
const RAMP_H = -30;
|
||||
const M_PER_DEG_LAT = 110_574;
|
||||
|
||||
/**
|
||||
* Contacts are placed due north of the cockpit, which faces north, so the sky
|
||||
* set and the ground set share one frame and one screenshot can show both.
|
||||
*/
|
||||
const CONTACTS = [
|
||||
// --- against open sky: above the cockpit's eye level ---
|
||||
{ id: 'AAL1042', metric: '168KT', range: 6_000, height: 620, want: 'sky' },
|
||||
{ id: 'DAL883', metric: '154KT', range: 9_000, height: 940, want: 'sky' },
|
||||
{ id: 'JBU221', metric: '178KT', range: 12_000, height: 1_500, want: 'sky' },
|
||||
{ id: 'UAL508', metric: '145KT', range: 4_200, height: 380, want: 'sky' },
|
||||
{ id: 'FDX1190', metric: '190KT', range: 16_000, height: 2_400, want: 'sky' },
|
||||
// --- against the apron: below the cockpit floor ---
|
||||
{ id: 'N6172G', metric: '12KT', range: 150, height: RAMP_H, want: 'ground' },
|
||||
{ id: 'SWA2255', metric: '8KT', range: 260, height: RAMP_H, want: 'ground' },
|
||||
{ id: 'DAL2190', metric: '15KT', range: 400, height: RAMP_H - 4, want: 'ground' },
|
||||
{ id: 'AAL77', metric: '6KT', range: 620, height: RAMP_H - 6, want: 'ground' },
|
||||
];
|
||||
|
||||
const SKY_IDS = CONTACTS.filter((contact) => contact.want === 'sky').map((contact) => contact.id);
|
||||
const GROUND_IDS = CONTACTS.filter((contact) => contact.want === 'ground').map((contact) => contact.id);
|
||||
|
||||
const POSES = [
|
||||
{
|
||||
name: 'level',
|
||||
height: COCKPIT_H,
|
||||
pitchDeg: -1.5,
|
||||
// Approach traffic is above eye level, so it survives the occluder and must
|
||||
// paint at the feathered scale.
|
||||
expectPlated: SKY_IDS,
|
||||
expectScale: SKY_PLATE_SCALE,
|
||||
expectAbsent: GROUND_IDS,
|
||||
note: 'sub-ellipsoid cockpit, nose on the horizon',
|
||||
},
|
||||
{
|
||||
name: 'tilted-down',
|
||||
height: COCKPIT_H,
|
||||
pitchDeg: -14,
|
||||
expectPlated: [],
|
||||
expectScale: null,
|
||||
expectAbsent: GROUND_IDS,
|
||||
note: 'same cockpit pitched down; the occluder owns this emptiness',
|
||||
},
|
||||
{
|
||||
name: 'lifted',
|
||||
height: 40,
|
||||
pitchDeg: -14,
|
||||
expectPlated: GROUND_IDS,
|
||||
expectScale: 1,
|
||||
expectAbsent: [],
|
||||
note: 'above the ellipsoid after rotate; apron plates at full strength',
|
||||
},
|
||||
];
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Version-pinned Chrome-for-Testing over the auto-updating system Chrome:
|
||||
// its software-GL behavior shifts across majors and has produced false
|
||||
// negatives in this repo's harnesses before.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
].filter(Boolean);
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(` [${ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
const stubJson = (body) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
/**
|
||||
* Installed before any app code runs. Records the callout painter's own canvas
|
||||
* calls; under `--teeth` it replaces them with no-ops instead, so the painter
|
||||
* stops reaching the framebuffer and every rendered assertion should fail.
|
||||
*/
|
||||
function installPaintTap(teeth) {
|
||||
const proto = CanvasRenderingContext2D.prototype;
|
||||
const origRoundRect = proto.roundRect;
|
||||
const origFill = proto.fill;
|
||||
const origFillText = proto.fillText;
|
||||
window.__PLATE_TAP = { on: false, events: [], lastRect: null, teeth };
|
||||
|
||||
if (teeth) {
|
||||
// Stub the painter: no record, no pixels. This is the negative control.
|
||||
proto.roundRect = function () {};
|
||||
proto.fill = function () {};
|
||||
proto.fillText = function () {};
|
||||
return;
|
||||
}
|
||||
|
||||
proto.roundRect = function (x, y, w, h, r) {
|
||||
if (window.__PLATE_TAP.on) window.__PLATE_TAP.lastRect = { x, y, w, h };
|
||||
return origRoundRect.call(this, x, y, w, h, r);
|
||||
};
|
||||
proto.fill = function (...args) {
|
||||
if (window.__PLATE_TAP.on) {
|
||||
window.__PLATE_TAP.events.push({
|
||||
kind: 'fill',
|
||||
alpha: this.globalAlpha,
|
||||
rect: window.__PLATE_TAP.lastRect || null,
|
||||
});
|
||||
}
|
||||
return origFill.apply(this, args);
|
||||
};
|
||||
proto.fillText = function (text, x, y, ...rest) {
|
||||
if (window.__PLATE_TAP.on) {
|
||||
window.__PLATE_TAP.events.push({ kind: 'text', alpha: this.globalAlpha, text: String(text) });
|
||||
}
|
||||
return origFillText.call(this, text, x, y, ...rest);
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const backend = SWIFTSHADER ? 'software GL (SwiftShader)' : 'real GPU (ANGLE/Metal)';
|
||||
console.log('\nCockpit Plate Backdrop QA — rendered path');
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Tag : ${TAG}${TEETH ? ' (TEETH: painter stubbed, expect RED)' : ''}`);
|
||||
console.log(` Backend : ${backend}\n`);
|
||||
|
||||
try {
|
||||
const response = await fetch(APP_URL);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
console.error(`Dev server not reachable at ${APP_URL}: ${error.message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
||||
const executablePath = CHROME_CANDIDATES.find((candidate) => {
|
||||
try { return fs.existsSync(candidate); } catch { return false; }
|
||||
});
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
...(SWIFTSHADER
|
||||
? ['--use-gl=angle', '--use-angle=swiftshader']
|
||||
: ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist']),
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
],
|
||||
});
|
||||
|
||||
const consoleErrors = [];
|
||||
const perPose = [];
|
||||
let renderer = 'unknown';
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 1 });
|
||||
page.on('console', (message) => {
|
||||
if (message.type() !== 'error') return;
|
||||
const text = message.text();
|
||||
if (!/Failed to load resource.*404/i.test(text)) consoleErrors.push(text);
|
||||
});
|
||||
page.on('pageerror', (error) => consoleErrors.push(`pageerror: ${error.message}`));
|
||||
await page.evaluateOnNewDocument(installPaintTap, TEETH);
|
||||
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.origin !== APP_ORIGIN) return void request.continue();
|
||||
// Silence the live feeds: the injected field is the only population.
|
||||
if (url.pathname === '/api/ais-live') {
|
||||
return void request.respond(stubJson({
|
||||
rows: [], source: 'QA', status: 'open', error: null, refreshing: false,
|
||||
newestPositionAt: null, lastMessageAt: null,
|
||||
}));
|
||||
}
|
||||
if (url.pathname === '/api/adsblol/mil') {
|
||||
return void request.respond(stubJson({ msg: 'No error', now: Date.now(), ac: [] }));
|
||||
}
|
||||
if (url.pathname === '/api/opensky') {
|
||||
return void request.respond(stubJson({ time: Math.floor(Date.now() / 1000), states: [] }));
|
||||
}
|
||||
if (url.pathname === '/api/opensky-track') {
|
||||
return void request.respond(stubJson({ path: [] }));
|
||||
}
|
||||
if (url.pathname === '/api/openai/hud-summary') {
|
||||
return void request.respond(stubJson({ summary: 'Cockpit plate QA' }));
|
||||
}
|
||||
return void request.continue();
|
||||
});
|
||||
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90_000 });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 90_000 });
|
||||
await page.waitForFunction(
|
||||
() => document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 90_000 },
|
||||
);
|
||||
|
||||
renderer = await page.evaluate(() => {
|
||||
const gl = document.createElement('canvas').getContext('webgl2')
|
||||
|| document.createElement('canvas').getContext('webgl');
|
||||
if (!gl) return 'no webgl';
|
||||
const ext = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
return ext
|
||||
? String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL))
|
||||
: String(gl.getParameter(gl.RENDERER));
|
||||
});
|
||||
const softwareRenderer = /swiftshader|llvmpipe|software/i.test(renderer);
|
||||
console.log(` GL : ${renderer}\n`);
|
||||
record(
|
||||
SWIFTSHADER
|
||||
? 'running on software GL as requested (structural checks, not GPU evidence)'
|
||||
: 'running on the real GPU, so the screenshots are real-GPU evidence',
|
||||
SWIFTSHADER ? softwareRenderer : !softwareRenderer,
|
||||
renderer,
|
||||
);
|
||||
|
||||
// Inject the field. The layers keep their real pipelines; only the source
|
||||
// of observations is replaced, exactly as scripts/qa-labels.mjs does.
|
||||
await page.evaluate((payload) => {
|
||||
const { viewer, dataManager, styleManager } = window.__godsEyeView;
|
||||
viewer.camera.cancelFlight();
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
window.__PLATE_QA_FIELD = payload.contacts.map((contact) => ({
|
||||
sourceId: contact.id.toLowerCase(),
|
||||
id: contact.id,
|
||||
metric: contact.metric,
|
||||
position: Cartesian3.fromDegrees(
|
||||
payload.lon,
|
||||
payload.lat + contact.range / payload.mPerDegLat,
|
||||
contact.height,
|
||||
),
|
||||
type: 'AIR',
|
||||
tier: 'civil',
|
||||
}));
|
||||
for (const layerId of ['flights', 'military', 'satellites']) {
|
||||
const entry = dataManager.layers.get(layerId);
|
||||
if (!entry?.module) continue;
|
||||
entry.module.getDetectableObjects = () => (
|
||||
layerId === 'flights' ? window.__PLATE_QA_FIELD : []
|
||||
);
|
||||
}
|
||||
styleManager.setDetection({ enabled: true, densityPct: 100, allocationStrategy: 'elastic' });
|
||||
}, { lon: JFK_LON, lat: JFK_LAT, mPerDegLat: M_PER_DEG_LAT, contacts: CONTACTS });
|
||||
|
||||
for (const pose of POSES) {
|
||||
console.log(`\n — pose "${pose.name}" (${pose.note})`);
|
||||
await page.evaluate((payload) => {
|
||||
const { viewer } = window.__godsEyeView;
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(payload.lon, payload.lat, payload.height),
|
||||
orientation: { heading: 0, pitch: payload.pitchDeg * Math.PI / 180, roll: 0 },
|
||||
});
|
||||
}, { lon: JFK_LON, lat: JFK_LAT, height: pose.height, pitchDeg: pose.pitchDeg });
|
||||
|
||||
// Let the tiles settle and the label solve run before the tap window.
|
||||
await new Promise((resolve) => setTimeout(resolve, 6_000));
|
||||
await page.evaluate(() => {
|
||||
window.__PLATE_TAP.events = [];
|
||||
window.__PLATE_TAP.on = true;
|
||||
// Drive the frames this measurement needs, instead of assuming something
|
||||
// else is holding the render loop open.
|
||||
//
|
||||
// This harness enables no data layer and never enters real Cockpit (which
|
||||
// takes its own hold), so nothing in the scene animates. It used to get
|
||||
// its frames for free from detection's continuous-render hold — and that
|
||||
// hold was itself the bug: it pinned every idle tab at 60 fps, which is
|
||||
// why it was removed (see src/data/detectionRenderDemand.js). The scene is
|
||||
// now correctly idle during the tap window, so a measurement of PAINT
|
||||
// OUTPUT has to ask for its own renders. Production is unaffected: real
|
||||
// Cockpit holds, and a parked map keeps showing the last correct frame
|
||||
// precisely because nothing changed.
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
const pump = () => {
|
||||
if (!window.__PLATE_TAP.on) return;
|
||||
scene.requestRender();
|
||||
requestAnimationFrame(pump);
|
||||
};
|
||||
pump();
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_200));
|
||||
const observed = await page.evaluate((wantedIds) => {
|
||||
window.__PLATE_TAP.on = false;
|
||||
const events = window.__PLATE_TAP.events;
|
||||
const wanted = new Set(wantedIds);
|
||||
// paintDetectionCallout paints, in order: the plate (alpha*plateScale),
|
||||
// the accent bar (alpha), the leader, then the callsign. So the two
|
||||
// fills before a callsign are that callout's plate and accent, and
|
||||
// their ratio is the plate scale that was actually painted.
|
||||
const byId = new Map();
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i];
|
||||
if (event.kind !== 'text') continue;
|
||||
const id = event.text.trim();
|
||||
if (!wanted.has(id)) continue;
|
||||
const fills = [];
|
||||
for (let j = i - 1; j >= 0 && fills.length < 2; j--) {
|
||||
if (events[j].kind === 'fill') fills.unshift(events[j]);
|
||||
}
|
||||
if (fills.length < 2) continue;
|
||||
const [plate, accent] = fills;
|
||||
if (!(accent.alpha > 0)) continue;
|
||||
byId.set(id, {
|
||||
id,
|
||||
plateAlpha: plate.alpha,
|
||||
accentAlpha: accent.alpha,
|
||||
plateScale: plate.alpha / accent.alpha,
|
||||
rect: plate.rect,
|
||||
});
|
||||
}
|
||||
|
||||
// Pixel readback from the real compositing surface, over the plate rect
|
||||
// the painter itself just used.
|
||||
const canvas = document.getElementById('world-overlay-canvas');
|
||||
const ctx = canvas?.getContext('2d', { willReadFrequently: true });
|
||||
const dpr = canvas && canvas.clientWidth ? canvas.width / canvas.clientWidth : 1;
|
||||
for (const entry of byId.values()) {
|
||||
entry.meanPixelAlpha = null;
|
||||
if (!ctx || !entry.rect || !(entry.rect.w > 0) || !(entry.rect.h > 0)) continue;
|
||||
const x = Math.max(0, Math.round(entry.rect.x * dpr));
|
||||
const y = Math.max(0, Math.round(entry.rect.y * dpr));
|
||||
const w = Math.min(canvas.width - x, Math.round(entry.rect.w * dpr));
|
||||
const h = Math.min(canvas.height - y, Math.round(entry.rect.h * dpr));
|
||||
if (!(w > 0) || !(h > 0)) continue;
|
||||
const data = ctx.getImageData(x, y, w, h).data;
|
||||
let total = 0;
|
||||
for (let p = 3; p < data.length; p += 4) total += data[p];
|
||||
entry.meanPixelAlpha = total / (data.length / 4) / 255;
|
||||
}
|
||||
|
||||
const diagnostics = window.__godsEyeView.styleManager.getDetectionDiagnostics();
|
||||
return {
|
||||
plated: [...byId.values()],
|
||||
visibleCount: diagnostics?.visibleCount ?? null,
|
||||
labeledKeys: diagnostics?.labeledKeys ?? null,
|
||||
profile: diagnostics?.profile ?? null,
|
||||
fillEvents: events.filter((event) => event.kind === 'fill').length,
|
||||
};
|
||||
}, [...pose.expectPlated, ...pose.expectAbsent]);
|
||||
|
||||
const labelledIds = (observed.labeledKeys || [])
|
||||
.filter((key) => key.startsWith('flights:'))
|
||||
.map((key) => key.slice('flights:'.length).toUpperCase());
|
||||
console.log(
|
||||
` diagnostics: profile=${observed.profile} visibleCount=${observed.visibleCount}`
|
||||
+ ` labelled=[${labelledIds.join(' ')}] fills=${observed.fillEvents}`,
|
||||
);
|
||||
for (const entry of observed.plated) {
|
||||
console.log(
|
||||
` ${entry.id.padEnd(8)} plateScale=${entry.plateScale.toFixed(4)}`
|
||||
+ ` (plate ${entry.plateAlpha.toFixed(4)} / accent ${entry.accentAlpha.toFixed(4)})`
|
||||
+ ` meanPixelAlpha=${entry.meanPixelAlpha === null ? 'n/a' : entry.meanPixelAlpha.toFixed(4)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 1. The overlay actually rendered something this pose.
|
||||
record(
|
||||
`${pose.name}: the overlay reports live rendered contacts`,
|
||||
Number.isFinite(observed.visibleCount) && observed.visibleCount > 0,
|
||||
`visibleCount=${observed.visibleCount}`,
|
||||
);
|
||||
// 2. The contacts we expect to be labelled are labelled, by id.
|
||||
const missingLabels = pose.expectPlated.filter((id) => !labelledIds.includes(id));
|
||||
record(
|
||||
`${pose.name}: every expected contact reaches the label solve`,
|
||||
missingLabels.length === 0,
|
||||
missingLabels.length
|
||||
? `missing ${missingLabels.join(',')}`
|
||||
: `${pose.expectPlated.length} labelled`,
|
||||
);
|
||||
// 3. The contacts that must NOT be labelled are absent.
|
||||
const unexpected = pose.expectAbsent.filter((id) => labelledIds.includes(id));
|
||||
record(
|
||||
`${pose.name}: below-eye-level contacts stay out of the solve`,
|
||||
unexpected.length === 0,
|
||||
unexpected.length
|
||||
? `unexpectedly labelled ${unexpected.join(',')}`
|
||||
: 'none labelled, as expected',
|
||||
);
|
||||
// 4/5/6. Painted plate scale and pixels, per named contact.
|
||||
if (pose.expectPlated.length > 0) {
|
||||
const platedIds = observed.plated.map((entry) => entry.id);
|
||||
const missingPaint = pose.expectPlated.filter((id) => !platedIds.includes(id));
|
||||
record(
|
||||
`${pose.name}: the callout painter ran for every expected contact`,
|
||||
missingPaint.length === 0,
|
||||
missingPaint.length
|
||||
? `no paint recorded for ${missingPaint.join(',')}`
|
||||
: `${platedIds.length} painted`,
|
||||
);
|
||||
const offScale = observed.plated.filter(
|
||||
(entry) => Math.abs(entry.plateScale - pose.expectScale) > PLATE_SCALE_TOLERANCE,
|
||||
);
|
||||
record(
|
||||
`${pose.name}: painted plate scale is ${pose.expectScale}`,
|
||||
observed.plated.length > 0 && offScale.length === 0,
|
||||
offScale.length
|
||||
? offScale.map((entry) => `${entry.id}=${entry.plateScale.toFixed(3)}`).join(' ')
|
||||
: observed.plated.map((entry) => entry.plateScale.toFixed(3)).join(' '),
|
||||
);
|
||||
const inked = observed.plated.filter((entry) => (entry.meanPixelAlpha ?? 0) > 0.01);
|
||||
record(
|
||||
`${pose.name}: the plate rect carries real pixels on the overlay canvas`,
|
||||
observed.plated.length > 0 && inked.length === observed.plated.length,
|
||||
observed.plated
|
||||
.map((entry) => `${entry.id}=${entry.meanPixelAlpha === null ? 'n/a' : entry.meanPixelAlpha.toFixed(3)}`)
|
||||
.join(' '),
|
||||
);
|
||||
}
|
||||
|
||||
perPose.push({ pose: pose.name, ...observed, labelledIds });
|
||||
const shot = path.join(SHOT_DIR, `${TAG}-${pose.name}.png`);
|
||||
await page.screenshot({ path: shot });
|
||||
console.log(` shot: ${shot}`);
|
||||
}
|
||||
|
||||
// Cross-pose pixel comparison: the same theme, the same canvas, the same
|
||||
// painter — only the backdrop differs, so sky plates must be lighter.
|
||||
const skyInk = perPose.find((entry) => entry.pose === 'level')?.plated || [];
|
||||
const groundInk = perPose.find((entry) => entry.pose === 'lifted')?.plated || [];
|
||||
const meanOf = (rows) => (rows.length
|
||||
? rows.reduce((sum, entry) => sum + (entry.meanPixelAlpha ?? 0), 0) / rows.length
|
||||
: null);
|
||||
const skyMean = meanOf(skyInk);
|
||||
const groundMean = meanOf(groundInk);
|
||||
record(
|
||||
'sky-backed plates are measurably lighter on the canvas than ground-backed ones',
|
||||
Number.isFinite(skyMean) && Number.isFinite(groundMean) && skyMean < groundMean,
|
||||
`sky mean alpha ${skyMean === null ? 'n/a' : skyMean.toFixed(4)}`
|
||||
+ ` vs ground ${groundMean === null ? 'n/a' : groundMean.toFixed(4)}`,
|
||||
);
|
||||
|
||||
record(
|
||||
'no page errors while the overlay ran at ground level',
|
||||
consoleErrors.length === 0,
|
||||
consoleErrors.slice(0, 3).join(' | ') || 'clean',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(SHOT_DIR, `${TAG}-rendered.json`),
|
||||
`${JSON.stringify({
|
||||
tag: TAG,
|
||||
teeth: TEETH,
|
||||
backend,
|
||||
renderer,
|
||||
cockpitHeight: COCKPIT_H,
|
||||
poses: perPose,
|
||||
}, null, 2)}\n`,
|
||||
);
|
||||
|
||||
const failed = results.filter((entry) => !entry.ok);
|
||||
console.log(`\n ${results.length - failed.length}/${results.length} checks passed\n`);
|
||||
if (TEETH) {
|
||||
// Under --teeth the run is EXPECTED to fail; a green run would mean the
|
||||
// assertions do not depend on the painter and the harness is decorative.
|
||||
const ok = failed.length > 0;
|
||||
console.log(ok
|
||||
? ` \x1b[32mTEETH OK\x1b[0m — ${failed.length} check(s) went red with the painter stubbed:\n ${failed.map((entry) => entry.name).join('\n ')}\n`
|
||||
: ' \x1b[31mTEETH FAILED\x1b[0m — every check passed with the painter stubbed; the harness proves nothing.\n');
|
||||
process.exit(ok ? 0 : 1);
|
||||
}
|
||||
process.exit(failed.length === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,582 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-enrich-ambient.mjs — headless proof for AMBIENT fleet type enrichment
|
||||
* (fleet-enrichment widening of skylight Task 14).
|
||||
*
|
||||
* Field data (2026-07-02): OpenSky's live category field is 0/"no info" for
|
||||
* ~94% of planes, so ambient classification defaulted nearly the whole fleet
|
||||
* to the airliner silhouette. This harness reproduces exactly that case —
|
||||
* 12 synthetic planes, ALL category 0 — and proves that the per-poll
|
||||
* on-screen enrichment sweep (`_sweepAmbientEnrichment` in flights.js) gives
|
||||
* them real types AMBIENTLY (nothing is ever tracked, 3D stays off), while
|
||||
* honoring the politeness bounds.
|
||||
*
|
||||
* Fetch shim (installed before app code, same pattern as qa-sprites-b5.mjs):
|
||||
* - /api/opensky → 12 straight-flying planes, category 0
|
||||
* - /api/adsbdb/type/:hex → varied REAL type codes (C172, B744, DH8D, H60,
|
||||
* F16, GLID, B77W, A320, B738, PC12, R44) + one
|
||||
* found:false miss; responses are HELD until the
|
||||
* harness releases them (deterministic baseline),
|
||||
* then served with 400 ms latency; every request
|
||||
* start/inflight count is logged for the bounds
|
||||
* assertions.
|
||||
*
|
||||
* Machine assertions:
|
||||
* E1 ingest : all 12 synthetic planes in the flights layer
|
||||
* E2 baseline : BEFORE any enrichment answer, all 12 billboards carry the
|
||||
* default airliner glyph at scale 1 (the real-world problem)
|
||||
* E3 requests : the sweep requested all 12 hexes, each exactly once
|
||||
* E4 diversity : ≥5 distinct glyph data-URIs displayed ambiently after
|
||||
* enrichment (expected: 8)
|
||||
* E5 glyphs : every billboard's image === aircraftIcon(classify(typeCode))
|
||||
* E6 scales : every billboard's scale === CLASS_SCALE_2D[klass]
|
||||
* E7 bounds : ≤4 concurrent requests, ≥185 ms between dispatches (≤5/s
|
||||
* drip), total ≤300 (budget ceiling)
|
||||
* E8 next poll : another poll neither reverts glyphs nor re-requests hexes
|
||||
* E10 exhaust : the ROLLING ambient budget exhausts — new on-screen planes
|
||||
* stop being requested once the bucket is empty (shrunk via
|
||||
* the __GEV_ENRICH_AMBIENT_QA seam; a real 5-min window can't
|
||||
* be waited out headlessly)
|
||||
* E11 resume : after a refill window passes, ambient enrichment RESUMES
|
||||
* for the still-unrequested planes (the 2026-07-03 field bug:
|
||||
* the old one-shot session cap never refilled, so hours-long
|
||||
* sessions showed airliner monoculture in every new region)
|
||||
* E12 clean : no console errors
|
||||
*
|
||||
* Visual output (qa-shots/enrich-ambient/, untracked): before/after top-down
|
||||
* spreads of the row.
|
||||
*
|
||||
* Run: node scripts/qa-enrich-ambient.mjs --url http://localhost:4304
|
||||
* Exits non-zero if any assertion fails. Never commits anything.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { classifyAircraft, CLASS_SCALE_2D } from '../src/data/aircraftClass.js';
|
||||
import { aircraftIcon } from '../src/data/aircraftIcons.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Args (same shape as qa-sprites-b5.mjs)
|
||||
// ---------------------------------------------------------------------------
|
||||
const argv = process.argv.slice(2);
|
||||
const getFlag = (name) => argv.includes(name);
|
||||
const getOpt = (name, dflt) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const SHOT_DIR = path.resolve('qa-shots/enrich-ambient');
|
||||
|
||||
// Client-side bounds under test (flights.js constants):
|
||||
const MAX_INFLIGHT = 4; // ENRICH_MAX_INFLIGHT
|
||||
const DISPATCH_GAP_MS = 200; // ENRICH_DISPATCH_GAP_MS (≤5 req/s)
|
||||
const GAP_TOLERANCE_MS = 15; // clock-quantization allowance on the gap check
|
||||
const SESSION_CAP = 300; // ENRICH_AMBIENT_BUDGET_CEIL (rolling-bucket ceiling)
|
||||
|
||||
// Rolling-budget QA seam (window.__GEV_ENRICH_AMBIENT_QA in flights.js):
|
||||
// shrunk so the exhaust→refill cycle is observable headlessly. windowMs starts
|
||||
// effectively-infinite (1 h) so E10 exhausts deterministically (no refill can
|
||||
// land mid-phase); E11 then shortens it in-page so refill windows have
|
||||
// "passed" and the bucket refills (clamped at ceil). ceil=20 leaves 8 tokens
|
||||
// after the 12 baseline planes — E10's batch exhausts those 8 and stalls.
|
||||
const BUDGET_QA = { ceil: 20, refillTokens: 6, windowMs: 3600000 };
|
||||
const RESUME_WINDOW_MS = 1000; // E11 swaps windowMs to this to unlock refills
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
|
||||
try { if (fs.existsSync(candidate)) return candidate; } catch { /* skip */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Synthetic fleet: one east-west row near Austin, ALL category 0 ("no info" —
|
||||
// the 94% real-world case). adsbdb type answers cover 8 distinct classes;
|
||||
// aa000c answers found:false (negative-cache case → stays airliner).
|
||||
// ---------------------------------------------------------------------------
|
||||
const ROW_LAT = 30.30;
|
||||
const ROW_LON0 = -97.82;
|
||||
const ROW_STEP_DEG = 0.012; // ~1.15 km — 12 planes span ~12.7 km, fits a 20 km-up frame
|
||||
|
||||
/** hex → adsbdb icao_type (null = adsbdb miss / negative cache). */
|
||||
const TYPES = {
|
||||
aa0001: 'C172', // light
|
||||
aa0002: 'B744', // quadjet
|
||||
aa0003: 'DH8D', // turboprop
|
||||
aa0004: 'H60', // helicopter
|
||||
aa0005: 'F16', // fastjet
|
||||
aa0006: 'GLID', // glider
|
||||
aa0007: 'B77W', // widebody
|
||||
aa0008: 'A320', // airliner
|
||||
aa0009: 'B738', // airliner (known code outside the special sets)
|
||||
aa000a: 'PC12', // turboprop
|
||||
aa000b: 'R44', // helicopter
|
||||
aa000c: null, // adsbdb 404 → found:false → stays airliner, never re-asked
|
||||
};
|
||||
|
||||
const SPEC = {
|
||||
timeOffsetSec: 0,
|
||||
responseDelayMs: 400, // adsbdb latency so concurrency is actually observable
|
||||
budgetQa: BUDGET_QA,
|
||||
types: TYPES,
|
||||
planes: Object.keys(TYPES).map((hex, i) => ({
|
||||
icao: hex,
|
||||
callsign: `AMB${String(i + 1).padStart(3, '0')}`,
|
||||
lon0: ROW_LON0 + i * ROW_STEP_DEG,
|
||||
lat0: ROW_LAT,
|
||||
courseDeg: 0,
|
||||
speedMps: 70,
|
||||
altM: 2400 + i * 40,
|
||||
})),
|
||||
};
|
||||
|
||||
// Node-side expectations (same classifier inputs the layer sees).
|
||||
const expected = new Map(Object.entries(TYPES).map(([hex, tc]) => [
|
||||
hex, classifyAircraft(tc ? { typeCode: tc } : { category: 0 }),
|
||||
]));
|
||||
const AIRLINER_ICON = aircraftIcon(classifyAircraft({ category: 0 })); // pre-enrichment default
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
async function main() {
|
||||
console.log(`\nAmbient fleet type-enrichment QA (fleet-enrichment widening)`);
|
||||
console.log(` App URL : ${APP_URL}\n`);
|
||||
console.log(` Expected classes: ${[...expected.entries()].map(([k, v]) => `${k}:${v}`).join(' ')}\n`);
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL, { method: 'GET' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
||||
|
||||
const chromeExecutable = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
],
|
||||
});
|
||||
|
||||
const consoleErrors = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
const text = msg.text();
|
||||
if (!/Failed to load resource.*404/i.test(text)) consoleErrors.push(text);
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (err) => consoleErrors.push(`pageerror: ${err.message}`));
|
||||
|
||||
// ---- Fetch shim: synthetic fleet + instrumented, holdable adsbdb -------
|
||||
await page.evaluateOnNewDocument((spec) => {
|
||||
window.__ENR = spec;
|
||||
window.__ENR.epochMs = Date.now();
|
||||
// Rolling-budget QA seam — must exist BEFORE flights.js initializes so
|
||||
// the shrunk knobs are read from the very first sweep (see BUDGET_QA).
|
||||
window.__GEV_ENRICH_AMBIENT_QA = { ...spec.budgetQa };
|
||||
// Request log: starts (hex + performance.now), live/max concurrency,
|
||||
// hold gate (responses park until the harness releases them so the
|
||||
// pre-enrichment baseline is deterministic).
|
||||
window.__ENRICH_LOG = { starts: [], inflight: 0, maxInflight: 0, held: true, holds: [] };
|
||||
window.__ENRICH_RELEASE = () => {
|
||||
window.__ENRICH_LOG.held = false;
|
||||
window.__ENRICH_LOG.holds.splice(0).forEach((fn) => fn());
|
||||
};
|
||||
|
||||
// Straight line from (lon0, lat0) along courseDeg at speedMps.
|
||||
window.__ENR.stateAt = (p, tSec) => {
|
||||
const cRad = (p.courseDeg * Math.PI) / 180;
|
||||
const dist = p.speedMps * tSec;
|
||||
const east = dist * Math.sin(cRad);
|
||||
const north = dist * Math.cos(cRad);
|
||||
const lat = p.lat0 + north / 111320;
|
||||
const lon = p.lon0 + east / (111320 * Math.cos((p.lat0 * Math.PI) / 180));
|
||||
return { lon, lat, course: p.courseDeg, speedMps: p.speedMps };
|
||||
};
|
||||
|
||||
const realFetch = window.fetch.bind(window);
|
||||
const jsonResponse = (obj) => new Response(JSON.stringify(obj), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
window.fetch = (input, init) => {
|
||||
const url = typeof input === 'string' ? input : (input && input.url) || '';
|
||||
const S = window.__ENR;
|
||||
const nowSec = Date.now() / 1000 + (S.timeOffsetSec || 0);
|
||||
const tRel = nowSec - S.epochMs / 1000;
|
||||
|
||||
if (url.includes('/api/adsbdb/type/')) {
|
||||
const hex = url.split('/').pop().split('?')[0].toLowerCase();
|
||||
const L = window.__ENRICH_LOG;
|
||||
L.starts.push({ hex, t: performance.now() });
|
||||
L.inflight += 1;
|
||||
L.maxInflight = Math.max(L.maxInflight, L.inflight);
|
||||
const tc = S.types[hex];
|
||||
const body = tc
|
||||
? { found: true, typeCode: tc, typeName: `Synthetic ${tc}`, registration: `N${hex.slice(-3).toUpperCase()}` }
|
||||
: { found: false };
|
||||
return new Promise((resolve) => {
|
||||
const finish = () => setTimeout(() => { L.inflight -= 1; resolve(jsonResponse(body)); }, S.responseDelayMs);
|
||||
if (L.held) L.holds.push(finish); else finish();
|
||||
});
|
||||
}
|
||||
if (url.includes('/api/adsbdb/')) return Promise.resolve(jsonResponse({ found: false }));
|
||||
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] }));
|
||||
if (url.includes('/api/adsblol/trace')) {
|
||||
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] }));
|
||||
}
|
||||
if (url.includes('/api/adsblol/mil')) {
|
||||
return Promise.resolve(jsonResponse({ msg: 'No error', now: Date.now(), ac: [] }));
|
||||
}
|
||||
if (url.includes('/api/opensky')) {
|
||||
const states = S.planes.map((f) => {
|
||||
const s = S.stateAt(f, tRel);
|
||||
return [
|
||||
f.icao, f.callsign, 'Synthetica',
|
||||
Math.floor(nowSec), Math.floor(nowSec),
|
||||
s.lon, s.lat, f.altM,
|
||||
false, // on_ground
|
||||
s.speedMps, s.course,
|
||||
0, null, null, null, false, 0,
|
||||
0, // state[17] — category 0 = "no info" (the 94% case)
|
||||
];
|
||||
});
|
||||
return Promise.resolve(jsonResponse({ time: Math.floor(nowSec), states }));
|
||||
}
|
||||
return realFetch(input, init);
|
||||
};
|
||||
}, SPEC);
|
||||
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
|
||||
{ timeout: 60000, polling: 200 }
|
||||
);
|
||||
console.log(' App globals ready.');
|
||||
|
||||
// ---- In-page billboard probe (same walk as qa-sprites-b5.mjs) ----------
|
||||
await page.evaluate(() => {
|
||||
window.__collectBillboards = function () {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let p;
|
||||
try { p = coll.get(i); } catch { continue; }
|
||||
if (!p) continue;
|
||||
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
|
||||
if (p.image !== undefined && p.alignedAxis !== undefined) {
|
||||
out.push({ id: p.id, image: p.image, scale: p.scale, show: p.show });
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(v.scene.primitives);
|
||||
return out;
|
||||
};
|
||||
});
|
||||
|
||||
// ---- Frame the row FIRST (top-down, all 12 on-screen), then prime ------
|
||||
// The ambient sweep runs inside update(), so the camera must already be
|
||||
// framing the fleet when the layer polls — otherwise the frustum test
|
||||
// (correctly) skips off-screen planes.
|
||||
const centerLon = ROW_LON0 + ((SPEC.planes.length - 1) / 2) * ROW_STEP_DEG;
|
||||
await page.evaluate(({ lat, lon, height }) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
v.camera.cancelFlight(); // the boot fly-to-Austin otherwise stomps setView
|
||||
const C3 = v.camera.position.constructor;
|
||||
v.camera.setView({
|
||||
destination: C3.fromDegrees(lon, lat, height),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
}, { lat: ROW_LAT, lon: centerLon, height: 20000 });
|
||||
|
||||
console.log('Priming straight-flight history through the render delay (30 s)...');
|
||||
const primed = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const v = window.__godsEyeView.viewer;
|
||||
window.__ENR.timeOffsetSec = -32;
|
||||
await dm.setEnabled('flights', true);
|
||||
const fl = dm.layers.get('flights').module;
|
||||
for (const off of [-24, -16, -8, 0]) {
|
||||
window.__ENR.timeOffsetSec = off;
|
||||
await fl.update(v);
|
||||
}
|
||||
window.__ENR.timeOffsetSec = 0;
|
||||
window.__ENR_DRIVER = setInterval(() => { fl.update(v); }, 15000);
|
||||
return { count: fl.getStats().count, starts: window.__ENRICH_LOG.starts.length };
|
||||
});
|
||||
console.log(` flights count=${primed.count} | adsbdb requests started (held): ${primed.starts}`);
|
||||
record('E1 ingest: all synthetic category-0 planes in the flights layer',
|
||||
primed.count === SPEC.planes.length, `count=${primed.count}/${SPEC.planes.length}`);
|
||||
if (primed.count === 0) { finish(); return; }
|
||||
|
||||
// ========================================================================
|
||||
// E2 — deterministic pre-enrichment baseline (responses still held)
|
||||
// ========================================================================
|
||||
console.log('\nE2 — pre-enrichment baseline (adsbdb responses held)');
|
||||
const before = await page.evaluate(() => window.__collectBillboards());
|
||||
const beforeById = new Map(before.map((b) => [b.id, b]));
|
||||
const baselineBad = [];
|
||||
for (const hex of Object.keys(TYPES)) {
|
||||
const bb = beforeById.get(hex);
|
||||
if (!bb) baselineBad.push(`${hex}:missing`);
|
||||
else if (bb.image !== AIRLINER_ICON) baselineBad.push(`${hex}:not-default-glyph`);
|
||||
else if (Math.abs(bb.scale - (CLASS_SCALE_2D.airliner || 1)) > 1e-9) baselineBad.push(`${hex}:scale=${bb.scale}`);
|
||||
}
|
||||
record('E2 baseline: all 12 category-0 planes default to the airliner glyph',
|
||||
baselineBad.length === 0,
|
||||
baselineBad.length ? baselineBad.join(' ') : 'all 12 identical airliner silhouettes (the field-data problem)');
|
||||
await sleep(1200); // let a fleet tick land rotations before the "before" shot
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'enrich-before-monoculture.png') });
|
||||
console.log(' saved enrich-before-monoculture.png (12 identical airliner glyphs)');
|
||||
|
||||
// ========================================================================
|
||||
// Release adsbdb + wait for the drip to finish, then for glyph swaps
|
||||
// ========================================================================
|
||||
console.log('\nReleasing adsbdb responses; waiting for the bounded drip to drain...');
|
||||
await page.evaluate(() => window.__ENRICH_RELEASE());
|
||||
const drained = await page.waitForFunction(
|
||||
(want) => window.__ENRICH_LOG.starts.length >= want && window.__ENRICH_LOG.inflight === 0,
|
||||
{ timeout: 30000, polling: 100 }, SPEC.planes.length
|
||||
).then(() => true).catch(() => false);
|
||||
if (!drained) console.log(' \x1b[33mdrip did not fully drain within 30 s\x1b[0m');
|
||||
// Give the enrichment callbacks a beat to apply billboard swaps.
|
||||
await page.waitForFunction(() => {
|
||||
const images = new Set(window.__collectBillboards().map((b) => b.image));
|
||||
return images.size >= 5;
|
||||
}, { timeout: 10000, polling: 200 }).catch(() => { /* asserted below */ });
|
||||
|
||||
// ========================================================================
|
||||
// E3–E6 — requests + ambient glyph/scale swaps (nothing tracked, 3D off)
|
||||
// ========================================================================
|
||||
console.log('\nE3–E6 — ambient enrichment results');
|
||||
const log1 = await page.evaluate(() => ({
|
||||
starts: window.__ENRICH_LOG.starts.map((s) => ({ hex: s.hex, t: s.t })),
|
||||
maxInflight: window.__ENRICH_LOG.maxInflight,
|
||||
}));
|
||||
const startHexes = log1.starts.map((s) => s.hex);
|
||||
const uniqueHexes = new Set(startHexes);
|
||||
const allRequested = Object.keys(TYPES).every((h) => uniqueHexes.has(h));
|
||||
record('E3 requests: sweep requested all 12 hexes, each exactly once',
|
||||
allRequested && startHexes.length === Object.keys(TYPES).length,
|
||||
`${startHexes.length} requests, ${uniqueHexes.size} unique`);
|
||||
|
||||
const after = await page.evaluate(() => window.__collectBillboards());
|
||||
const afterById = new Map(after.map((b) => [b.id, b]));
|
||||
const syntheticImages = new Set(
|
||||
Object.keys(TYPES).map((hex) => afterById.get(hex)?.image).filter(Boolean)
|
||||
);
|
||||
record('E4 diversity: ≥5 distinct glyph data-URIs displayed ambiently',
|
||||
syntheticImages.size >= 5, `${syntheticImages.size} distinct glyphs (expected 8)`);
|
||||
|
||||
const glyphBad = [];
|
||||
const scaleBad = [];
|
||||
for (const [hex, klass] of expected) {
|
||||
const bb = afterById.get(hex);
|
||||
const wantImg = aircraftIcon(klass);
|
||||
const wantScale = CLASS_SCALE_2D[klass] || 1;
|
||||
if (!bb || bb.image !== wantImg) glyphBad.push(`${hex}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||
if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${hex}: ${bb.scale} != ${wantScale}`);
|
||||
}
|
||||
record('E5 glyphs: every billboard matches aircraftIcon(classify(typeCode))',
|
||||
glyphBad.length === 0, glyphBad.length ? glyphBad.join(' ') : `${expected.size} matched (incl. the found:false miss staying airliner)`);
|
||||
record('E6 scales: every billboard has its per-class CLASS_SCALE_2D',
|
||||
scaleBad.length === 0, scaleBad.length ? scaleBad.join(' ') : 'all scales exact (composes with scaleByDistance)');
|
||||
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'enrich-after-diverse.png') });
|
||||
console.log(' saved enrich-after-diverse.png (type-diverse row, still untracked)');
|
||||
|
||||
// ========================================================================
|
||||
// E7 — politeness bounds (concurrency, drip rate, session cap)
|
||||
// ========================================================================
|
||||
console.log('\nE7 — client-side bounds');
|
||||
const gaps = [];
|
||||
for (let i = 1; i < log1.starts.length; i++) gaps.push(log1.starts[i].t - log1.starts[i - 1].t);
|
||||
const minGap = gaps.length ? Math.min(...gaps) : Infinity;
|
||||
// Max dispatches in any sliding 1 s window (reported for context).
|
||||
let maxPerSec = 0;
|
||||
for (let i = 0; i < log1.starts.length; i++) {
|
||||
let n = 0;
|
||||
for (let j = i; j < log1.starts.length && log1.starts[j].t - log1.starts[i].t < 1000; j++) n++;
|
||||
maxPerSec = Math.max(maxPerSec, n);
|
||||
}
|
||||
const boundsOk = log1.maxInflight <= MAX_INFLIGHT
|
||||
&& minGap >= (DISPATCH_GAP_MS - GAP_TOLERANCE_MS)
|
||||
&& log1.starts.length <= SESSION_CAP;
|
||||
record(`E7 bounds: ≤${MAX_INFLIGHT} concurrent, ≥${DISPATCH_GAP_MS}ms drip gap, ≤${SESSION_CAP}/session`,
|
||||
boundsOk,
|
||||
`maxConcurrent=${log1.maxInflight} minGap=${Number.isFinite(minGap) ? minGap.toFixed(1) : 'n/a'}ms `
|
||||
+ `maxIn1s=${maxPerSec} total=${log1.starts.length}`);
|
||||
|
||||
// ========================================================================
|
||||
// E8 — next poll: klass persists (poll re-derive keeps enriched types) and
|
||||
// the session dedupe never re-requests an answered/negative hex
|
||||
// ========================================================================
|
||||
console.log('\nE8 — next poll after enrichment');
|
||||
await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const v = window.__godsEyeView.viewer;
|
||||
await dm.layers.get('flights').module.update(v);
|
||||
});
|
||||
await sleep(500);
|
||||
const afterPoll = await page.evaluate(() => ({
|
||||
billboards: window.__collectBillboards(),
|
||||
starts: window.__ENRICH_LOG.starts.length,
|
||||
}));
|
||||
const afterPollById = new Map(afterPoll.billboards.map((b) => [b.id, b]));
|
||||
const pollBad = [];
|
||||
for (const [hex, klass] of expected) {
|
||||
const bb = afterPollById.get(hex);
|
||||
if (!bb || bb.image !== aircraftIcon(klass)) pollBad.push(`${hex}(${klass})${bb ? ':reverted' : ':missing'}`);
|
||||
else if (Math.abs(bb.scale - (CLASS_SCALE_2D[klass] || 1)) > 1e-9) pollBad.push(`${hex}:scale-reverted`);
|
||||
}
|
||||
const noReRequests = afterPoll.starts === Object.keys(TYPES).length;
|
||||
record('E8 next poll: enriched glyphs persist and no hex is re-requested',
|
||||
pollBad.length === 0 && noReRequests,
|
||||
pollBad.length ? pollBad.join(' ') : `glyphs stable, requests still ${afterPoll.starts}`);
|
||||
|
||||
// ========================================================================
|
||||
// E10 — rolling-budget exhaustion (2026-07-03 field bug, red/green): the
|
||||
// old ONE-SHOT session cap burned out and never refilled, so every new
|
||||
// region after the first polls showed airliner monoculture. With the seam
|
||||
// ceil=20 and 12 tokens spent above, only 8 remain — a batch of 26 fresh
|
||||
// on-screen planes must stall at exactly 8 new requests, and further polls
|
||||
// must enqueue NOTHING while the bucket is empty (windowMs is 1 h here, so
|
||||
// no refill can land mid-phase). Pre-fix code fails this phase: there is
|
||||
// no rolling bucket to exhaust (the seam is unread and the 300 cap simply
|
||||
// admits the whole batch).
|
||||
// ========================================================================
|
||||
console.log('\nE10 — rolling budget exhausts (batch of 26 new planes, 8 tokens left)');
|
||||
const BATCH_COUNT = 26;
|
||||
const batchHexes = Array.from({ length: BATCH_COUNT }, (_, i) => `ab00${(i + 1).toString(16).padStart(2, '0')}`);
|
||||
const startsBeforeBatch = await page.evaluate(() => window.__ENRICH_LOG.starts.length);
|
||||
await page.evaluate(async ({ hexes, rowLat, rowLon0, stepDeg }) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const v = window.__godsEyeView.viewer;
|
||||
// Two fresh rows just north of the original one — still inside the
|
||||
// top-down 20 km frame, so the sweep's frustum test keeps them.
|
||||
// STATIONARY (speed 0): moving planes drift north out of the frustum at
|
||||
// run-time-dependent rates, which made this phase timing-sensitive.
|
||||
hexes.forEach((hex, i) => {
|
||||
window.__ENR.planes.push({
|
||||
icao: hex,
|
||||
callsign: `BGT${String(i + 1).padStart(3, '0')}`,
|
||||
lon0: rowLon0 + (i % 13) * stepDeg,
|
||||
lat0: rowLat + (i < 13 ? 0.02 : 0.04),
|
||||
courseDeg: 0,
|
||||
speedMps: 0,
|
||||
altM: 2600 + i * 15,
|
||||
});
|
||||
});
|
||||
const fl = dm.layers.get('flights').module;
|
||||
await fl.update(v); // sweep enqueues the last 8 tokens
|
||||
await fl.update(v); // bucket empty — must enqueue nothing more
|
||||
}, { hexes: batchHexes, rowLat: ROW_LAT, rowLon0: ROW_LON0, stepDeg: ROW_STEP_DEG });
|
||||
// Wait for the drip to drain whatever was enqueued, then settle.
|
||||
await page.waitForFunction(
|
||||
(min) => window.__ENRICH_LOG.starts.length >= min && window.__ENRICH_LOG.inflight === 0,
|
||||
{ timeout: 20000, polling: 100 }, startsBeforeBatch + (BUDGET_QA.ceil - startsBeforeBatch)
|
||||
).catch(() => { /* asserted below */ });
|
||||
await sleep(1500); // any illegal post-exhaustion enqueue would dispatch within ~200 ms
|
||||
// One more poll while exhausted — still nothing new.
|
||||
await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer);
|
||||
});
|
||||
await sleep(1000);
|
||||
const exhausted = await page.evaluate(() => ({
|
||||
starts: window.__ENRICH_LOG.starts.length,
|
||||
inflight: window.__ENRICH_LOG.inflight,
|
||||
}));
|
||||
record(`E10 exhaust: requests stall at the budget ceiling (${BUDGET_QA.ceil}) with unrequested planes on-screen`,
|
||||
exhausted.starts === BUDGET_QA.ceil && exhausted.inflight === 0,
|
||||
`starts=${exhausted.starts} (want ${BUDGET_QA.ceil}: ${startsBeforeBatch} baseline + ${BUDGET_QA.ceil - startsBeforeBatch} tokens; `
|
||||
+ `${BATCH_COUNT - (BUDGET_QA.ceil - startsBeforeBatch)} planes left waiting) inflight=${exhausted.inflight}`);
|
||||
|
||||
// ========================================================================
|
||||
// E11 — refill window passes → enrichment RESUMES. Shorten windowMs so the
|
||||
// time since the bucket's refill anchor now spans whole windows; the next
|
||||
// sweep refills (clamped at ceil) and the waiting planes get requested.
|
||||
// ========================================================================
|
||||
console.log('\nE11 — refill window passes; ambient enrichment resumes');
|
||||
await page.evaluate((winMs) => { window.__GEV_ENRICH_AMBIENT_QA.windowMs = winMs; }, RESUME_WINDOW_MS);
|
||||
await sleep(RESUME_WINDOW_MS + 200); // a full (shortened) refill window elapses
|
||||
await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer);
|
||||
});
|
||||
const expectedTotal = startsBeforeBatch + BATCH_COUNT; // every batch plane eventually requested
|
||||
const resumed = await page.waitForFunction(
|
||||
(want) => window.__ENRICH_LOG.starts.length >= want && window.__ENRICH_LOG.inflight === 0,
|
||||
{ timeout: 25000, polling: 100 }, expectedTotal
|
||||
).then(() => true).catch(() => false);
|
||||
const resumeLog = await page.evaluate(() => window.__ENRICH_LOG.starts.map((s) => s.hex));
|
||||
const batchRequested = batchHexes.filter((h) => resumeLog.includes(h)).length;
|
||||
const dupes = resumeLog.length !== new Set(resumeLog).size;
|
||||
record('E11 resume: enrichment resumes after a refill window (all waiting planes requested, once each)',
|
||||
resumed && batchRequested === BATCH_COUNT && resumeLog.length === expectedTotal && !dupes,
|
||||
`starts=${resumeLog.length} (want ${expectedTotal}) batchRequested=${batchRequested}/${BATCH_COUNT} dupes=${dupes}`);
|
||||
|
||||
record('E12: no console errors during QA run', consoleErrors.length === 0,
|
||||
consoleErrors.length ? `${consoleErrors.length}: ${consoleErrors.slice(0, 3).join(' | ')}` : 'clean');
|
||||
|
||||
finish();
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
function finish() {
|
||||
const failed = results.filter((r) => r.ok === false);
|
||||
const passed = results.filter((r) => r.ok === true);
|
||||
console.log(`\n${'─'.repeat(60)}`);
|
||||
console.log(` RESULT: ${passed.length} passed, ${failed.length} failed`);
|
||||
console.log(`${'─'.repeat(60)}\n`);
|
||||
process.exitCode = failed.length > 0 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('\n\x1b[31mQA harness error:\x1b[0m', err && err.stack ? err.stack : err);
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -0,0 +1,570 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-failstate-b10.mjs
|
||||
*
|
||||
* Targeted proof for Batch 10 — the "silent-failure" theme (finding H3 + H9).
|
||||
* Drives the REAL app in headless Chromium and asserts that dead/failed feeds
|
||||
* are SURFACED to the user instead of a healthy-looking empty state.
|
||||
*
|
||||
* Node-side request interception (page.setRequestInterception) fabricates the
|
||||
* upstream failures so the run is deterministic and never depends on live
|
||||
* AISStream / CelesTrak availability:
|
||||
*
|
||||
* (i) AIS invalid key — /api/ais-live is answered with
|
||||
* {rows:[],status:'error',error:'invalid key'}. Assert the layer's
|
||||
* getStats().error is set (feed down) — NOT a clean 'just now · 0'.
|
||||
*
|
||||
* (ii) CelesTrak outage — first serve a good catalog so there's something to
|
||||
* preserve, then flip ALL /api/celestrak/* groups to HTTP 503. Toggle
|
||||
* satellites off/on. Assert the catalog is NOT wiped to 0 AND
|
||||
* getStats().error is set.
|
||||
*
|
||||
* (iii) DETECT with no data — enable panoptic detection with NO data layers.
|
||||
* Assert the shared #world-overlay-canvas is non-empty (mode banner drawn),
|
||||
* so DETECT reads "armed, nothing in view" instead of a blank canvas.
|
||||
* The banner is developer telemetry, hidden from users by default, so this
|
||||
* harness loads the app with ?detectDebug=1 — without it the pixel check
|
||||
* would pass on scanlines alone and no longer assert what it names.
|
||||
* (Absent-by-default is pinned in src/data/detectionHost.test.mjs.)
|
||||
*
|
||||
* Run: node scripts/qa-failstate-b10.mjs --url http://localhost:4300
|
||||
* Exits non-zero if any assertion fails. DOES NOT COMMIT anything.
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, dflt) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
const getFlag = (name) => argv.includes(name);
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4300');
|
||||
|
||||
/**
|
||||
* Check (iii) asserts the detection MODE BANNER is drawn on a zero-object frame.
|
||||
* That banner is developer telemetry and is hidden from users by default, so the
|
||||
* harness must ask for it explicitly — otherwise the pixel check silently passes
|
||||
* on scanlines alone and the assertion name stops meaning anything.
|
||||
*/
|
||||
function withDetectDebug(url) {
|
||||
const parsed = new URL(url);
|
||||
parsed.searchParams.set('detectDebug', '1');
|
||||
return parsed.toString();
|
||||
}
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const ARTIFACT_DIR = getOpt('--artifact-dir', null);
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
|
||||
try {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
const tag = ok === null ? '\x1b[33mINCONCLUSIVE\x1b[0m' : ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function readLayerControl(page, layerId, expectedLabel) {
|
||||
await page.waitForFunction(
|
||||
(id, label) => {
|
||||
const button = document.querySelector(`[data-layer-id="${id}"] .data-toggle-btn`);
|
||||
return button?.textContent?.trim() === label;
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
layerId,
|
||||
expectedLabel,
|
||||
);
|
||||
return page.evaluate((id) => {
|
||||
const row = document.querySelector(`[data-layer-id="${id}"]`);
|
||||
const button = row?.querySelector('.data-toggle-btn');
|
||||
const meta = row?.querySelector('.data-toggle-meta');
|
||||
return {
|
||||
label: button?.textContent?.trim() || '',
|
||||
feedState: button?.dataset?.feedState || '',
|
||||
ariaLabel: button?.getAttribute('aria-label') || '',
|
||||
meta: meta?.textContent?.trim() || '',
|
||||
};
|
||||
}, layerId);
|
||||
}
|
||||
|
||||
async function captureLayerControl(page, layerId, filename) {
|
||||
if (!ARTIFACT_DIR) return;
|
||||
fs.mkdirSync(ARTIFACT_DIR, { recursive: true });
|
||||
const dataPanel = await page.$('#data-panel');
|
||||
const panelCollapsed = await dataPanel?.evaluate((element) => element.classList.contains('collapsed'));
|
||||
if (panelCollapsed) await page.click('#data-panel .panel-collapse-btn');
|
||||
const row = await page.$(`[data-layer-id="${layerId}"]`);
|
||||
if (!row) throw new Error(`Layer control not found for screenshot: ${layerId}`);
|
||||
await row.evaluate((element) => element.scrollIntoView({ block: 'center' }));
|
||||
await page.waitForFunction((id) => {
|
||||
const element = document.querySelector(`[data-layer-id="${id}"]`);
|
||||
const rect = element?.getBoundingClientRect();
|
||||
return !!rect && rect.width > 0 && rect.height > 0;
|
||||
}, { timeout: 5_000 }, layerId);
|
||||
await sleep(100);
|
||||
await row.screenshot({ path: path.join(ARTIFACT_DIR, filename) });
|
||||
}
|
||||
|
||||
// A minimal but valid TLE for one satellite (ISS), so the "good catalog" pass
|
||||
// builds a non-zero catalog we can later prove is preserved across the outage.
|
||||
// parseTLE in satellites.js expects `NAME\n1 ...\n2 ...` triplets.
|
||||
const GOOD_TLE = [
|
||||
'ISS (ZARYA)',
|
||||
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927',
|
||||
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537',
|
||||
'NOAA 19',
|
||||
'1 33591U 09005A 08264.51782528 -.00000045 00000-0 35116-4 0 9999',
|
||||
'2 33591 99.1949 123.4567 0013000 200.0000 160.0000 14.12345678123456',
|
||||
].join('\n');
|
||||
|
||||
async function main() {
|
||||
console.log('\nSilent-Failure Proof (Batch 10 — H3 + H9)');
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Mode : ${HEADFUL ? 'headful' : 'headless'}\n`);
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL, { method: 'GET' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const chromeExecutable = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
],
|
||||
});
|
||||
|
||||
let exitCode = 0;
|
||||
|
||||
// Node-side controllable interception mode, flipped mid-run.
|
||||
// celestrak: 'good' → serve GOOD_TLE for every group
|
||||
// celestrak: 'partial' → fail one group while preserving a usable catalog
|
||||
// celestrak: 'down' → 503 for every group (total outage)
|
||||
const mode = { celestrak: 'good' };
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (req) => {
|
||||
const url = req.url();
|
||||
// (i) AIS invalid-key: the exact payload the spec calls for.
|
||||
if (url.includes('/api/ais-live') && !url.includes('/track')) {
|
||||
req.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ rows: [], status: 'error', error: 'invalid key' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// (ii) CelesTrak proxy — /api/celestrak/<group>
|
||||
if (url.includes('/api/celestrak/')) {
|
||||
if (mode.celestrak === 'down'
|
||||
|| (mode.celestrak === 'partial' && url.includes('/api/celestrak/stations'))) {
|
||||
req.respond({ status: 503, contentType: 'text/plain', body: 'upstream unavailable' });
|
||||
} else {
|
||||
req.respond({ status: 200, contentType: 'text/plain', body: GOOD_TLE });
|
||||
}
|
||||
return;
|
||||
}
|
||||
req.continue();
|
||||
});
|
||||
|
||||
console.log('Loading app...');
|
||||
await page.goto(withDetectDebug(APP_URL), { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView
|
||||
&& window.__godsEyeView.viewer
|
||||
&& window.__godsEyeView.dataManager
|
||||
&& window.__godsEyeView.styleManager,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await sleep(1500);
|
||||
|
||||
// ── (0) Manager-owned periodic refresh → shared work/failure/recovery ──
|
||||
console.log('\n(0) Universal periodic refresh feedback...');
|
||||
const refreshFeedback = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const ids = ['flights', 'military', 'satellites'];
|
||||
const styleManager = window.__godsEyeView.styleManager;
|
||||
const outcomes = [];
|
||||
const readBanner = () => ({
|
||||
hidden: document.getElementById('global-loading-status')?.hidden,
|
||||
state: document.getElementById('global-loading-status')?.dataset?.state || '',
|
||||
label: document.getElementById('global-loading-label')?.textContent?.trim() || '',
|
||||
detail: document.getElementById('global-loading-detail')?.textContent?.trim() || '',
|
||||
});
|
||||
|
||||
for (const id of ids) {
|
||||
const entry = dm.layers.get(id);
|
||||
if (!entry) {
|
||||
outcomes.push({ id, missing: true });
|
||||
continue;
|
||||
}
|
||||
const original = {
|
||||
enabled: entry.enabled,
|
||||
initialized: entry.initialized,
|
||||
lifecycleState: entry.lifecycleState,
|
||||
update: entry.module.update,
|
||||
getStats: entry.module.getStats,
|
||||
managerRefreshError: entry.managerRefreshError,
|
||||
};
|
||||
const baseStats = typeof original.getStats === 'function'
|
||||
? original.getStats.call(entry.module)
|
||||
: { count: 0, lastUpdate: null };
|
||||
let rejectRefresh;
|
||||
try {
|
||||
entry.enabled = true;
|
||||
entry.initialized = true;
|
||||
entry.lifecycleState = 'enabled';
|
||||
entry.managerRefreshError = null;
|
||||
entry.module.getStats = () => ({
|
||||
...baseStats,
|
||||
error: null,
|
||||
lastError: null,
|
||||
available: true,
|
||||
unavailable: false,
|
||||
loading: false,
|
||||
refreshing: false,
|
||||
});
|
||||
entry.module.update = () => new Promise((resolve, reject) => {
|
||||
rejectRefresh = reject;
|
||||
});
|
||||
const pendingFailure = dm._runPeriodicUpdate(id, entry);
|
||||
styleManager._updateGlobalLoadingFeedback(performance.now());
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
styleManager._updateGlobalLoadingFeedback(performance.now());
|
||||
const working = readBanner();
|
||||
rejectRefresh(new Error(`${id} QA refresh failure`));
|
||||
await pendingFailure;
|
||||
styleManager._updateGlobalLoadingFeedback(performance.now());
|
||||
const failed = readBanner();
|
||||
const managerError = entry.managerRefreshError;
|
||||
|
||||
entry.module.update = async () => true;
|
||||
const pendingRecovery = dm._runPeriodicUpdate(id, entry);
|
||||
styleManager._updateGlobalLoadingFeedback(performance.now());
|
||||
await pendingRecovery;
|
||||
styleManager._updateGlobalLoadingFeedback(performance.now());
|
||||
outcomes.push({
|
||||
id,
|
||||
name: entry.module.name,
|
||||
working,
|
||||
failed,
|
||||
managerError,
|
||||
recoveredError: entry.managerRefreshError,
|
||||
lifecycleState: entry.lifecycleState,
|
||||
enabled: entry.enabled,
|
||||
});
|
||||
} finally {
|
||||
entry.module.update = original.update;
|
||||
entry.module.getStats = original.getStats;
|
||||
entry.enabled = original.enabled;
|
||||
entry.initialized = original.initialized;
|
||||
entry.lifecycleState = original.lifecycleState;
|
||||
entry.managerRefreshError = original.managerRefreshError;
|
||||
}
|
||||
}
|
||||
return outcomes;
|
||||
});
|
||||
for (const outcome of refreshFeedback) {
|
||||
const passed = !outcome.missing
|
||||
&& outcome.working.hidden === false
|
||||
&& outcome.working.label === 'REFRESHING LIVE DATA'
|
||||
&& outcome.working.detail.includes(outcome.name)
|
||||
&& outcome.failed.hidden === false
|
||||
&& outcome.failed.label === 'LOAD FAILED'
|
||||
&& outcome.failed.state === 'error'
|
||||
&& outcome.managerError?.includes('QA refresh failure')
|
||||
&& outcome.recoveredError === null
|
||||
&& outcome.lifecycleState === 'enabled'
|
||||
&& outcome.enabled === true;
|
||||
record(
|
||||
`${outcome.name || outcome.id}: periodic refresh reports work, failure, and recovery`,
|
||||
passed,
|
||||
JSON.stringify(outcome),
|
||||
);
|
||||
if (!passed) exitCode = 1;
|
||||
}
|
||||
|
||||
// ── (i) AIS invalid key → surfaced error, not a clean empty ──────────────
|
||||
console.log('\n(i) AIS invalid-key: enabling ais-live-vessels...');
|
||||
const aisStats = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.setEnabled('ais-live-vessels', true);
|
||||
const mod = dm.layers.get('ais-live-vessels').module;
|
||||
// DataLayerManager intentionally treats enable() as a synchronous
|
||||
// lifecycle hook, while this layer starts its first poll asynchronously
|
||||
// from enable(). Wait on the layer's public loading state instead of a
|
||||
// machine-speed-dependent fixed delay so the assertion always observes
|
||||
// the injected response, not an in-flight request.
|
||||
const deadline = Date.now() + 5000;
|
||||
while (mod.getStats().loading && Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
return mod.getStats();
|
||||
});
|
||||
{
|
||||
const hasError = typeof aisStats.error === 'string' && aisStats.error.length > 0;
|
||||
const cleanEmpty = !hasError && (aisStats.count === 0);
|
||||
record(
|
||||
'AIS: dead feed surfaces an error (not clean empty)',
|
||||
hasError && !cleanEmpty,
|
||||
`stats=${JSON.stringify({ error: aisStats.error, count: aisStats.count, lastUpdate: aisStats.lastUpdate })}`,
|
||||
);
|
||||
if (!hasError) exitCode = 1;
|
||||
}
|
||||
const aisControl = await readLayerControl(page, 'ais-live-vessels', 'UNAVAILABLE');
|
||||
const aisChipHonest = aisControl.feedState === 'unavailable'
|
||||
&& aisControl.ariaLabel === 'Live AIS Vessels: UNAVAILABLE';
|
||||
const aisMetaHonest = /^UNAVAILABLE · AISStream · /i.test(aisControl.meta)
|
||||
&& aisStats.error
|
||||
&& aisControl.meta.includes(aisStats.error);
|
||||
record(
|
||||
'AIS: layer control reads UNAVAILABLE',
|
||||
aisChipHonest,
|
||||
JSON.stringify(aisControl),
|
||||
);
|
||||
record(
|
||||
'AIS: layer metadata names the upstream error',
|
||||
aisMetaHonest,
|
||||
`meta=${JSON.stringify(aisControl.meta)}`,
|
||||
);
|
||||
if (!aisChipHonest || !aisMetaHonest) exitCode = 1;
|
||||
await captureLayerControl(page, 'ais-live-vessels', 'failstate-ais-unavailable.png');
|
||||
|
||||
// ── (ii) CelesTrak outage on re-enable → catalog NOT wiped, error set ────
|
||||
console.log('\n(ii) Satellites: building a good catalog first...');
|
||||
const goodStats = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.setEnabled('satellites', true);
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
const mod = dm.layers.get('satellites').module;
|
||||
return mod.getStats();
|
||||
});
|
||||
record(
|
||||
'Satellites: good catalog loaded before outage (baseline)',
|
||||
goodStats.count > 0,
|
||||
`count=${goodStats.count} error=${goodStats.error ?? null}`,
|
||||
);
|
||||
if (!(goodStats.count > 0)) {
|
||||
// Without a baseline the preservation assertion is meaningless.
|
||||
record('Satellites: catalog preserved across total outage', null, 'no baseline catalog to preserve');
|
||||
} else {
|
||||
console.log(' Failing one CelesTrak group and verifying the visible degraded state...');
|
||||
mode.celestrak = 'partial';
|
||||
const partialStats = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.setEnabled('satellites', false);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
await dm.setEnabled('satellites', true);
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
return dm.layers.get('satellites').module.getStats();
|
||||
});
|
||||
const partialControl = await readLayerControl(page, 'satellites', 'DEGRADED');
|
||||
const partialChipHonest = partialStats.count > 0
|
||||
&& /1 CelesTrak group unavailable/i.test(partialStats.error || '')
|
||||
&& partialControl.feedState === 'degraded'
|
||||
&& partialControl.ariaLabel === 'Satellites: DEGRADED';
|
||||
const partialMetaHonest = /^DEGRADED · CelesTrak · /i.test(partialControl.meta)
|
||||
&& /1 CelesTrak group unavailable/i.test(partialControl.meta);
|
||||
record(
|
||||
'Satellites: partial outage renders a DEGRADED chip',
|
||||
partialChipHonest,
|
||||
JSON.stringify({ stats: partialStats, control: partialControl }),
|
||||
);
|
||||
record(
|
||||
'Satellites: degraded metadata names partial coverage',
|
||||
partialMetaHonest,
|
||||
`meta=${JSON.stringify(partialControl.meta)}`,
|
||||
);
|
||||
if (!partialChipHonest || !partialMetaHonest) exitCode = 1;
|
||||
await captureLayerControl(page, 'satellites', 'failstate-satellites-degraded.png');
|
||||
|
||||
console.log(' Flipping ALL CelesTrak groups to 503 and toggling satellites off→on...');
|
||||
mode.celestrak = 'down';
|
||||
const outageStats = await page.evaluate(async (baseline) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
// Toggle off then on — the re-enable's update() re-fetches (now all 503).
|
||||
await dm.setEnabled('satellites', false);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
await dm.setEnabled('satellites', true);
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
const mod = dm.layers.get('satellites').module;
|
||||
return { stats: mod.getStats(), baseline };
|
||||
}, goodStats.count);
|
||||
|
||||
const s = outageStats.stats;
|
||||
const notWiped = s.count > 0; // catalog preserved, not blanked to 0
|
||||
const errorSet = typeof s.error === 'string' && s.error.length > 0;
|
||||
const outageControl = await readLayerControl(page, 'satellites', 'UNAVAILABLE');
|
||||
const outageChipHonest = outageControl.feedState === 'unavailable'
|
||||
&& outageControl.ariaLabel === 'Satellites: UNAVAILABLE';
|
||||
const outageMetaHonest = /^UNAVAILABLE · CelesTrak · /i.test(outageControl.meta)
|
||||
&& outageControl.meta.includes(s.error || '');
|
||||
record(
|
||||
'Satellites: catalog NOT wiped to 0 on total outage',
|
||||
notWiped,
|
||||
`count=${s.count} (baseline=${outageStats.baseline})`,
|
||||
);
|
||||
record(
|
||||
'Satellites: outage surfaces stats.error',
|
||||
errorSet,
|
||||
`error=${JSON.stringify(s.error)} lastUpdate=${s.lastUpdate}`,
|
||||
);
|
||||
record(
|
||||
'Satellites: total outage renders an UNAVAILABLE chip',
|
||||
outageChipHonest,
|
||||
JSON.stringify(outageControl),
|
||||
);
|
||||
record(
|
||||
'Satellites: unavailable metadata names the outage',
|
||||
outageMetaHonest,
|
||||
`meta=${JSON.stringify(outageControl.meta)}`,
|
||||
);
|
||||
if (!notWiped || !errorSet || !outageChipHonest || !outageMetaHonest) exitCode = 1;
|
||||
await captureLayerControl(page, 'satellites', 'failstate-satellites-unavailable.png');
|
||||
}
|
||||
|
||||
// ── (iii) DETECT with no data layers → mode banner drawn (non-blank) ─────
|
||||
console.log('\n(iii) DETECT with no data layers: disabling data layers, enabling panoptic...');
|
||||
const detect = await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
const dm = gev.dataManager;
|
||||
// Turn OFF every data layer so detection collects zero objects.
|
||||
for (const [id, entry] of dm.layers) {
|
||||
if (entry.enabled) { try { await dm.setEnabled(id, false); } catch { /* ignore */ } }
|
||||
}
|
||||
// Enable panoptic detection via the styleManager facade (the UI path).
|
||||
gev.styleManager.setDetection({ enabled: true, mode: 'panoptic' });
|
||||
const canvas = document.getElementById('world-overlay-canvas');
|
||||
if (!canvas) return { present: false };
|
||||
const viewer = gev.viewer;
|
||||
const tileset = gev.tileset;
|
||||
const priorDefaultLoop = viewer.useDefaultRenderLoop;
|
||||
const priorTilesetShow = tileset?.show;
|
||||
let nonEmpty = 0;
|
||||
let solid = 0;
|
||||
let sampled = 0;
|
||||
viewer.useDefaultRenderLoop = false;
|
||||
if (tileset) tileset.show = false;
|
||||
try {
|
||||
const deadline = performance.now() + 5000;
|
||||
while (performance.now() < deadline && solid <= 20) {
|
||||
viewer.render();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const w = canvas.width, h = canvas.height;
|
||||
const off = document.createElement('canvas');
|
||||
off.width = w; off.height = h;
|
||||
const octx = off.getContext('2d');
|
||||
// The detection pass (banner included) paints on the host-owned
|
||||
// blend-isolation surface beneath the shared canvas — composite
|
||||
// both so the sample sees whichever surface carries the banner.
|
||||
const detSurface = document.getElementById('world-overlay-detection-surface');
|
||||
if (detSurface) octx.drawImage(detSurface, 0, 0);
|
||||
octx.drawImage(canvas, 0, 0);
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const bw = Math.min(w, Math.round(260 * dpr));
|
||||
const bh = Math.min(h, Math.round(48 * dpr));
|
||||
const data = octx.getImageData(0, 0, bw, bh).data;
|
||||
sampled = bw * bh;
|
||||
nonEmpty = 0;
|
||||
solid = 0;
|
||||
for (let i = 3; i < data.length; i += 4) {
|
||||
if (data[i] > 4) nonEmpty++;
|
||||
// The banner paints labelBg at full opacity (alpha 0.66-0.78 => 168+).
|
||||
// Scanlines paint the SAME color but scaled by _theme.scanline, and
|
||||
// only on every fourth row, so they never reach this threshold. This
|
||||
// is what separates "the banner is drawn" from "something is drawn".
|
||||
if (data[i] > 128) solid++;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (tileset) tileset.show = priorTilesetShow;
|
||||
viewer.useDefaultRenderLoop = priorDefaultLoop;
|
||||
viewer.scene.requestRender();
|
||||
}
|
||||
const detState = gev.styleManager.getDetectionState?.() || null;
|
||||
return { present: true, nonEmpty, solid, sampled, mode: detState?.detectionMode ?? null };
|
||||
});
|
||||
|
||||
if (!detect.present) {
|
||||
record('DETECT: mode banner drawn on 0 objects', false, 'world-overlay canvas absent');
|
||||
exitCode = 1;
|
||||
} else {
|
||||
// Solid pixels only: a non-empty canvas proves SOMETHING painted, which
|
||||
// faint scanlines alone satisfy. The banner's opaque background is what
|
||||
// this check is named for, so that is what it asserts.
|
||||
const bannerDrawn = detect.solid > 20;
|
||||
record(
|
||||
'DETECT: mode banner drawn on 0 objects (opaque banner fill present)',
|
||||
bannerDrawn,
|
||||
`solidPx=${detect.solid} nonEmptyPx=${detect.nonEmpty}/${detect.sampled} mode=${detect.mode}`,
|
||||
);
|
||||
if (!bannerDrawn) exitCode = 1;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('\x1b[31mHarness error:\x1b[0m', e);
|
||||
exitCode = 3;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const pass = results.filter((r) => r.ok === true).length;
|
||||
const fail = results.filter((r) => r.ok === false).length;
|
||||
const inconclusive = results.filter((r) => r.ok === null).length;
|
||||
console.log('\n' + '─'.repeat(60));
|
||||
console.log(` RESULT: ${pass} passed, ${fail} failed, ${inconclusive} inconclusive`);
|
||||
console.log('─'.repeat(60) + '\n');
|
||||
process.exit(exitCode || (fail > 0 ? 1 : 0));
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(3);
|
||||
});
|
||||
@@ -0,0 +1,481 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-firms.mjs — headless proof for the LIVE NASA FIRMS fires layer.
|
||||
*
|
||||
* Drives the REAL app in headless Chromium against a dev server with a
|
||||
* FIRMS key (default :4420). The live section rides the proxy's 30-min disk
|
||||
* cache (zero upstream quota risk per run); the degraded states are
|
||||
* fabricated with request interception so they're deterministic.
|
||||
*
|
||||
* (i) LIVE — layer loads >1000 detections, no error, label 'LIVE ·',
|
||||
* /api/firms/status reports the key + transaction telemetry.
|
||||
* (ii) CARDS — the shared world-overlay canvas reports painted FIRMS
|
||||
* entries and is non-blank at a fire cluster (detections LOD) AND at
|
||||
* global zoom (cell cards); aggregate cells expose no action target.
|
||||
* (iii) KEYLESS — /api/firms intercepted to 503 {error:'no_key'}:
|
||||
* stats.error 'KEY REQUIRED', zero fires, no crash.
|
||||
* (iv) STALE — /api/firms intercepted to a stale:true payload with
|
||||
* fresh-enough detections: stats.stale true, error 'STALE · …',
|
||||
* fires still render (old data beats a wiped map).
|
||||
* (v) ACTION (synthetic) — one deterministic intercepted detection's
|
||||
* accessible card selects exactly that stable fire and transfers the
|
||||
* camera through the shared UI navigation policy.
|
||||
*
|
||||
* Visual proof saved to qa-shots/ (gitignored).
|
||||
*
|
||||
* Run: node scripts/qa-firms.mjs --url http://localhost:4420
|
||||
* Exits non-zero on any FAIL. Does not commit anything.
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, dflt) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4420');
|
||||
const HEADFUL = argv.includes('--headful');
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
|
||||
try { if (fs.existsSync(candidate)) return candidate; } catch { /* ignore */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
const tag = ok === null ? '\x1b[33mINCONCLUSIVE\x1b[0m' : ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** Non-transparent pixels plus source-scoped host diagnostics. */
|
||||
async function cardCanvasInk(page) {
|
||||
return page.evaluate(() => {
|
||||
const canvas = document.getElementById('world-overlay-canvas');
|
||||
const diagnostics = window.__gevWorldOverlay?.getDiagnostics?.();
|
||||
const painted = diagnostics?.paintedBySource?.firms || 0;
|
||||
const entries = diagnostics?.entriesBySource?.firms || 0;
|
||||
if (!canvas) return {
|
||||
present: false, ink: 0, painted, entries,
|
||||
candidates: diagnostics?.candidateCount || 0,
|
||||
projected: diagnostics?.projectedCount || 0,
|
||||
};
|
||||
const ctx = canvas.getContext('2d');
|
||||
const { width, height } = canvas;
|
||||
if (!width || !height) return {
|
||||
present: true, ink: 0, painted, entries,
|
||||
candidates: diagnostics?.candidateCount || 0,
|
||||
projected: diagnostics?.projectedCount || 0,
|
||||
};
|
||||
const data = ctx.getImageData(0, 0, width, height).data;
|
||||
let ink = 0;
|
||||
for (let i = 3; i < data.length; i += 4) if (data[i] > 8) ink++;
|
||||
return {
|
||||
present: true, ink, painted, entries,
|
||||
candidates: diagnostics?.candidateCount || 0,
|
||||
projected: diagnostics?.projectedCount || 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Wait for two consecutive painted frames instead of sampling a render gap. */
|
||||
async function waitForCardCanvasInk(page, { timeoutMs = 12000 } = {}) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let consecutive = 0;
|
||||
let sample = null;
|
||||
while (Date.now() < deadline) {
|
||||
await page.evaluate(() => window.__godsEyeView?.viewer?.scene?.requestRender?.());
|
||||
await sleep(150);
|
||||
sample = await cardCanvasInk(page);
|
||||
if (sample.present && sample.entries > 0 && sample.painted > 0 && sample.ink > 500) {
|
||||
consecutive += 1;
|
||||
if (consecutive >= 2) return sample;
|
||||
} else {
|
||||
consecutive = 0;
|
||||
}
|
||||
}
|
||||
return sample || {
|
||||
present: false, ink: 0, painted: 0, entries: 0, candidates: 0, projected: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Currently painted FIRMS actions mirrored for keyboard/assistive input. */
|
||||
async function firmsActionSnapshot(page) {
|
||||
return page.evaluate(() => {
|
||||
const buttons = [...document.querySelectorAll(
|
||||
'#world-overlay-action-list button[data-overlay-action-key]',
|
||||
)].filter((button) => String(button.dataset.overlayActionKey || '').startsWith('firms\u0000'));
|
||||
return {
|
||||
count: buttons.length,
|
||||
selectedCount: buttons.filter((button) => button.getAttribute('aria-pressed') === 'true').length,
|
||||
labels: buttons.map((button) => button.getAttribute('aria-label') || button.textContent || ''),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Wait until the accessible mirror reflects the requested FIRMS action count. */
|
||||
async function waitForFirmsActionCount(page, expected, { timeoutMs = 12000 } = {}) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let snapshot = await firmsActionSnapshot(page);
|
||||
while (Date.now() < deadline && snapshot.count !== expected) {
|
||||
await page.evaluate(() => window.__godsEyeView?.viewer?.scene?.requestRender?.());
|
||||
await sleep(175);
|
||||
snapshot = await firmsActionSnapshot(page);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Boot the app and enable the fires layer, polling until settled. */
|
||||
async function bootAndEnable(page, { timeoutS = 45 } = {}) {
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await sleep(1500);
|
||||
return page.evaluate(async (tS) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.setEnabled('local-firms', true);
|
||||
const mod = dm.layers.get('local-firms').module;
|
||||
let s = null;
|
||||
for (let i = 0; i < tS; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
s = mod.getStats();
|
||||
if ((s.count > 0 || s.error) && !s.loading) break;
|
||||
}
|
||||
return s;
|
||||
}, timeoutS);
|
||||
}
|
||||
|
||||
/** Teleport the camera (duck-typed cartographic — no Cesium global). */
|
||||
async function setView(page, lon, lat, height) {
|
||||
await page.evaluate((lo, la, h) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const ell = gev.viewer.scene.globe.ellipsoid;
|
||||
const d2r = Math.PI / 180;
|
||||
// The app's intro flyTo animation clobbers a setView issued mid-flight.
|
||||
try { gev.viewer.camera.cancelFlight(); } catch { /* no flight active */ }
|
||||
gev.viewer.camera.setView({
|
||||
destination: ell.cartographicToCartesian({ longitude: lo * d2r, latitude: la * d2r, height: h }),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
gev.viewer.scene.requestRender?.();
|
||||
}, lon, lat, height);
|
||||
}
|
||||
|
||||
/** Fabricated stale-but-recent proxy payload for section (iv). */
|
||||
function stalePayload() {
|
||||
const now = Date.now();
|
||||
const acq = new Date(now - 2 * 3600000);
|
||||
const acqDate = acq.toISOString().slice(0, 10);
|
||||
const acqTime = `${String(acq.getUTCHours()).padStart(2, '0')}${String(acq.getUTCMinutes()).padStart(2, '0')}`;
|
||||
const mk = (lat, lon, frp, confidence) => ({
|
||||
lat, lon, frp, confidence, brightness: 330, brightnessTi5: 290,
|
||||
daynight: 'N', acqDate, acqTime, satellite: 'N20', instrument: 'VIIRS',
|
||||
});
|
||||
return {
|
||||
fetchedAt: now - 2 * 3600000,
|
||||
stale: true,
|
||||
ttlMs: 1800000,
|
||||
sources: [{ source: 'VIIRS_NOAA20_NRT', count: 3, ok: false }],
|
||||
count: 3,
|
||||
fires: [mk(61.9, -122.9, 900, 'h'), mk(61.95, -122.8, 45, 'n'), mk(61.85, -123.0, 4, 'l')],
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\nLive NASA FIRMS Proof (qa-firms)');
|
||||
console.log(` App URL : ${APP_URL}\n`);
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const status = await fetch(`${APP_URL}/api/firms/status`).then((r) => r.json()).catch(() => null);
|
||||
if (!status?.hasKey) {
|
||||
console.error('\x1b[31mServer has no FIRMS key — run against the keyed dev server (:4420).\x1b[0m');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}),
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage', '--disable-web-security',
|
||||
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
||||
'--window-size=1440,900',
|
||||
],
|
||||
});
|
||||
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
|
||||
// ── (i) LIVE feed ────────────────────────────────────────────────────────
|
||||
console.log('(i) LIVE — loading the fires layer through the cached proxy...');
|
||||
const live = await bootAndEnable(page);
|
||||
{
|
||||
const ok = live.count > 1000 && !live.error && String(live.loadingLabel || '').startsWith('LIVE');
|
||||
record('LIVE: >1000 detections, no error, LIVE label', ok,
|
||||
`count=${live.count} error=${JSON.stringify(live.error)} label=${JSON.stringify(live.loadingLabel)}`);
|
||||
record('LIVE: /api/firms/status has key + transaction telemetry',
|
||||
status.hasKey === true && (status.transactions === null || Number.isFinite(status.transactions?.used)),
|
||||
`transactions=${JSON.stringify(status.transactions)} count=${status.count}`);
|
||||
if (!ok) exitCode = 1;
|
||||
}
|
||||
|
||||
// ── (ii) CARDS — canvas overlay draws at both LODs ──────────────────────
|
||||
console.log('\n(ii) CARDS — tactical card overlay ink at both LODs...');
|
||||
{
|
||||
const strongest = await page.evaluate(() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('local-firms').module;
|
||||
return mod.getStrongestFire();
|
||||
});
|
||||
if (!strongest) {
|
||||
record('CARDS: detections-LOD cards drawn', false, 'no strongest fire available');
|
||||
exitCode = 1;
|
||||
} else {
|
||||
await setView(page, strongest.longitude, strongest.latitude, 60000);
|
||||
const det = await waitForCardCanvasInk(page);
|
||||
record('CARDS: detections-LOD cards drawn (host source + canvas ink)',
|
||||
det.present && det.painted > 0 && det.ink > 500,
|
||||
`entries=${det.entries} candidates=${det.candidates} projected=${det.projected} `
|
||||
+ `painted=${det.painted} inkPx=${det.ink} @ strongest fire FRP ${strongest.frp}`);
|
||||
if (!(det.present && det.painted > 0 && det.ink > 500)) exitCode = 1;
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-cards-detections.png') });
|
||||
|
||||
// Global LOD band starts at 9,000 km; its labelDistance is 12,000 km
|
||||
// (cards fade to zero beyond it, matching the legacy labels) — so the
|
||||
// cell-card proof must sit inside that window.
|
||||
await setView(page, strongest.longitude, strongest.latitude, 10000000);
|
||||
// Do not let the previous detections frame satisfy the ink predicate
|
||||
// before the 650 ms LOD watcher has swapped in aggregate cell cards.
|
||||
const aggregateActions = await waitForFirmsActionCount(page, 0);
|
||||
const cells = await waitForCardCanvasInk(page);
|
||||
record('CARDS: global-LOD cell cards drawn (host source + canvas ink)',
|
||||
cells.present && cells.painted > 0 && cells.ink > 500,
|
||||
`entries=${cells.entries} candidates=${cells.candidates} projected=${cells.projected} `
|
||||
+ `painted=${cells.painted} inkPx=${cells.ink}`);
|
||||
if (!(cells.present && cells.painted > 0 && cells.ink > 500)) exitCode = 1;
|
||||
record('CARDS: aggregate cells expose no FIRMS action target',
|
||||
aggregateActions.count === 0,
|
||||
`actionButtons=${aggregateActions.count}`);
|
||||
if (aggregateActions.count !== 0) exitCode = 1;
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-global-cells.png') });
|
||||
}
|
||||
}
|
||||
|
||||
// ── (iii) KEYLESS (intercepted) ──────────────────────────────────────────
|
||||
console.log('\n(iii) KEYLESS — intercepted 503 no_key...');
|
||||
await page.setRequestInterception(true);
|
||||
let interceptMode = 'keyless';
|
||||
page.on('request', (req) => {
|
||||
const url = req.url();
|
||||
if (url.includes('/api/firms/status')) {
|
||||
req.respond({
|
||||
status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify(interceptMode === 'keyless'
|
||||
? { hasKey: false }
|
||||
: { hasKey: true, lastFetch: Date.now() - 7200000, count: 3, stale: true, ttlMs: 1800000, transactions: null }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.includes('/api/firms')) {
|
||||
if (interceptMode === 'keyless') {
|
||||
req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) });
|
||||
} else {
|
||||
req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify(stalePayload()) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
try { req.continue(); } catch { /* already handled */ }
|
||||
});
|
||||
|
||||
const keyless = await bootAndEnable(page, { timeoutS: 20 });
|
||||
{
|
||||
const ok = keyless.error === 'KEY REQUIRED' && keyless.count === 0;
|
||||
record('KEYLESS: error "KEY REQUIRED", zero fires, no crash', ok,
|
||||
`error=${JSON.stringify(keyless.error)} count=${keyless.count} label=${JSON.stringify(keyless.loadingLabel)}`);
|
||||
if (!ok) exitCode = 1;
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-keyless.png') });
|
||||
}
|
||||
|
||||
// ── (iv) STALE (intercepted) ─────────────────────────────────────────────
|
||||
console.log('\n(iv) STALE — intercepted stale:true payload...');
|
||||
interceptMode = 'stale';
|
||||
const stale = await bootAndEnable(page, { timeoutS: 20 });
|
||||
{
|
||||
const ok = stale.stale === true
|
||||
&& String(stale.error || '').startsWith('STALE')
|
||||
&& stale.count === 3;
|
||||
record('STALE: stats.stale, "STALE · cached" error, fires still render', ok,
|
||||
`stale=${stale.stale} error=${JSON.stringify(stale.error)} count=${stale.count}`);
|
||||
if (!ok) exitCode = 1;
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-stale.png') });
|
||||
}
|
||||
|
||||
// ── (v) ACTION (deterministic intercepted detection) ────────────────────────
|
||||
console.log('\n(v) ACTION — synthetic intercepted fire card through shared camera policy...');
|
||||
await setView(page, -122.9, 61.9, 60000);
|
||||
const actionCards = await waitForCardCanvasInk(page);
|
||||
const beforeActions = await firmsActionSnapshot(page);
|
||||
if (!(actionCards.present && actionCards.painted > 0 && beforeActions.count > 0)) {
|
||||
record('ACTION (synthetic): individual fire action is available', false,
|
||||
`painted=${actionCards.painted} actionButtons=${beforeActions.count}`);
|
||||
exitCode = 1;
|
||||
} else {
|
||||
const prepared = await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const mod = gev.dataManager.layers.get('local-firms').module;
|
||||
const target = mod.getDetectableObjects({ maxCount: 1 })[0];
|
||||
if (!target?.position) return null;
|
||||
|
||||
const sentinel = gev.viewer.entities.add({
|
||||
id: 'qa-firms-prior-camera-owner',
|
||||
position: target.position,
|
||||
});
|
||||
gev.viewer.trackedEntity = sentinel;
|
||||
|
||||
const camera = gev.viewer.camera;
|
||||
const originalFly = camera.flyToBoundingSphere;
|
||||
const proof = {
|
||||
sentinel,
|
||||
originalFly,
|
||||
flightCount: 0,
|
||||
trackedAtFlight: 'not-called',
|
||||
request: null,
|
||||
generationBefore: gev.styleManager._navigationGeneration,
|
||||
};
|
||||
proof.onRequest = (event) => {
|
||||
proof.request = {
|
||||
kind: event.detail?.kind || null,
|
||||
id: event.detail?.id || null,
|
||||
};
|
||||
};
|
||||
window.addEventListener('gev:world-request-focus', proof.onRequest);
|
||||
camera.flyToBoundingSphere = function qaFirmsObservedFlight(...args) {
|
||||
proof.flightCount += 1;
|
||||
proof.trackedAtFlight = gev.viewer.trackedEntity?.id || null;
|
||||
return originalFly.apply(this, args);
|
||||
};
|
||||
window.__qaFirmsActionProof = proof;
|
||||
return { generationBefore: proof.generationBefore };
|
||||
});
|
||||
|
||||
if (!prepared) {
|
||||
record('ACTION (synthetic): individual fire action is available', false,
|
||||
'no deterministic fire position');
|
||||
exitCode = 1;
|
||||
} else {
|
||||
const clicked = await page.evaluate(() => {
|
||||
const button = [...document.querySelectorAll(
|
||||
'#world-overlay-action-list button[data-overlay-action-key]',
|
||||
)].find((candidate) => String(candidate.dataset.overlayActionKey || '').startsWith('firms\u0000'));
|
||||
button?.click();
|
||||
return Boolean(button);
|
||||
});
|
||||
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
const settled = await page.evaluate(() => {
|
||||
window.__godsEyeView?.viewer?.scene?.requestRender?.();
|
||||
return (window.__qaFirmsActionProof?.flightCount || 0) > 0;
|
||||
});
|
||||
if (settled) break;
|
||||
await sleep(100);
|
||||
}
|
||||
await sleep(250);
|
||||
|
||||
const proof = await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const state = window.__qaFirmsActionProof;
|
||||
const actionButtons = [...document.querySelectorAll(
|
||||
'#world-overlay-action-list button[data-overlay-action-key]',
|
||||
)].filter((button) => String(button.dataset.overlayActionKey || '').startsWith('firms\u0000'));
|
||||
const selectedEntityId = window.__gevContextStore?.selectedEntityId || null;
|
||||
const result = {
|
||||
flightCount: state?.flightCount || 0,
|
||||
trackedAtFlight: state?.trackedAtFlight,
|
||||
trackingReleased: gev.viewer.trackedEntity == null,
|
||||
generationBefore: state?.generationBefore,
|
||||
generationAfter: gev.styleManager._navigationGeneration,
|
||||
request: state?.request || null,
|
||||
selectedEntityId,
|
||||
selectedActionCount: actionButtons.filter(
|
||||
(button) => button.getAttribute('aria-pressed') === 'true',
|
||||
).length,
|
||||
};
|
||||
if (state) {
|
||||
gev.viewer.camera.flyToBoundingSphere = state.originalFly;
|
||||
window.removeEventListener('gev:world-request-focus', state.onRequest);
|
||||
if (state.sentinel) gev.viewer.entities.remove(state.sentinel);
|
||||
}
|
||||
delete window.__qaFirmsActionProof;
|
||||
return result;
|
||||
});
|
||||
|
||||
const actionOk = clicked
|
||||
&& proof.flightCount === 1
|
||||
&& proof.trackedAtFlight == null
|
||||
&& proof.trackingReleased
|
||||
&& proof.generationAfter === proof.generationBefore + 1
|
||||
&& proof.request?.kind === 'fire'
|
||||
&& proof.request?.id === proof.selectedEntityId
|
||||
&& proof.selectedActionCount === 1;
|
||||
record('ACTION (synthetic): stable fire selects once and shared policy owns flight', actionOk,
|
||||
`clicked=${clicked} flights=${proof.flightCount} trackedAtFlight=${JSON.stringify(proof.trackedAtFlight)} `
|
||||
+ `generation=${proof.generationBefore}->${proof.generationAfter} `
|
||||
+ `request=${JSON.stringify(proof.request)} selected=${JSON.stringify(proof.selectedEntityId)} `
|
||||
+ `selectedActions=${proof.selectedActionCount}`);
|
||||
if (!actionOk) exitCode = 1;
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-synthetic-fire-action.png') });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('\x1b[31mHarness error:\x1b[0m', e);
|
||||
exitCode = 3;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const pass = results.filter((r) => r.ok === true).length;
|
||||
const fail = results.filter((r) => r.ok === false).length;
|
||||
console.log('\n' + '─'.repeat(60));
|
||||
console.log(` RESULT: ${pass} passed, ${fail} failed`);
|
||||
console.log(` Shots : ${SHOTS_DIR}/firms-*.png`);
|
||||
console.log('─'.repeat(60) + '\n');
|
||||
process.exit(exitCode || (fail > 0 ? 1 : 0));
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(3); });
|
||||
@@ -0,0 +1,519 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Are the first-run launcher's pins actually load-bearing?
|
||||
*
|
||||
* A pin that only goes red when you delete the whole feature proves very little.
|
||||
* This reverts each decision INDIVIDUALLY — the smallest edit that reintroduces
|
||||
* the original defect or contradicts the product rule — and requires
|
||||
* src/firstRunExperience.test.mjs to go red for it. Every entry names what it
|
||||
* restores, so the count is reproducible rather than asserted in a commit
|
||||
* message.
|
||||
*
|
||||
* node scripts/qa-firstrun-mutations.mjs
|
||||
*
|
||||
* Every touched file is restored on exit, including on failure.
|
||||
*
|
||||
* NOTE: two mutations edit vite.config.js, and a running dev server watches that
|
||||
* file and restarts on every write. Writes are therefore content-guarded below
|
||||
* so the file is touched exactly twice per mutation instead of on every
|
||||
* iteration — enough that a dev server survives, but expect it to restart. If
|
||||
* you are mid-QA on a live server, run this before or after, not during.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const TESTS = 'src/firstRunExperience.test.mjs';
|
||||
|
||||
const FILES = {
|
||||
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
|
||||
html: path.join(ROOT, 'index.html'),
|
||||
css: path.join(ROOT, 'style.css'),
|
||||
vite: path.join(ROOT, 'vite.config.js'),
|
||||
main: path.join(ROOT, 'src', 'main.js'),
|
||||
ui: path.join(ROOT, 'src', 'ui.js'),
|
||||
docs: path.join(ROOT, 'docs', 'CURRENT-STATE.md'),
|
||||
};
|
||||
|
||||
/** @type {Array<{defect: string, file: keyof FILES, from: string, to: string}>} */
|
||||
const MUTATIONS = [
|
||||
// ── Show policy (product decision: session-scoped dismiss vs durable checkbox) ──
|
||||
{
|
||||
defect: 'dismissing writes the DURABLE key, so the launcher never returns',
|
||||
file: 'module',
|
||||
from: "writeStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY, 'dismissed');",
|
||||
to: "writeStored('local', sessionStorageRef, FIRST_RUN_STORAGE_KEY, 'suppressed');",
|
||||
},
|
||||
{
|
||||
defect: 'the checkbox is ignored, so nothing can ever suppress the launcher',
|
||||
file: 'module',
|
||||
from: " ? writeStored('local', storage, FIRST_RUN_STORAGE_KEY, 'suppressed')",
|
||||
to: ' ? false',
|
||||
},
|
||||
{
|
||||
defect: 'unticking the box cannot take the suppression back',
|
||||
file: 'module',
|
||||
from: " : removeStored('local', storage, FIRST_RUN_STORAGE_KEY);",
|
||||
to: ' : true;',
|
||||
},
|
||||
{
|
||||
defect: 'the session flag is never read, so it re-nags on every reload',
|
||||
file: 'module',
|
||||
from: "if (readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) === 'dismissed') return false;",
|
||||
to: '// session check removed',
|
||||
},
|
||||
{
|
||||
defect: 'the durable flag is never read, so the checkbox does nothing',
|
||||
file: 'module',
|
||||
from: "if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') return false;",
|
||||
to: '// durable check removed',
|
||||
},
|
||||
{
|
||||
defect: '?welcome=1 no longer outranks suppression, so demos cannot replay it',
|
||||
file: 'module',
|
||||
from: "if (params.get('welcome') === '1') return true;",
|
||||
to: '// replay hatch removed',
|
||||
},
|
||||
{
|
||||
defect: '?welcome=0 stops suppressing',
|
||||
file: 'module',
|
||||
from: "if (params.get('welcome') === '0') return false;",
|
||||
to: '// suppression param removed',
|
||||
},
|
||||
{
|
||||
defect: 'a share link gets the launcher over the view its author chose',
|
||||
file: 'module',
|
||||
from: 'if (hasShareState) return false;',
|
||||
to: '// share bypass removed',
|
||||
},
|
||||
{
|
||||
defect: 'storage is read from a DEFAULT PARAMETER, outside every guard',
|
||||
file: 'module',
|
||||
from: 'export function rememberFirstRunSessionDismissed(sessionStorageRef) {',
|
||||
to: 'export function rememberFirstRunSessionDismissed(sessionStorageRef = globalThis.sessionStorage) {',
|
||||
},
|
||||
{
|
||||
defect: 'shouldShowFirstRun resolves storage in its parameter list again',
|
||||
file: 'module',
|
||||
from: 'export function shouldShowFirstRun({\n hasShareState = false,\n storage,\n sessionStorageRef,',
|
||||
to: 'export function shouldShowFirstRun({\n hasShareState = false,\n storage = globalThis.localStorage,\n sessionStorageRef = globalThis.sessionStorage,',
|
||||
},
|
||||
{
|
||||
defect: 'the guarded resolver stops catching, so a hostile getter escapes',
|
||||
file: 'module',
|
||||
from: " try {\n return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;\n } catch {\n // Privacy-restricted storage should not make first launch silent.\n return null;\n }",
|
||||
to: " return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;",
|
||||
},
|
||||
{
|
||||
defect: 'a blocked getItem throws instead of reading as "nothing stored"',
|
||||
file: 'module',
|
||||
from: ' return resolveStore(kind, injected)?.getItem?.(key) ?? null;\n } catch {\n return null;\n }',
|
||||
to: ' return resolveStore(kind, injected)?.getItem?.(key) ?? null;\n } finally {\n // no catch\n }',
|
||||
},
|
||||
|
||||
// ── ESC arbitration ───────────────────────────────────────────────────────
|
||||
{
|
||||
defect: 'the key handler trusts the CLASS, so an invisible card eats ESC',
|
||||
file: 'module',
|
||||
from: 'if (closing || !isTopmost()) return;',
|
||||
to: 'if (closing) return;',
|
||||
},
|
||||
{
|
||||
defect: 'the launcher stops yielding and fights Cockpit/Scenes for ESC',
|
||||
file: 'module',
|
||||
from: 'if (revealed && blocked) yieldToExclusiveSurface();\n else if (!revealed && !blocked) reveal();',
|
||||
to: 'if (!revealed) reveal();',
|
||||
},
|
||||
{
|
||||
defect: 'nothing watches for another surface taking the screen',
|
||||
file: 'module',
|
||||
from: "surfaceObserver?.observe(documentRef.body, { attributes: true, attributeFilter: ['class'] });",
|
||||
to: '/* observer never attached */',
|
||||
},
|
||||
{
|
||||
defect: 'cockpit drops off the exclusive list and can stack with the launcher',
|
||||
file: 'module',
|
||||
from: "export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([\n 'cockpit-mode',",
|
||||
to: 'export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([',
|
||||
},
|
||||
{
|
||||
defect: 'a scene hides the launcher in CSS but the JS never learns of it',
|
||||
file: 'css',
|
||||
from: 'body.scene-playback-mode #first-run-launcher {',
|
||||
to: 'body.scene-playback-mode-typo #first-run-launcher {',
|
||||
},
|
||||
{
|
||||
defect: 'yielding steals focus back from the surface that just took over',
|
||||
file: 'module',
|
||||
from: 'dismiss({ restoreFocus: false });',
|
||||
to: 'dismiss();',
|
||||
},
|
||||
{
|
||||
defect: 'isTopmost stops hit-testing, so an unclassed overlay buries the card again',
|
||||
file: 'module',
|
||||
from: ' && root.getClientRects().length > 0\n && !coveredByOverlay();',
|
||||
to: ' && root.getClientRects().length > 0;',
|
||||
},
|
||||
{
|
||||
defect: 'an inconclusive hit test reads as COVERED, so ESC quietly stops working',
|
||||
file: 'module',
|
||||
from: ' return Boolean(hit) && !root.contains(hit);',
|
||||
to: ' return !root.contains(hit);',
|
||||
},
|
||||
{
|
||||
defect: 'the launcher stops honouring a key another surface already claimed',
|
||||
file: 'module',
|
||||
from: ' if (event.defaultPrevented) return;',
|
||||
to: ' /* belt removed */',
|
||||
},
|
||||
{
|
||||
// Anchored past the call so it cannot land on the cockpit disclosure's own
|
||||
// stopImmediatePropagation() a few lines below and prove nothing about this.
|
||||
defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things',
|
||||
file: 'ui',
|
||||
from: ' event.stopImmediatePropagation();\n setRadioDisclosure(false, { returnFocus: true });',
|
||||
to: ' event.stopPropagation();\n setRadioDisclosure(false, { returnFocus: true });',
|
||||
},
|
||||
{
|
||||
defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind',
|
||||
file: 'module',
|
||||
from: ' * ACCEPTED, DELIBERATELY NOT TIMED OUT: a surface class that never clears',
|
||||
to: ' * (note removed)',
|
||||
},
|
||||
{
|
||||
defect: 'a bounded reveal timer punches the card through a recording in progress',
|
||||
file: 'module',
|
||||
from: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;',
|
||||
to: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;\n globalThis.setTimeout?.(reveal, 45000);',
|
||||
},
|
||||
{
|
||||
defect: 'the accepted no-show vanishes from CURRENT-STATE and reads as a bug',
|
||||
file: 'docs',
|
||||
from: 'a surface class that never clears means no launcher for that page',
|
||||
to: 'the launcher always turns up eventually for that page',
|
||||
},
|
||||
|
||||
// ── The checkbox may not promise what storage refused ─────────────────────
|
||||
{
|
||||
defect: 'a refused write leaves the box ticked, promising a suppression nobody stored',
|
||||
file: 'module',
|
||||
from: ' if (setFirstRunSuppressed(wanted, storage)) return;',
|
||||
to: ' setFirstRunSuppressed(wanted, storage);\n return;',
|
||||
},
|
||||
{
|
||||
defect: 'a missing storage area reports the write as saved',
|
||||
file: 'module',
|
||||
from: " if (typeof store?.setItem !== 'function') return false;\n store.setItem(key, value);\n return true;",
|
||||
to: ' store?.setItem?.(key, value);\n return true;',
|
||||
},
|
||||
{
|
||||
defect: 'the scroll fade promises more list on a card where everything fits',
|
||||
file: 'module',
|
||||
from: 'choiceList.dataset.scrollable = String(overflows);',
|
||||
to: "choiceList.dataset.scrollable = 'true'; void overflows;",
|
||||
},
|
||||
|
||||
{
|
||||
defect: 'the DISPLAY rail opens on first run again, stealing the impression',
|
||||
file: 'ui',
|
||||
from: "if (panelId === 'pp-toggles' && stored === null) collapsed = true;",
|
||||
to: "if (panelId === 'pp-toggles' && stored === null) collapsed = false;",
|
||||
},
|
||||
{
|
||||
defect: 'the first-run rail default overrides a stored user choice',
|
||||
file: 'ui',
|
||||
from: "if (panelId === 'pp-toggles' && stored === null) collapsed = true;",
|
||||
to: "if (panelId === 'pp-toggles') collapsed = true;",
|
||||
},
|
||||
|
||||
// ── Mission definitions ────────────────────────────────────────────────────
|
||||
{
|
||||
defect: 'the removed INFRASTRUCTURE tile comes back as a one-click globe dump',
|
||||
file: 'module',
|
||||
from: " environmental: Object.freeze({",
|
||||
to: " infrastructure: Object.freeze({\n kind: 'globe',\n layerIds: Object.freeze(['local-datacenters', 'local-dams', 'telegeography-submarine-cables']),\n busyText: 'Mapping global infrastructure…',\n }),\n environmental: Object.freeze({",
|
||||
},
|
||||
{
|
||||
defect: 'ENVIRONMENTAL drops the keyless earthquakes that carry it without a key',
|
||||
file: 'module',
|
||||
from: "layerIds: Object.freeze(['earthquakes', 'local-firms']),",
|
||||
to: "layerIds: Object.freeze(['local-firms']),",
|
||||
},
|
||||
{
|
||||
defect: 'FIRMS is dropped again from the tile whose subcopy promises it',
|
||||
file: 'module',
|
||||
from: "layerIds: Object.freeze(['earthquakes', 'local-firms']),",
|
||||
to: "layerIds: Object.freeze(['earthquakes']),",
|
||||
},
|
||||
{
|
||||
defect: 'the tile stops promising the fires it actually turns on',
|
||||
file: 'html',
|
||||
from: '<small>Live earthquakes and active fires, from USGS and NASA</small>',
|
||||
to: '<small>Live earthquakes worldwide, straight from USGS</small>',
|
||||
},
|
||||
{
|
||||
defect: "the final first-run line is quietly rewritten",
|
||||
file: 'html',
|
||||
from: 'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.',
|
||||
to: "It feels like a forbidden cockpit. It isn't — every feed is public, and every contact is live.",
|
||||
},
|
||||
{
|
||||
defect: 'the ledgered global-chip defect loses its pointer to the real fix',
|
||||
file: 'module',
|
||||
from: ' // means a KEY REQUIRED terminal state in src/loadingFeedback.js, a state',
|
||||
to: ' // (note removed)',
|
||||
},
|
||||
{
|
||||
defect: 'the globe missions never pull the camera out',
|
||||
file: 'module',
|
||||
from: ' .then(() => flyToGlobe())',
|
||||
to: ' .then(() => null)',
|
||||
},
|
||||
{
|
||||
defect: 'a refused layer is reported as a successful mission',
|
||||
file: 'module',
|
||||
from: 'return { ok: failedLayerIds.length === 0, choice, failedLayerIds };',
|
||||
to: 'return { ok: true, choice, failedLayerIds };',
|
||||
},
|
||||
{
|
||||
defect: 'a cancelled globe flight fails the whole mission',
|
||||
file: 'module',
|
||||
from: ' .catch(() => null);',
|
||||
to: ' .catch(() => { throw new Error("flight failed"); });',
|
||||
},
|
||||
{
|
||||
defect: 'Explore manually quietly runs a mission instead of nothing',
|
||||
file: 'module',
|
||||
from: "if (mission.kind === 'none') return { ok: true, choice };",
|
||||
to: "if (mission.kind === 'none') return { ok: true, choice, ran: await setContextMode('contacts') };",
|
||||
},
|
||||
{
|
||||
defect: 'an unknown choice is treated as a success',
|
||||
file: 'module',
|
||||
from: 'if (!mission) return { ok: false, choice };',
|
||||
to: 'if (!mission) return { ok: true, choice };',
|
||||
},
|
||||
{
|
||||
defect: 'the fires/quakes tile name stops being switchable from one constant',
|
||||
file: 'module',
|
||||
from: 'return ENVIRONMENTAL_LABELS[choice] || ENVIRONMENTAL_LABELS.ENVIRONMENTAL;',
|
||||
to: 'return ENVIRONMENTAL_LABELS.ENVIRONMENTAL;',
|
||||
},
|
||||
|
||||
// ── Defaults interplay (the ruling this whole integration turns on) ────────
|
||||
{
|
||||
defect: "mission layer enables stop persisting, unlike the clicks they stand for",
|
||||
file: 'module',
|
||||
from: "dataManager.setEnabled(layerId, true, { origin: 'user' })",
|
||||
to: 'dataManager.setEnabled(layerId, true)',
|
||||
},
|
||||
{
|
||||
defect: 'a mission hand-edits detection and kills the style auto-preset contract',
|
||||
file: 'module',
|
||||
from: ' flyToGlobe: () => styleManager.resetToGlobeView(),',
|
||||
to: ' flyToGlobe: () => { styleManager._detectionUserOverridden = true; return styleManager.resetToGlobeView(); },',
|
||||
},
|
||||
{
|
||||
defect: 'a mission persists a 3D-models choice nobody made',
|
||||
file: 'module',
|
||||
from: ' const result = await styleManager.setContextMode(mode);',
|
||||
to: ' styleManager._setModels3dEnabled?.(true);\n const result = await styleManager.setContextMode(mode);',
|
||||
},
|
||||
{
|
||||
defect: 'the Context panel is left collapsed, hiding the mode that just started',
|
||||
file: 'module',
|
||||
from: "styleManager.setPanelCollapsed?.('global-context-panel', false, { explicit: true });",
|
||||
to: '// panel reveal removed',
|
||||
},
|
||||
{
|
||||
defect: 'the decision table is deleted, so the next editor re-litigates it blind',
|
||||
file: 'module',
|
||||
from: ' * MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST',
|
||||
to: ' * (notes removed)',
|
||||
},
|
||||
|
||||
// ── Accessibility and the transplant defects ──────────────────────────────
|
||||
{
|
||||
defect: 'disabling the focused tile drops the keyboard to <body> mid-flight',
|
||||
file: 'module',
|
||||
from: "for (const button of buttons) button.setAttribute('aria-disabled', String(next));",
|
||||
to: 'for (const button of buttons) button.disabled = next;',
|
||||
},
|
||||
{
|
||||
defect: 'Tab escapes into an app the visitor has not seen yet',
|
||||
file: 'module',
|
||||
from: "if (event.key !== 'Tab') return;",
|
||||
to: 'return;',
|
||||
},
|
||||
{
|
||||
defect: 'ESC no longer dismisses the launcher',
|
||||
file: 'module',
|
||||
from: "if (event.key === 'Escape') {",
|
||||
to: 'if (false) {',
|
||||
},
|
||||
{
|
||||
defect: 'focus is never returned to where the visitor left it',
|
||||
file: 'module',
|
||||
from: "if (typeof previouslyFocused?.focus === 'function' && previouslyFocused.isConnected) {",
|
||||
to: 'if (false) {',
|
||||
},
|
||||
{
|
||||
defect: 'app hotkeys eat the launcher keys (bubble instead of capture)',
|
||||
file: 'module',
|
||||
from: "documentRef.addEventListener('keydown', onKeyDown, true);",
|
||||
to: "documentRef.addEventListener('keydown', onKeyDown);",
|
||||
},
|
||||
{
|
||||
defect: 'the card stays clickable through its fade-out (double-launch)',
|
||||
file: 'css',
|
||||
from: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);\n /* Inert until revealed AND again while fading out, so a click during the\n dismiss animation cannot launch a second mission behind the fade. */\n pointer-events: none;',
|
||||
to: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);',
|
||||
},
|
||||
{
|
||||
defect: 'the launcher survives Clean-UI and recording mode',
|
||||
file: 'css',
|
||||
from: 'body.ui-clean-view #first-run-launcher,',
|
||||
to: 'body.never-matches-anything #first-run-launcher,',
|
||||
},
|
||||
{
|
||||
defect: 'the flex card defeats [hidden] and sits in the a11y tree pre-reveal',
|
||||
file: 'css',
|
||||
from: '#first-run-launcher[hidden] {\n display: none;\n}',
|
||||
to: '/* [hidden] rule removed */',
|
||||
},
|
||||
{
|
||||
defect: 'the card loses its viewport cap and clips on a short landscape phone',
|
||||
file: 'css',
|
||||
from: ' max-height: calc(100dvh - 1.5rem);',
|
||||
to: ' /* cap removed */',
|
||||
},
|
||||
{
|
||||
defect: 'the mission list stops scrolling, so tiles overflow the capped card',
|
||||
file: 'css',
|
||||
from: ' min-height: 0;\n overflow-y: auto;',
|
||||
to: ' /* scroll removed */',
|
||||
},
|
||||
{
|
||||
// Anchored to the launcher's OWN selectors: style.css has four other
|
||||
// reduced-motion blocks, and a bare `@media` anchor would mutate one of
|
||||
// those instead and prove nothing about this feature.
|
||||
defect: "the launcher's reduced-motion opt-out is dropped",
|
||||
file: 'css',
|
||||
from: ' #first-run-launcher,\n .first-run-choices button,\n .first-run-arrow { transition: none; }\n /* Scrolling a tile into view must not animate either. */\n .first-run-choices { scroll-behavior: auto; }',
|
||||
to: ' .first-run-nothing { transition: none; }',
|
||||
},
|
||||
|
||||
// ── Markup and startup ordering ───────────────────────────────────────────
|
||||
{
|
||||
defect: 'a mission tile is dropped from the menu',
|
||||
file: 'html',
|
||||
from: ' <button type="button" data-first-run-choice="space-missions">',
|
||||
to: ' <button type="button" data-first-run-choice-disabled="space-missions">',
|
||||
},
|
||||
{
|
||||
defect: 'the menu order stops matching the owner\'s',
|
||||
file: 'html',
|
||||
from: '<button type="button" data-first-run-choice="environmental">',
|
||||
to: '<button type="button" data-first-run-choice="zzz-environmental">',
|
||||
},
|
||||
{
|
||||
defect: 'the "don\'t show again" checkbox is removed',
|
||||
file: 'html',
|
||||
from: '<input type="checkbox" data-first-run-suppress />',
|
||||
to: '<span data-first-run-suppress-removed></span>',
|
||||
},
|
||||
{
|
||||
defect: 'the status line stops being a polite live region',
|
||||
file: 'html',
|
||||
from: 'data-first-run-status role="status" aria-live="polite"',
|
||||
to: 'data-first-run-status',
|
||||
},
|
||||
{
|
||||
defect: 'the launcher is no longer announced as a dialog',
|
||||
file: 'html',
|
||||
from: '<aside id="first-run-launcher" role="dialog"',
|
||||
to: '<aside id="first-run-launcher"',
|
||||
},
|
||||
{
|
||||
defect: 'the launcher is revealed before the loading cover yields',
|
||||
file: 'main',
|
||||
from: " loadingScreen.classList.add('hidden');",
|
||||
to: ' initFirstRunExperience({ styleManager, dataManager });\n loadingScreen.classList.add(\'hidden\');',
|
||||
},
|
||||
{
|
||||
defect: 'the globe missions lose their DataManager and cannot enable layers',
|
||||
file: 'main',
|
||||
from: 'initFirstRunExperience({ styleManager, dataManager });',
|
||||
to: 'initFirstRunExperience({ styleManager });',
|
||||
},
|
||||
|
||||
// ── Voice: schema must not drift ──────────────────────────────────────────
|
||||
{
|
||||
defect: 'the voice TOOL SCHEMA is edited (a Realtime prompt-cache bust)',
|
||||
file: 'vite',
|
||||
from: " 'earthquakes',\n 'satellites',",
|
||||
to: " 'earthquakes',\n 'infrastructure-mode',\n 'satellites',",
|
||||
},
|
||||
{
|
||||
defect: 'the instruction mapping is dropped, so voice cannot reach the modes',
|
||||
file: 'vite',
|
||||
from: " 'NAMED VIEWS are shorthand",
|
||||
to: " // 'NAMED VIEWS are shorthand",
|
||||
},
|
||||
];
|
||||
|
||||
const originals = new Map();
|
||||
for (const [key, file] of Object.entries(FILES)) originals.set(key, fs.readFileSync(file, 'utf8'));
|
||||
|
||||
/** Write only on a real change: a no-op write still wakes every file watcher. */
|
||||
const write = (file, next) => {
|
||||
if (fs.readFileSync(file, 'utf8') !== next) fs.writeFileSync(file, next);
|
||||
};
|
||||
const restoreAll = () => {
|
||||
for (const [key, file] of Object.entries(FILES)) write(file, originals.get(key));
|
||||
};
|
||||
process.on('exit', restoreAll);
|
||||
|
||||
let caught = 0;
|
||||
const missed = [];
|
||||
|
||||
console.log(`\nFirst-run launcher pin strength — ${MUTATIONS.length} individual reverts\n`);
|
||||
for (const { defect, file, from, to } of MUTATIONS) {
|
||||
const original = originals.get(file);
|
||||
if (!original.includes(from)) {
|
||||
missed.push(`${defect} (ANCHOR MISSING — the mutation no longer applies)`);
|
||||
console.log(` \x1b[31mSTALE\x1b[0m ${defect}`);
|
||||
continue;
|
||||
}
|
||||
write(FILES[file], original.replace(from, to));
|
||||
let red = false;
|
||||
let by = '';
|
||||
try {
|
||||
execFileSync('node', ['--test', TESTS], { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' });
|
||||
} catch (error) {
|
||||
red = true;
|
||||
const failed = String(error.stdout || '').split('\n')
|
||||
.filter((line) => line.trim().startsWith('✖') && line.includes('('))
|
||||
.map((line) => line.trim().slice(2).split(' (')[0]);
|
||||
by = [...new Set(failed)].slice(0, 2).join('; ');
|
||||
}
|
||||
write(FILES[file], original);
|
||||
if (red) {
|
||||
caught += 1;
|
||||
console.log(` \x1b[32mRED \x1b[0m ${defect}\n caught by: ${by}`);
|
||||
} else {
|
||||
missed.push(defect);
|
||||
console.log(` \x1b[31mGREEN\x1b[0m ${defect} <-- no pin covers this`);
|
||||
}
|
||||
}
|
||||
restoreAll();
|
||||
|
||||
console.log(`\n ${caught}/${MUTATIONS.length} defects caught by the pins`);
|
||||
if (missed.length) {
|
||||
console.log('\n uncovered:');
|
||||
for (const item of missed) console.log(` - ${item}`);
|
||||
}
|
||||
console.log('');
|
||||
process.exitCode = missed.length ? 1 : 0;
|
||||
@@ -0,0 +1,878 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-firstrun — the mission launcher's contract, proved in the real app.
|
||||
*
|
||||
* The unit suite pins the show policy and the mission table against fakes. This
|
||||
* harness answers the questions only a running app can: does a mission actually
|
||||
* enable those layers, does the camera actually reach the globe, does a keyless
|
||||
* visitor actually get the honest FIRMS state instead of a silent empty layer,
|
||||
* and does any of it disturb the reasonable-defaults startup look.
|
||||
*
|
||||
* It also captures the owner's taste-pass screenshots into qa-shots/firstrun/.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/qa-firstrun.mjs --url http://localhost:4278
|
||||
* node scripts/qa-firstrun.mjs --url http://localhost:4278 --teeth
|
||||
*
|
||||
* `--teeth` is the negative control: it suppresses the launcher's own reveal, so
|
||||
* every rendered/behavioral assertion below must go RED. A green --teeth run
|
||||
* means the assertions are not measuring what they claim to.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const getOpt = (flag, fallback) => {
|
||||
const index = args.indexOf(flag);
|
||||
return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
|
||||
};
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173').replace(/\/$/, '');
|
||||
const TEETH = args.includes('--teeth');
|
||||
const HEADFUL = args.includes('--headful');
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SHOT_DIR = path.join(ROOT, 'qa-shots', 'firstrun');
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Version-pinned Chrome-for-Testing over the auto-updating system Chrome.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
].filter(Boolean);
|
||||
|
||||
const results = [];
|
||||
let currentSection = 'setup';
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail, section: currentSection });
|
||||
console.log(` [${ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one independent section.
|
||||
*
|
||||
* Sections are isolated on purpose. The first version of this harness ran as a
|
||||
* single straight line, so the FIRST throw ended the run — under `--teeth` that
|
||||
* meant it stopped at 9 of 38 checks and never exercised the mission, viewport
|
||||
* or console assertions at all, while still claiming the control had teeth. A
|
||||
* throw now fails its own section and the rest still run, which is what makes
|
||||
* the per-section reachability tally below meaningful.
|
||||
*/
|
||||
async function section(name, body) {
|
||||
currentSection = name;
|
||||
console.log(`\n \x1b[2m── ${name} ──\x1b[0m`);
|
||||
try {
|
||||
await body();
|
||||
} catch (error) {
|
||||
record(`[${name}] section completed without throwing`, false, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const LAUNCHER = '#first-run-launcher';
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Load the app and wait for the launcher to settle one way or the other.
|
||||
*
|
||||
* `errorSink` is emptied on the way out: navigating away aborts whatever the
|
||||
* previous page had in flight, and Chrome reports those aborts as
|
||||
* "TypeError: Failed to fetch". Those belong to this harness, not to the app,
|
||||
* so the console assertion only ever judges the page it is actually looking at.
|
||||
*/
|
||||
async function open(page, { hash = '', query = '', clearAll = true, clearSession = true, errorSink = null } = {}) {
|
||||
await page.goto(`${APP_URL}/${query}${hash}`, { waitUntil: 'domcontentloaded' });
|
||||
if (clearAll || clearSession) {
|
||||
await page.evaluate((all) => {
|
||||
if (all) localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
}, clearAll);
|
||||
await page.goto(`${APP_URL}/${query}${hash}`, { waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
// The launcher is revealed after the loading cover yields (~T+1.9 s).
|
||||
await page.waitForFunction(
|
||||
(sel) => !!window.__godsEyeView?.styleManager
|
||||
&& (document.querySelector(sel)?.classList.contains('visible') || !document.querySelector(sel)),
|
||||
{ timeout: 45000 },
|
||||
LAUNCHER,
|
||||
).catch(() => {});
|
||||
await sleep(400);
|
||||
if (errorSink) errorSink.length = 0;
|
||||
}
|
||||
|
||||
const launcherVisible = (page) => page.evaluate(
|
||||
(sel) => !!document.querySelector(sel)?.classList.contains('visible'),
|
||||
LAUNCHER,
|
||||
);
|
||||
|
||||
const appState = (page) => page.evaluate(() => {
|
||||
const gev = window.__godsEyeView || {};
|
||||
const sm = gev.styleManager;
|
||||
const dm = gev.dataManager;
|
||||
const carto = sm?.viewer?.camera?.positionCartographic;
|
||||
const layers = {};
|
||||
const counts = {};
|
||||
const all = dm?.getAll?.() || [];
|
||||
for (const id of [
|
||||
'local-datacenters', 'local-dams', 'telegeography-submarine-cables',
|
||||
'local-firms', 'earthquakes', 'flights', 'military', 'rocket-launches', 'satellites',
|
||||
]) {
|
||||
layers[id] = !!dm?.isEnabled?.(id);
|
||||
counts[id] = all.find((entry) => entry.id === id)?.stats?.count ?? null;
|
||||
}
|
||||
const firms = all.find((entry) => entry.id === 'local-firms');
|
||||
return {
|
||||
heightKm: carto ? Math.round(carto.height / 1000) : null,
|
||||
layers,
|
||||
counts,
|
||||
contextMode: sm?.getContextModeState?.().mode ?? null,
|
||||
firmsError: firms?.stats?.error ?? null,
|
||||
firmsCount: firms?.stats?.count ?? null,
|
||||
detectionOverridden: sm?._detectionUserOverridden ?? null,
|
||||
durable: localStorage.getItem('gev:first-run-mission:v1'),
|
||||
session: sessionStorage.getItem('gev:first-run-mission-session:v1'),
|
||||
layerStateBlob: localStorage.getItem('gev:layer-state:v2'),
|
||||
allocation: localStorage.getItem('gev:detection-allocation:v1'),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Click a mission tile, wait for the launcher to close, then let the layers it
|
||||
* asked for actually settle. Asserting (or screenshotting) before the feeds have
|
||||
* drawn measures the harness's own impatience, not the mission.
|
||||
*/
|
||||
/**
|
||||
* Start recording every state the GLOBAL loading chip passes through. The chip
|
||||
* is transient — sampling it once after a mission would miss a failure banner
|
||||
* that flashed and cleared — so this watches it continuously instead.
|
||||
*/
|
||||
async function watchLoadingChip(page) {
|
||||
await page.evaluate(() => {
|
||||
window.__chipSeen = [];
|
||||
const el = document.getElementById('global-loading-status');
|
||||
if (!el) return;
|
||||
const sample = () => {
|
||||
const label = document.getElementById('global-loading-label')?.textContent?.trim() || '';
|
||||
const state = el.dataset.state || '';
|
||||
const hidden = el.hidden;
|
||||
const last = window.__chipSeen[window.__chipSeen.length - 1];
|
||||
const entry = `${hidden ? 'hidden' : 'shown'}:${state}:${label}`;
|
||||
if (entry !== last) window.__chipSeen.push(entry);
|
||||
};
|
||||
sample();
|
||||
window.__chipObserver?.disconnect();
|
||||
window.__chipObserver = new MutationObserver(sample);
|
||||
window.__chipObserver.observe(el, {
|
||||
attributes: true, childList: true, subtree: true, characterData: true,
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const readLoadingChip = (page) => page.evaluate(() => window.__chipSeen || []);
|
||||
|
||||
async function pick(page, choice, { timeout = 40000, settle = [] } = {}) {
|
||||
await page.click(`[data-first-run-choice="${choice}"]`);
|
||||
await page.waitForFunction(
|
||||
(sel) => !document.querySelector(sel) || !document.querySelector(sel).classList.contains('visible'),
|
||||
{ timeout },
|
||||
LAUNCHER,
|
||||
).catch(() => {});
|
||||
if (settle.length) {
|
||||
await page.evaluate(async (ids) => {
|
||||
const dm = window.__godsEyeView?.dataManager;
|
||||
await Promise.all(ids.map((id) => dm?.waitForLayerSettled?.(id)));
|
||||
}, settle).catch(() => {});
|
||||
}
|
||||
// Plus a render beat: settled data still has to reach the framebuffer.
|
||||
await sleep(settle.length ? 3500 : 1500);
|
||||
}
|
||||
|
||||
/** Every section the run is expected to reach; the teeth verdict requires all. */
|
||||
const EXPECTED_SECTIONS = [
|
||||
'show-policy', 'esc-arbitration',
|
||||
'mission-environmental', 'mission-contacts', 'mission-space', 'mission-explore',
|
||||
'viewports', 'console',
|
||||
];
|
||||
|
||||
/*
|
||||
* Sections that are NOT expected to go red under --teeth.
|
||||
*
|
||||
* `console` is a hygiene check, not a launcher check: with the launcher removed
|
||||
* the page legitimately still logs nothing, so demanding a red there would be
|
||||
* demanding a false failure. Everything else exists to test the launcher and
|
||||
* must collapse without it. This list is deliberately tiny and named — it is the
|
||||
* one place a section can be excused, so an unjustified entry is visible.
|
||||
*/
|
||||
const HYGIENE_SECTIONS = ['console'];
|
||||
|
||||
/**
|
||||
* ESC arbitration — the two defects that motivated the yield design, plus the
|
||||
* yield itself. Driven through the real body classes, because that is exactly
|
||||
* what Cockpit and the Scene director set.
|
||||
*/
|
||||
async function runArbitrationSection(page, { shots, consoleErrors }) {
|
||||
await section('esc-arbitration', async () => {
|
||||
const launcherState = () => page.evaluate((sel) => {
|
||||
const node = document.querySelector(sel);
|
||||
const rect = node?.getBoundingClientRect();
|
||||
// Topmost, not merely present: hit-test the card's own centre the way the
|
||||
// launcher does, so an overlay that leaves the box intact is visible here.
|
||||
const hit = rect && rect.width > 0 && rect.height > 0
|
||||
? document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2)
|
||||
: null;
|
||||
return {
|
||||
present: !!node,
|
||||
classVisible: !!node?.classList.contains('visible'),
|
||||
// Real visibility: `display:none` yields zero client rects.
|
||||
onScreen: !!node && node.getClientRects().length > 0,
|
||||
topmost: !!(hit && node && node.contains(hit)),
|
||||
session: sessionStorage.getItem('gev:first-run-mission-session:v1'),
|
||||
};
|
||||
}, LAUNCHER);
|
||||
|
||||
// ── Blocker 2 repro: a Scene starts while the launcher is up ────────────
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
const beforeScene = await launcherState();
|
||||
record('the launcher is up before the scene starts', beforeScene.onScreen);
|
||||
|
||||
await page.evaluate(() => document.body.classList.add('scene-playback-mode'));
|
||||
await sleep(300);
|
||||
const duringScene = await launcherState();
|
||||
record(
|
||||
'a scene taking the screen makes the launcher YIELD, not just hide',
|
||||
beforeScene.onScreen && !duringScene.classVisible,
|
||||
`onScreen=${duringScene.onScreen} classVisible=${duringScene.classVisible} — a hidden card that kept "visible" is the defect`,
|
||||
);
|
||||
record('yielding is session-scoped, like any other dismissal',
|
||||
duringScene.session === 'dismissed', `session=${duringScene.session}`);
|
||||
|
||||
// The key that used to dismiss an invisible launcher must now do nothing
|
||||
// to it — proven by the scene class still being set afterwards.
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(300);
|
||||
const afterEsc = await page.evaluate(() => ({
|
||||
sceneStillOn: document.body.classList.contains('scene-playback-mode'),
|
||||
}));
|
||||
record(
|
||||
'ESC during a scene never lands on the launcher',
|
||||
afterEsc.sceneStillOn,
|
||||
afterEsc.sceneStillOn ? 'scene class untouched by the launcher' : 'something else consumed the key',
|
||||
);
|
||||
await page.evaluate(() => document.body.classList.remove('scene-playback-mode'));
|
||||
await sleep(200);
|
||||
const afterScene = await launcherState();
|
||||
record('a yielded launcher does not pop back when the scene ends',
|
||||
!afterScene.classVisible, `classVisible=${afterScene.classVisible}`);
|
||||
|
||||
// ── Blocker 3 repro: Cockpit engages while the launcher is up ───────────
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
const beforeCockpit = await launcherState();
|
||||
record('the launcher is up before cockpit engages', beforeCockpit.onScreen);
|
||||
await page.evaluate(() => document.body.classList.add('cockpit-mode'));
|
||||
await sleep(300);
|
||||
const duringCockpit = await launcherState();
|
||||
record(
|
||||
'cockpit engaging makes the launcher YIELD before it can contest ESC',
|
||||
beforeCockpit.onScreen && !duringCockpit.classVisible,
|
||||
`classVisible=${duringCockpit.classVisible} — cockpit registers its capture listener first, so stacking must never arise`,
|
||||
);
|
||||
await page.evaluate(() => document.body.classList.remove('cockpit-mode'));
|
||||
await sleep(200);
|
||||
|
||||
// ── Blocker repro: a surface that takes the screen with NO class ────────
|
||||
// The attribution lightbox is full-screen at z-index 200 against the card's
|
||||
// 175 and announces itself with nothing at all. The card keeps its box, so a
|
||||
// getClientRects()-only check called it visible: ESC dismissed a launcher
|
||||
// the visitor could not see, and burned the session flag, behind a lightbox
|
||||
// that binds no ESC of its own and therefore stayed open.
|
||||
const lightboxState = () => page.evaluate(() => {
|
||||
const overlay = document.querySelector('.cesium-credit-lightbox-overlay');
|
||||
return {
|
||||
present: !!overlay,
|
||||
shown: !!overlay && getComputedStyle(overlay).display !== 'none',
|
||||
z: overlay ? getComputedStyle(overlay).zIndex : null,
|
||||
};
|
||||
});
|
||||
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
const beforeLightbox = await launcherState();
|
||||
record('the launcher is up before the attribution lightbox opens', beforeLightbox.onScreen);
|
||||
|
||||
const linkClicked = await page.evaluate(() => {
|
||||
const link = document.querySelector('#cesium-credits .cesium-credit-expand-link');
|
||||
link?.click();
|
||||
return !!link;
|
||||
});
|
||||
await sleep(300);
|
||||
const lightboxUp = await lightboxState();
|
||||
record(
|
||||
'the real "Data attribution" lightbox opens above the card',
|
||||
linkClicked && lightboxUp.shown && lightboxUp.z === '200',
|
||||
`link=${linkClicked} shown=${lightboxUp.shown} z-index=${lightboxUp.z} vs the launcher's 175`,
|
||||
);
|
||||
const buried = await launcherState();
|
||||
record(
|
||||
'an unclassed overlay leaves the card MEASURABLE but no longer topmost',
|
||||
buried.onScreen && !buried.topmost,
|
||||
`onScreen=${buried.onScreen} topmost=${buried.topmost} — the class watch cannot see this surface at all`,
|
||||
);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(400);
|
||||
const escUnderLightbox = await launcherState();
|
||||
const lightboxAfterEsc = await lightboxState();
|
||||
record(
|
||||
'ESC under the lightbox never lands on the buried launcher',
|
||||
beforeLightbox.onScreen && escUnderLightbox.classVisible,
|
||||
`classVisible=${escUnderLightbox.classVisible} — the card was up, covered, and must stay up`,
|
||||
);
|
||||
record(
|
||||
'that ESC does not burn the session flag either',
|
||||
beforeLightbox.onScreen && escUnderLightbox.session !== 'dismissed',
|
||||
`session=${escUnderLightbox.session}`,
|
||||
);
|
||||
record(
|
||||
'the lightbox keeps its OWN ESC semantics (Cesium binds none, so it stays)',
|
||||
lightboxAfterEsc.shown,
|
||||
`overlay shown=${lightboxAfterEsc.shown}`,
|
||||
);
|
||||
|
||||
// The guard disarms the launcher; it must never break it. Close the overlay
|
||||
// the way a visitor does and the key comes straight back.
|
||||
await page.evaluate(() => document.querySelector('.cesium-credit-lightbox-close')?.click());
|
||||
await sleep(300);
|
||||
const uncovered = await launcherState();
|
||||
record('closing the lightbox hands the card back the key',
|
||||
uncovered.topmost, `topmost=${uncovered.topmost}`);
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(600);
|
||||
const afterUncoveredEsc = await launcherState();
|
||||
record(
|
||||
'ESC dismisses normally again once nothing is on top',
|
||||
beforeLightbox.onScreen && !afterUncoveredEsc.classVisible
|
||||
&& afterUncoveredEsc.session === 'dismissed',
|
||||
`classVisible=${afterUncoveredEsc.classVisible} session=${afterUncoveredEsc.session}`,
|
||||
);
|
||||
|
||||
// ── Blocker repro: a control that claims only the KEY ───────────────────
|
||||
// The compact Radio disclosure closes on ESC from a capture listener bound
|
||||
// long before this module exists. It called stopPropagation(), which does
|
||||
// NOT stop later listeners on the same document — so one key closed the
|
||||
// disclosure AND dismissed the launcher. The card is not hiding behind this
|
||||
// one and must not yield to it: the key is simply already spoken for.
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
const beforeRadio = await launcherState();
|
||||
record('the launcher is up before the radio disclosure opens', beforeRadio.onScreen);
|
||||
const radioOpened = await page.evaluate(() => {
|
||||
document.getElementById('context-radio-toggle-btn')?.click();
|
||||
return !!document.getElementById('context-radio-dock')?.classList.contains('disclosure-open');
|
||||
});
|
||||
await sleep(250);
|
||||
record('the compact Radio disclosure opens', radioOpened);
|
||||
const withRadio = await launcherState();
|
||||
record(
|
||||
'a small disclosure is a key contest, not a screen takeover — no yield',
|
||||
withRadio.classVisible && withRadio.topmost,
|
||||
`classVisible=${withRadio.classVisible} topmost=${withRadio.topmost}`,
|
||||
);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(400);
|
||||
const radioAfterEsc = await page.evaluate(() => (
|
||||
!!document.getElementById('context-radio-dock')?.classList.contains('disclosure-open')
|
||||
));
|
||||
const launcherAfterRadioEsc = await launcherState();
|
||||
record('ESC closes the disclosure', radioOpened && !radioAfterEsc,
|
||||
`disclosure still open=${radioAfterEsc}`);
|
||||
record(
|
||||
'that SAME ESC does not also dismiss the launcher',
|
||||
beforeRadio.onScreen && launcherAfterRadioEsc.classVisible
|
||||
&& launcherAfterRadioEsc.session !== 'dismissed',
|
||||
`classVisible=${launcherAfterRadioEsc.classVisible} session=${launcherAfterRadioEsc.session} — one key, one action`,
|
||||
);
|
||||
// ...and the NEXT press is the launcher's, so nothing was permanently taken.
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(600);
|
||||
const escAfterRadioClosed = await launcherState();
|
||||
record(
|
||||
'the next ESC belongs to the launcher again',
|
||||
beforeRadio.onScreen && !escAfterRadioClosed.classVisible
|
||||
&& escAfterRadioClosed.session === 'dismissed',
|
||||
`classVisible=${escAfterRadioClosed.classVisible} session=${escAfterRadioClosed.session}`,
|
||||
);
|
||||
|
||||
// ── Deferred reveal: a surface already up when the launcher would show ──
|
||||
// The class goes on as soon as the app exists, which is well before the
|
||||
// launcher's reveal (~T+1.9s), so init genuinely sees a screen it does not
|
||||
// own. Cockpit's own exit() strips this class, so it is re-asserted right up
|
||||
// to the check rather than set once and hoped for.
|
||||
await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); });
|
||||
await page.goto(`${APP_URL}/?welcome=1`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!document.body, { timeout: 45000 }).catch(() => {});
|
||||
const holdCockpit = async (ms) => {
|
||||
const until = Date.now() + ms;
|
||||
while (Date.now() < until) {
|
||||
await page.evaluate(() => document.body?.classList.add('cockpit-mode')).catch(() => {});
|
||||
await sleep(200);
|
||||
}
|
||||
};
|
||||
await holdCockpit(5000);
|
||||
const withCockpitUp = await launcherState();
|
||||
const classHeld = await page.evaluate(() => document.body.classList.contains('cockpit-mode'));
|
||||
record(
|
||||
'the launcher WAITS rather than appearing over a surface already up',
|
||||
classHeld && withCockpitUp.present && !withCockpitUp.classVisible,
|
||||
`cockpitClassHeld=${classHeld} present=${withCockpitUp.present} classVisible=${withCockpitUp.classVisible}`,
|
||||
);
|
||||
record('waiting does not burn the session flag',
|
||||
withCockpitUp.session !== 'dismissed', `session=${withCockpitUp.session}`);
|
||||
|
||||
// ...and appears once that surface clears.
|
||||
await page.evaluate(() => document.body.classList.remove('cockpit-mode'));
|
||||
await sleep(800);
|
||||
const afterCockpitCleared = await launcherState();
|
||||
record('the launcher appears once the surface clears',
|
||||
afterCockpitCleared.onScreen, `onScreen=${afterCockpitCleared.onScreen}`);
|
||||
if (afterCockpitCleared.onScreen) shots.push(await shoot(page, 'arbitration-revealed-after-cockpit'));
|
||||
});
|
||||
}
|
||||
|
||||
async function shoot(page, name) {
|
||||
// The teeth run deliberately has no launcher, so its frames are pictures of
|
||||
// an empty loading screen. Writing them would overwrite the real evidence
|
||||
// this directory exists to hold, so the control captures nothing.
|
||||
if (TEETH) return `${name}.png (skipped: teeth run)`;
|
||||
const file = path.join(SHOT_DIR, `${name}.png`);
|
||||
await page.screenshot({ path: file });
|
||||
return path.relative(ROOT, file);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\nFirst-Run Mission Launcher QA');
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Mode : ${TEETH ? 'TEETH (launcher suppressed — expect RED)' : 'normal'}\n`);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${APP_URL}/`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
console.error(`Dev server not reachable at ${APP_URL}: ${error.message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
||||
const executablePath = CHROME_CANDIDATES.find((candidate) => {
|
||||
try { return fs.existsSync(candidate); } catch { return false; }
|
||||
});
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox',
|
||||
'--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist',
|
||||
'--disable-dev-shm-usage', '--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding', '--window-size=1440,900',
|
||||
],
|
||||
});
|
||||
|
||||
const shots = [];
|
||||
const consoleErrors = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
|
||||
page.on('console', (message) => {
|
||||
if (message.type() !== 'error') return;
|
||||
const text = message.text();
|
||||
if (!/Failed to load resource.*(404|429|503)/i.test(text)) consoleErrors.push(text);
|
||||
});
|
||||
page.on('pageerror', (error) => consoleErrors.push(`pageerror: ${error.message}`));
|
||||
if (TEETH) {
|
||||
// Negative control: the markup never arrives, so nothing can be revealed.
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('first-run-launcher')?.remove();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await section('show-policy', async () => {
|
||||
await open(page);
|
||||
const freshVisible = await launcherVisible(page);
|
||||
record('a fresh session gets the launcher', freshVisible);
|
||||
if (freshVisible) shots.push(await shoot(page, 'launcher-desktop'));
|
||||
|
||||
const focused = await page.evaluate(() => document.activeElement?.dataset?.firstRunChoice ?? null);
|
||||
record('focus lands on the first mission tile', focused === 'contacts', `activeElement=${focused}`);
|
||||
|
||||
// The card is a flex column (so its list can scroll on short viewports),
|
||||
// and an author `display` on this id outranks the UA's `[hidden]` rule.
|
||||
// Prove the attribute still hides it, or it sits in the a11y tree from
|
||||
// page parse until reveal — and on a share link, until removal.
|
||||
const hiddenHonored = await page.evaluate((sel) => {
|
||||
const node = document.querySelector(sel);
|
||||
if (!node) return null;
|
||||
node.hidden = true;
|
||||
const display = getComputedStyle(node).display;
|
||||
node.hidden = false;
|
||||
return display;
|
||||
}, LAUNCHER);
|
||||
record('the hidden attribute still hides the flex card', hiddenHonored === 'none',
|
||||
`computed display while hidden = ${hiddenHonored}`);
|
||||
|
||||
const tiles = await page.$$eval('[data-first-run-choice]', (nodes) => nodes.map((n) => n.dataset.firstRunChoice));
|
||||
record(
|
||||
'four tiles in the owner\'s order',
|
||||
JSON.stringify(tiles) === JSON.stringify(['contacts', 'space-missions', 'environmental', 'explore']),
|
||||
tiles.join(' · '),
|
||||
);
|
||||
|
||||
// ESC dismisses. Stated as a TRANSITION (was up, now gone) — "not visible"
|
||||
// alone passes vacuously whenever the launcher never appeared at all.
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(600);
|
||||
const state = await appState(page);
|
||||
record('ESC dismisses the launcher', freshVisible && !(await launcherVisible(page)),
|
||||
freshVisible ? 'visible → dismissed' : 'never appeared, so nothing was dismissed');
|
||||
record('ESC writes the SESSION flag only', state.session === 'dismissed' && state.durable === null,
|
||||
`session=${state.session} durable=${state.durable}`);
|
||||
|
||||
// Same session, reload → stays gone. Also non-vacuous: it only means
|
||||
// anything if this session had seen the launcher in the first place.
|
||||
await open(page, { clearAll: false, clearSession: false });
|
||||
record('a reload in the same session does not re-nag',
|
||||
freshVisible && !(await launcherVisible(page)),
|
||||
freshVisible ? 'seen this session, stayed away on reload' : 'never appeared, so the check is empty');
|
||||
|
||||
// New session (sessionStorage cleared, localStorage kept) → back.
|
||||
await open(page, { clearAll: false, clearSession: true });
|
||||
record('the next fresh session gets it again', await launcherVisible(page));
|
||||
|
||||
// Checkbox → durable suppression.
|
||||
await page.click('[data-first-run-suppress]');
|
||||
await sleep(200);
|
||||
const ticked = await appState(page);
|
||||
record('the checkbox writes durable suppression immediately', ticked.durable === 'suppressed', `durable=${ticked.durable}`);
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(400);
|
||||
await open(page, { clearAll: false, clearSession: true });
|
||||
record('a suppressed profile stays quiet in later sessions',
|
||||
ticked.durable === 'suppressed' && !(await launcherVisible(page)),
|
||||
ticked.durable === 'suppressed' ? 'suppressed and stayed away' : 'nothing was ever suppressed');
|
||||
|
||||
// ?welcome=1 outranks the checkbox; ?welcome=0 suppresses a fresh session.
|
||||
await open(page, { clearAll: false, clearSession: true, query: "?welcome=1", errorSink: consoleErrors });
|
||||
record('?welcome=1 replays past durable suppression', await launcherVisible(page));
|
||||
await open(page, { query: '?welcome=0' });
|
||||
record('?welcome=0 suppresses a fresh session', !(await launcherVisible(page)));
|
||||
|
||||
// Share links bypass entirely.
|
||||
await open(page, { hash: '#lat=30.2672&lon=-97.7431&alt=2500' });
|
||||
const shareState = await page.evaluate(() => !!window.__godsEyeView?.styleManager?.hasShareState);
|
||||
const shareShowed = await launcherVisible(page);
|
||||
record('a share link bypasses the launcher', shareState && !shareShowed,
|
||||
`hasShareState=${shareState} launcherVisible=${shareShowed}`);
|
||||
});
|
||||
|
||||
await runArbitrationSection(page, { shots, consoleErrors });
|
||||
|
||||
// ── Mission outcomes ────────────────────────────────────────────────────
|
||||
// One section PER MISSION: a throw in one (a tile that never rendered under
|
||||
// --teeth, say) must not take the other four down with it, or the teeth
|
||||
// tally reports "reached" for assertions that never ran.
|
||||
let state;
|
||||
let before;
|
||||
await section('mission-environmental', async () => {
|
||||
// Full clear, not just the session: layer enables are DURABLE, so without it
|
||||
// an earlier mission's layers ride along and this stops measuring
|
||||
// Environmental alone (and its screenshot stops showing it alone).
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
before = await appState(page);
|
||||
await watchLoadingChip(page);
|
||||
await pick(page, 'environmental', { settle: ['earthquakes', 'local-firms'] });
|
||||
state = await appState(page);
|
||||
record('ENVIRONMENTAL leaves the detection override untouched',
|
||||
state.detectionOverridden === false, `_detectionUserOverridden=${state.detectionOverridden}`);
|
||||
record('a mission never auto-suppresses itself', state.durable === null, `durable=${state.durable}`);
|
||||
record('a mission never writes the detection-allocation pref',
|
||||
state.allocation === before.allocation, `${before.allocation} → ${state.allocation}`);
|
||||
record('ENVIRONMENTAL enables BOTH of its feeds', state.layers.earthquakes && state.layers['local-firms'],
|
||||
`quakes=${state.layers.earthquakes} fires=${state.layers['local-firms']}`);
|
||||
record('ENVIRONMENTAL loads real quake records', (state.counts.earthquakes ?? 0) > 0,
|
||||
`${state.counts.earthquakes} quakes`);
|
||||
record('ENVIRONMENTAL reaches the full-earth camera', state.heightKm !== null && state.heightKm > 12000,
|
||||
`${state.heightKm} km`);
|
||||
|
||||
/*
|
||||
* The tile optimizes for the FULLY CONFIGURED app, so the assertion splits
|
||||
* on what this server actually has rather than pretending one answer fits
|
||||
* both. The branch is reported, so a green run always says which one it was.
|
||||
*
|
||||
* KEYED — both datasets must actually arrive, and a failure banner in
|
||||
* that state is a real defect, so the chip IS asserted.
|
||||
* KEYLESS — the LAYER ROW reports KEY REQUIRED while the global batch
|
||||
* completes without presenting that deliberate configuration
|
||||
* state as a failed mission.
|
||||
*/
|
||||
const keyless = state.firmsError === 'KEY REQUIRED';
|
||||
console.log(` \x1b[2m FIRMS key state: ${keyless ? 'KEYLESS' : 'KEYED'} `
|
||||
+ `(row="${state.firmsError ?? 'none'}", count=${state.firmsCount})\x1b[0m`);
|
||||
if (keyless) {
|
||||
record(
|
||||
'KEYLESS: the FIRMS row tells the visitor the truth (KEY REQUIRED)',
|
||||
state.firmsError === 'KEY REQUIRED',
|
||||
`layer row reports "${state.firmsError}"`,
|
||||
);
|
||||
record(
|
||||
'KEYLESS: the quakes half of the tile still delivers in full',
|
||||
(state.counts.earthquakes ?? 0) > 0,
|
||||
`${state.counts.earthquakes} quakes`,
|
||||
);
|
||||
const chip = await readLoadingChip(page);
|
||||
const failed = chip.filter((entry) => /LOAD FAILED/i.test(entry));
|
||||
record(
|
||||
'KEYLESS: a missing optional FIRMS key never becomes a global load failure',
|
||||
failed.length === 0,
|
||||
failed.length
|
||||
? `chip showed: ${failed.join(' | ')}`
|
||||
: `chip states seen: ${chip.join(' → ') || 'none'}`,
|
||||
);
|
||||
} else {
|
||||
record(
|
||||
'KEYED: both datasets actually arrive',
|
||||
(state.counts.earthquakes ?? 0) > 0 && (state.firmsCount ?? 0) > 0,
|
||||
`${state.counts.earthquakes} quakes · ${state.firmsCount} fire detections`,
|
||||
);
|
||||
const chip = await readLoadingChip(page);
|
||||
const failed = chip.filter((entry) => /LOAD FAILED/i.test(entry));
|
||||
// A STALE served-from-cache FIRMS payload is degraded, not failed, and it
|
||||
// still carries data — so the failure test is the banner, not any
|
||||
// non-empty error string.
|
||||
record(
|
||||
'KEYED: no unexpected global failure while the mission runs',
|
||||
failed.length === 0,
|
||||
failed.length
|
||||
? `chip showed: ${failed.join(' | ')}`
|
||||
: `firms row="${state.firmsError ?? 'none'}"; chip states seen: ${chip.join(' → ') || 'none'}`,
|
||||
);
|
||||
}
|
||||
record(
|
||||
'no mission turns on the bundled infrastructure layers any more',
|
||||
!state.layers['local-datacenters'] && !state.layers['local-dams']
|
||||
&& !state.layers['telegeography-submarine-cables'],
|
||||
'the removed tile left nothing enabling ~5,700 entities at globe scale',
|
||||
);
|
||||
shots.push(await shoot(page, 'mission-environmental'));
|
||||
});
|
||||
|
||||
await section('mission-contacts', async () => {
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
await pick(page, 'contacts');
|
||||
state = await appState(page);
|
||||
record('LIVE CONTACTS activates the Contacts context mode', state.contextMode === 'flights',
|
||||
`contextMode=${state.contextMode}`);
|
||||
const panelOpen = await page.evaluate(() => {
|
||||
const panel = document.getElementById('global-context-panel');
|
||||
return !!panel && !panel.classList.contains('collapsed');
|
||||
});
|
||||
record('LIVE CONTACTS reveals the Context panel', panelOpen);
|
||||
record('LIVE CONTACTS still leaves the detection override untouched',
|
||||
state.detectionOverridden === false, `_detectionUserOverridden=${state.detectionOverridden}`);
|
||||
shots.push(await shoot(page, 'mission-contacts'));
|
||||
});
|
||||
|
||||
await section('mission-space', async () => {
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
await pick(page, 'space-missions');
|
||||
state = await appState(page);
|
||||
record('SPACE MISSIONS activates its context mode', state.contextMode === 'space-missions',
|
||||
`contextMode=${state.contextMode}`);
|
||||
shots.push(await shoot(page, 'mission-space-missions'));
|
||||
});
|
||||
|
||||
await section('mission-explore', async () => {
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
const beforeExplore = await appState(page);
|
||||
await pick(page, 'explore');
|
||||
state = await appState(page);
|
||||
record('EXPLORE MANUALLY enables no layer and no context mode',
|
||||
Object.values(state.layers).every((on) => !on) && state.contextMode === null,
|
||||
Object.entries(state.layers).filter(([, on]) => on).map(([id]) => id).join(', ') || 'nothing enabled');
|
||||
// The real claim is that Explore issues no camera command of its own: the
|
||||
// arrival fly-in owns the camera and must still be heading down, not out.
|
||||
record('EXPLORE MANUALLY never commandeers the camera',
|
||||
(state.heightKm ?? 0) < 12000 && (state.heightKm ?? 1e9) <= (beforeExplore.heightKm ?? 1e9),
|
||||
`${beforeExplore.heightKm} → ${state.heightKm} km (globe would be 18000)`);
|
||||
shots.push(await shoot(page, 'mission-explore'));
|
||||
});
|
||||
|
||||
// ── Viewports ───────────────────────────────────────────────────────────
|
||||
await section('viewports', async () => {
|
||||
// Portrait phone, LANDSCAPE phone (the short viewport that clipped the card
|
||||
// at both ends before it grew a max-height), and a small desktop window.
|
||||
const VIEWPORTS = [
|
||||
{ name: 'mobile-375', width: 375, height: 812, dpr: 2, label: '375 x 812 portrait phone' },
|
||||
{ name: 'landscape-667', width: 667, height: 390, dpr: 2, label: '667 x 390 landscape phone' },
|
||||
{ name: 'desktop-800', width: 800, height: 600, dpr: 1, label: '800 x 600 small desktop' },
|
||||
];
|
||||
for (const vp of VIEWPORTS) {
|
||||
await page.setViewport({ width: vp.width, height: vp.height, deviceScaleFactor: vp.dpr });
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
const box = await page.evaluate((sel) => {
|
||||
const node = document.querySelector(sel);
|
||||
if (!node) return null;
|
||||
const rect = node.getBoundingClientRect();
|
||||
const list = node.querySelector('.first-run-choices');
|
||||
const checkbox = node.querySelector('[data-first-run-suppress]');
|
||||
const cb = checkbox?.getBoundingClientRect();
|
||||
return {
|
||||
top: Math.round(rect.top), bottom: Math.round(rect.bottom),
|
||||
left: Math.round(rect.left), right: Math.round(rect.right),
|
||||
vw: window.innerWidth, vh: window.innerHeight,
|
||||
listScrolls: !!list && list.scrollHeight > list.clientHeight + 1,
|
||||
// The checkbox and the status line live OUTSIDE the scrolling list, so
|
||||
// they must be on screen at every height, scrollable list or not.
|
||||
checkboxOnScreen: !!cb && cb.top >= 0 && cb.bottom <= window.innerHeight,
|
||||
};
|
||||
}, LAUNCHER);
|
||||
const fits = !!box && box.top >= 0 && box.bottom <= box.vh
|
||||
&& box.left >= 0 && box.right <= box.vw;
|
||||
record(
|
||||
`the launcher fits a ${vp.label} with no clipping`,
|
||||
fits,
|
||||
box ? `${box.left},${box.top} → ${box.right},${box.bottom} in ${box.vw}x${box.vh}${box.listScrolls ? ' (list scrolls)' : ''}` : 'absent',
|
||||
);
|
||||
record(`"Don't show this again" stays on screen at ${vp.width}x${vp.height}`,
|
||||
!!box?.checkboxOnScreen);
|
||||
|
||||
// Discoverability: if the list scrolls, the LAST tile must still peek
|
||||
// above the fold. A fold that lands exactly between tiles looks like a
|
||||
// complete list of four, and the fifth mission is simply never found.
|
||||
const peek = await page.evaluate((sel) => {
|
||||
const node = document.querySelector(sel);
|
||||
const list = node?.querySelector('.first-run-choices');
|
||||
if (!list) return null;
|
||||
list.scrollTop = 0;
|
||||
const tiles = [...list.querySelectorAll('[data-first-run-choice]')];
|
||||
const last = tiles[tiles.length - 1];
|
||||
if (!last) return null;
|
||||
const listBox = list.getBoundingClientRect();
|
||||
const lastBox = last.getBoundingClientRect();
|
||||
const visible = Math.max(0, Math.min(listBox.bottom, lastBox.bottom) - Math.max(listBox.top, lastBox.top));
|
||||
return {
|
||||
scrolls: list.scrollHeight > list.clientHeight + 1,
|
||||
fraction: lastBox.height ? visible / lastBox.height : 0,
|
||||
label: last.dataset.firstRunChoice,
|
||||
};
|
||||
}, LAUNCHER);
|
||||
record(
|
||||
`the last mission is discoverable at ${vp.width}x${vp.height}`,
|
||||
!!peek && (!peek.scrolls || peek.fraction >= 0.25),
|
||||
peek
|
||||
? `${peek.label} ${(peek.fraction * 100).toFixed(0)}% visible${peek.scrolls ? ' (list scrolls)' : ' (no scroll needed)'}`
|
||||
: 'absent',
|
||||
);
|
||||
|
||||
// Every tile plus the checkbox must still be reachable by Tab when the
|
||||
// list scrolls — and reaching one must bring it into view.
|
||||
const reach = await page.evaluate(async (sel) => {
|
||||
const node = document.querySelector(sel);
|
||||
if (!node) return null;
|
||||
const targets = [...node.querySelectorAll('[data-first-run-choice], [data-first-run-suppress]')];
|
||||
const offscreen = [];
|
||||
for (const target of targets) {
|
||||
target.focus();
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const rect = target.getBoundingClientRect();
|
||||
const host = node.getBoundingClientRect();
|
||||
if (document.activeElement !== target
|
||||
|| rect.bottom <= host.top || rect.top >= host.bottom) {
|
||||
offscreen.push(target.dataset.firstRunChoice || 'suppress-checkbox');
|
||||
}
|
||||
}
|
||||
return { count: targets.length, offscreen };
|
||||
}, LAUNCHER);
|
||||
// Derived, not a magic number: every rendered tile plus the checkbox.
|
||||
// A hardcoded count silently turns into a failure the day the menu
|
||||
// changes, which is exactly what it did when a tile was removed.
|
||||
const expectedFocusable = await page.$$eval(
|
||||
'[data-first-run-choice]', (nodes) => nodes.length,
|
||||
) + 1;
|
||||
record(
|
||||
`every tile and the checkbox are keyboard-reachable at ${vp.width}x${vp.height}`,
|
||||
!!reach && reach.count === expectedFocusable && reach.offscreen.length === 0,
|
||||
reach
|
||||
? `${reach.count}/${expectedFocusable} focusable${reach.offscreen.length ? `, unreachable: ${reach.offscreen.join(', ')}` : ''}`
|
||||
: 'absent',
|
||||
);
|
||||
// The reachability probe above tabs to the last tile and scrolls the list
|
||||
// with it. Put it back to the top so the taste-pass shot shows what a
|
||||
// visitor actually opens on.
|
||||
await page.evaluate((sel) => {
|
||||
const list = document.querySelector(sel)?.querySelector('.first-run-choices');
|
||||
if (list) list.scrollTop = 0;
|
||||
document.activeElement?.blur?.();
|
||||
}, LAUNCHER);
|
||||
await sleep(250);
|
||||
shots.push(await shoot(page, `launcher-${vp.name}`));
|
||||
}
|
||||
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
|
||||
});
|
||||
|
||||
await section('console', async () => {
|
||||
record('no console errors across the whole run', consoleErrors.length === 0,
|
||||
consoleErrors.slice(0, 3).join(' | ') || 'clean');
|
||||
});
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const failed = results.filter((entry) => !entry.ok);
|
||||
const sections = [...new Set(results.map((entry) => entry.section))];
|
||||
const tally = sections.map((name) => {
|
||||
const rows = results.filter((entry) => entry.section === name);
|
||||
return { name, total: rows.length, red: rows.filter((entry) => !entry.ok).length };
|
||||
});
|
||||
|
||||
console.log(`\n Screenshots: ${shots.join(', ')}`);
|
||||
console.log(`\n ${results.length - failed.length}/${results.length} checks passed\n`);
|
||||
|
||||
if (!TEETH) process.exit(failed.length === 0 ? 0 : 1);
|
||||
|
||||
/*
|
||||
* NEGATIVE CONTROL VERDICT.
|
||||
*
|
||||
* Two things have to be true for the control to mean anything, and the old
|
||||
* version checked neither properly: it exited ZERO after a single throw ended
|
||||
* the run at 9 of 38 checks, so most assertions were never even reached.
|
||||
*
|
||||
* (a) the harness must REPORT FAILURE under the stub — a run with the
|
||||
* launcher removed is a failing run, and it exits non-zero like one;
|
||||
* (b) EVERY section must produce at least one red — a section that stays
|
||||
* fully green without the launcher is not testing the launcher.
|
||||
*
|
||||
* Because (a) means the process always exits non-zero here, the control's own
|
||||
* verdict is carried in the exit CODE: 1 = healthy, 2 = the control is broken.
|
||||
*/
|
||||
console.log(' TEETH — per-section reachability (each section must go red):');
|
||||
for (const row of tally) {
|
||||
const hygiene = HYGIENE_SECTIONS.includes(row.name);
|
||||
const ok = row.red > 0 || hygiene;
|
||||
const tag = hygiene ? '\x1b[2mHYGN\x1b[0m' : (row.red > 0 ? '\x1b[32mRED \x1b[0m' : '\x1b[31mNONE\x1b[0m');
|
||||
void ok;
|
||||
console.log(` [${tag}] ${row.name}: ${row.red}/${row.total} red`);
|
||||
}
|
||||
const toothless = tally.filter((row) => row.red === 0 && !HYGIENE_SECTIONS.includes(row.name));
|
||||
const unreached = EXPECTED_SECTIONS.filter((name) => !sections.includes(name));
|
||||
const healthy = failed.length > 0 && toothless.length === 0 && unreached.length === 0;
|
||||
if (unreached.length) console.log(`\n UNREACHED sections: ${unreached.join(', ')}`);
|
||||
if (toothless.length) console.log(` TOOTHLESS sections: ${toothless.map((row) => row.name).join(', ')}`);
|
||||
console.log(healthy
|
||||
? `\n TEETH HEALTHY: harness failed as required (${failed.length} red) and every one of ${tally.length} sections went red. Exit 1 by design.\n`
|
||||
: '\n TEETH BROKEN: the control did not collapse the way it claims to. Exit 2.\n');
|
||||
process.exit(healthy ? 1 : 2);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* scripts/qa-floor-hold.mjs — a grounded contact holds its floor through a
|
||||
* terrain-proxy outage (field incident, 2026-08-21).
|
||||
*
|
||||
* Reproduces the incident end to end against the RENDERED mesh:
|
||||
*
|
||||
* phase A the terrain proxy is healthy. A GROUNDED contact reporting no
|
||||
* altitude at all (the AXEL21 profile — a few knots, alt unknown)
|
||||
* settles onto its resolved floor.
|
||||
* phase B every `/api/terrain/heights` request answers 504 — the same
|
||||
* "[terrain-heights-proxy] refresh incomplete" the server logged —
|
||||
* and the contact taxis into cells that were never warmed.
|
||||
*
|
||||
* The contact must stay ON the mesh through phase B. Without the hold it falls
|
||||
* to the geoid, which at an inland field is ~150 m of burial.
|
||||
*
|
||||
* node scripts/qa-floor-hold.mjs # real GPU (ANGLE/Metal)
|
||||
* node scripts/qa-floor-hold.mjs --headful # watch it
|
||||
* QA_BASE_URL=http://localhost:4257 node scripts/qa-floor-hold.mjs
|
||||
*
|
||||
* GPU BACKEND MATTERS. `scene.sampleHeight` answers on both backends, but
|
||||
* software rasterization streams a coarser LOD. Measured directly, one probe
|
||||
* point on the Austin apron read ~20.9 m under swiftshader against ~168.4 m
|
||||
* under Metal — a backend comparison at one point, not to be confused with the
|
||||
* same-backend run-to-run spread (122.1 m vs 20.6 m on a cell centre) that
|
||||
* retired the hold chain's unvalidated tier. The
|
||||
* default is the real GPU. Under `--angle=swiftshader` the rendered-mesh oracle
|
||||
* is reported as unavailable rather than trusted, and the run falls back to the
|
||||
* bare-earth DEM (fetched server-side, so the simulated outage cannot reach
|
||||
* it) — a weaker but still decisive check, since the geoid sits ~150 m below it.
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const APP_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const argv = Object.fromEntries(process.argv.slice(2)
|
||||
.map((a) => a.replace(/^--/, '').split('=')).map(([k, v]) => [k, v ?? true]));
|
||||
const ANGLE = String(argv.angle || 'metal');
|
||||
const HEADFUL = !!argv.headful;
|
||||
// Austin airport apron by default — the site qa-floor-verify pins, where the
|
||||
// mesh sits ~150 m above the geoid and burial is unmistakable.
|
||||
const SITE = {
|
||||
lat: Number.isFinite(Number(argv.lat)) ? Number(argv.lat) : 30.1975,
|
||||
lon: Number.isFinite(Number(argv.lon)) ? Number(argv.lon) : -97.6660,
|
||||
};
|
||||
const ICAO = 'aa0f01';
|
||||
const OUT_DIR = 'qa-shots/floorhold';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(` [${ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
const chrome = (() => { try { return puppeteer.executablePath(); } catch { return undefined; } })();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
executablePath: chrome,
|
||||
args: ['--use-gl=angle', `--use-angle=${ANGLE}`, '--enable-webgl',
|
||||
'--ignore-gpu-blocklist', '--no-sandbox'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1400, height: 900 });
|
||||
page.on('pageerror', (e) => console.log('[pageerror]', e.message));
|
||||
|
||||
await page.evaluateOnNewDocument((site, icao) => {
|
||||
window.__CONTACT = { icao, lat: site.lat, lon: site.lon };
|
||||
window.__TERRAIN = { ok: 0, failed: 0 };
|
||||
window.__TERRAIN_FAIL = false;
|
||||
const realFetch = window.fetch.bind(window);
|
||||
const json = (o) => new Response(JSON.stringify(o), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
window.fetch = (input, init) => {
|
||||
const raw = typeof input === 'string' || input instanceof URL ? String(input) : input?.url;
|
||||
let url; try { url = new URL(raw, window.location.href); } catch { return realFetch(input, init); }
|
||||
const app = url.origin === window.location.origin;
|
||||
if (app && url.pathname === '/api/terrain/heights') {
|
||||
if (window.__TERRAIN_FAIL) {
|
||||
window.__TERRAIN.failed += 1;
|
||||
return Promise.resolve(new Response('upstream timeout', { status: 504 }));
|
||||
}
|
||||
window.__TERRAIN.ok += 1;
|
||||
return realFetch(input, init);
|
||||
}
|
||||
if (app && url.pathname === '/api/opensky') {
|
||||
const c = window.__CONTACT;
|
||||
const t = Math.floor(Date.now() / 1000);
|
||||
// baro_altitude (7) and geo_altitude (13) both null, on_ground true.
|
||||
return Promise.resolve(json({
|
||||
time: t,
|
||||
states: [[c.icao, 'AXEL21', 'Synthetica', t, t, c.lon, c.lat,
|
||||
null, true, 1.5, 90, 0, null, null, null, false, 0]],
|
||||
}));
|
||||
}
|
||||
if (app && url.pathname === '/api/opensky-track') return Promise.resolve(json({ path: [] }));
|
||||
if (app && /^\/api\/adsbdb\//.test(url.pathname)) return Promise.resolve(json({}));
|
||||
return realFetch(input, init);
|
||||
};
|
||||
}, SITE, ICAO);
|
||||
|
||||
console.log(`\nFloor hold through a terrain outage\n App : ${APP_URL}\n GPU : ANGLE/${ANGLE}\n Site : ${SITE.lat}, ${SITE.lon}\n`);
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 120000 });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 150000 });
|
||||
await sleep(12000); // let the boot fly-to settle before pinning
|
||||
|
||||
await page.evaluate((site) => { window.__SITE = site; }, SITE);
|
||||
const pin = () => page.evaluate(() => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
v.camera.cancelFlight();
|
||||
v.camera.setView({
|
||||
destination: v.scene.globe.ellipsoid.cartographicToCartesian(
|
||||
C.fromDegrees(window.__SITE.lon, window.__SITE.lat - 0.008, 900),
|
||||
),
|
||||
orientation: { heading: 0, pitch: -0.45, roll: 0 },
|
||||
});
|
||||
v.camera.moveEnd.raiseEvent();
|
||||
});
|
||||
await pin();
|
||||
await page.evaluate(async () => { await window.__godsEyeView.dataManager.toggle('flights'); });
|
||||
|
||||
/** Reads the contact's rendered height and the rendered mesh beneath it. */
|
||||
const measure = () => page.evaluate(async (icao) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
const sprites = []; const models = []; let bb = null;
|
||||
const walk = (coll) => {
|
||||
for (let i = 0; i < coll.length; i++) {
|
||||
let p; try { p = coll.get(i); } catch { continue; }
|
||||
if (!p) continue;
|
||||
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
|
||||
if (p.image !== undefined && p.alignedAxis !== undefined) {
|
||||
sprites.push(p); if (p.id === icao) bb = p;
|
||||
} else if (p.boundingSphere && p.modelMatrix) models.push(p);
|
||||
}
|
||||
};
|
||||
try { walk(v.scene.primitives); } catch { /* mid-teardown */ }
|
||||
if (!bb?.position) return { error: 'contact billboard not found' };
|
||||
const carto = C.fromCartesian(bb.position);
|
||||
const lat = carto.latitude * 180 / Math.PI;
|
||||
const lon = carto.longitude * 180 / Math.PI;
|
||||
// `sampleHeight` runs an offscreen PICK pass, so it needs the scene to be
|
||||
// rendering. This app parks its render loop when idle (the wave-2 governor),
|
||||
// and a probe fired against a parked scene — or one whose tiles are still
|
||||
// streaming after a camera move — reads `undefined`. Pump a frame through the
|
||||
// app's own governor hook before each attempt, exactly as the fly_route
|
||||
// harness does, and accept the coarse cell centre as a fallback point.
|
||||
const pump = () => new Promise((resolve) => {
|
||||
try { window.__godsEyeView.requestRender?.(); } catch { /* no governor */ }
|
||||
try { v.scene.requestRender(); } catch { /* explicit-render off */ }
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
||||
});
|
||||
let mesh = null;
|
||||
for (let attempt = 0; attempt < 10 && mesh == null; attempt += 1) {
|
||||
await pump();
|
||||
for (const [pLat, pLon] of [[lat, lon], [+lat.toFixed(3), +lon.toFixed(3)]]) {
|
||||
for (const exclude of [[...sprites, ...models], []]) {
|
||||
try {
|
||||
const h = v.scene.sampleHeight(C.fromDegrees(pLon, pLat, 0), exclude);
|
||||
if (Number.isFinite(h)) { mesh = h; break; }
|
||||
} catch { /* mid-stream */ }
|
||||
}
|
||||
if (mesh != null) break;
|
||||
}
|
||||
if (mesh == null) await new Promise((r) => setTimeout(r, 1200));
|
||||
}
|
||||
return {
|
||||
spriteH: carto.height, meshH: mesh, shown: bb.show === true,
|
||||
lat: +lat.toFixed(5), lon: +lon.toFixed(5), terrain: { ...window.__TERRAIN },
|
||||
};
|
||||
}, ICAO);
|
||||
|
||||
/** Bare-earth ground truth, fetched SERVER-SIDE so the page's simulated outage
|
||||
* cannot touch it. The photoreal mesh sits ABOVE bare earth, so this is the
|
||||
* weaker of the two oracles — but the geoid is ~150 m below it, so burial is
|
||||
* still unmistakable. */
|
||||
const truth = new Map();
|
||||
async function demTruth(lat, lon) {
|
||||
const key = `${lat.toFixed(4)},${lon.toFixed(4)}`;
|
||||
if (truth.has(key)) return truth.get(key);
|
||||
let h = null;
|
||||
try {
|
||||
const res = await fetch(`${APP_URL}/api/terrain/heights?points=${lon.toFixed(5)},${lat.toFixed(5)}`);
|
||||
if (res.ok) {
|
||||
const v = Number((await res.json())?.results?.[0]?.ellipsoid);
|
||||
if (Number.isFinite(v)) h = v;
|
||||
}
|
||||
} catch { /* the oracle is best-effort too */ }
|
||||
truth.set(key, h);
|
||||
return h;
|
||||
}
|
||||
|
||||
const samples = [];
|
||||
async function sample(phase, note) {
|
||||
const m = await measure();
|
||||
if (m.error) { console.log(` ${phase} ${note}: ${m.error}`); return null; }
|
||||
const dem = await demTruth(m.lat, m.lon);
|
||||
const row = {
|
||||
phase, note, ...m, demH: dem,
|
||||
meshClearM: m.meshH != null ? +(m.spriteH - m.meshH).toFixed(2) : null,
|
||||
demClearM: dem != null ? +(m.spriteH - dem).toFixed(2) : null,
|
||||
};
|
||||
samples.push(row);
|
||||
console.log(` ${phase} ${note}: @${m.lat},${m.lon} sprite=${m.spriteH.toFixed(1)}`
|
||||
+ ` mesh=${m.meshH?.toFixed?.(1) ?? 'n/a'} (${row.meshClearM})`
|
||||
+ ` DEM=${dem?.toFixed?.(1) ?? 'n/a'} (${row.demClearM})`
|
||||
+ ` terrain=${JSON.stringify(m.terrain)}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
console.log('\nphase A — terrain healthy, warming cells (3 polls)');
|
||||
for (let i = 0; i < 3; i++) { await sleep(32000); await pin(); }
|
||||
await sample('A', 'warm');
|
||||
fs.writeFileSync(path.join(OUT_DIR, `hold-A-warm-${ANGLE}.png`), await page.screenshot({ type: 'png' }));
|
||||
|
||||
console.log('\nphase B — every terrain request answers 504 from here');
|
||||
await page.evaluate(() => { window.__TERRAIN_FAIL = true; });
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
await page.evaluate(() => { window.__CONTACT.lon += 0.0016; }); // ~150 m east per poll
|
||||
await sleep(32000);
|
||||
await pin();
|
||||
await sample('B', `poll+${i}`);
|
||||
}
|
||||
fs.writeFileSync(path.join(OUT_DIR, `hold-B-outage-${ANGLE}.png`), await page.screenshot({ type: 'png' }));
|
||||
await browser.close();
|
||||
|
||||
console.log('');
|
||||
const outage = samples.filter((s) => s.phase === 'B');
|
||||
const meshRows = outage.filter((s) => s.meshClearM != null);
|
||||
const demRows = outage.filter((s) => s.demClearM != null);
|
||||
record('the terrain proxy really went down mid-run',
|
||||
outage.length > 0 && outage[outage.length - 1].terrain.failed > 0,
|
||||
`${outage[outage.length - 1]?.terrain?.failed ?? 0} requests answered 504`);
|
||||
record('the contact stayed visible throughout', outage.every((s) => s.shown === true));
|
||||
if (meshRows.length) {
|
||||
const worst = Math.min(...meshRows.map((s) => s.meshClearM));
|
||||
record('the contact never rendered below the RENDERED MESH', worst >= -0.5,
|
||||
`worst clearance ${worst} m across ${meshRows.length} samples`);
|
||||
} else {
|
||||
record('rendered-mesh oracle available', false,
|
||||
`sampleHeight never answered on ANGLE/${ANGLE} — falling back to the bare-earth DEM`);
|
||||
}
|
||||
if (demRows.length) {
|
||||
const worst = Math.min(...demRows.map((s) => s.demClearM));
|
||||
record('the contact never fell to the geoid (bare-earth DEM oracle)', worst >= -2,
|
||||
`worst clearance ${worst} m above bare earth; the geoid here is ~150 m below it`);
|
||||
}
|
||||
fs.writeFileSync(path.join(OUT_DIR, `hold-${ANGLE}.json`), JSON.stringify(samples, null, 2));
|
||||
console.log(`\n evidence: ${OUT_DIR}/hold-*-${ANGLE}.png, ${OUT_DIR}/hold-${ANGLE}.json`);
|
||||
|
||||
const failed = results.filter((r) => r.ok === false).length;
|
||||
console.log(`\n RESULT: ${results.length - failed} passed, ${failed} failed\n`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,209 @@
|
||||
// scripts/qa-floor-verify.mjs — live floor verification at AUS (round 5).
|
||||
// Pins the camera at Austin airport, enables flights, waits ~3 polls, then
|
||||
// measures every nearby contact's ACTUAL visible anchor against the rendered
|
||||
// mesh (sprite- and model-excluded scene.sampleHeight probes). Model-owned
|
||||
// contacts deliberately keep their hidden billboard at the raw sensor datum,
|
||||
// so getNearby().position is not a render-height oracle for those contacts.
|
||||
// Caught the mesh-latch coarse-LOD poison and the taxiing cold-cell regression
|
||||
// on 2026-07-06.
|
||||
// Run: node scripts/qa-floor-verify.mjs (dev server on :4173, real GPU best)
|
||||
// with the poison fix + simplified chain live.
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
|
||||
// QA_BASE_URL matches the sibling harnesses (qa-height-datum / qa-cctv-v2) so
|
||||
// each candidate can verify against its own dev server instead of :4173.
|
||||
const APP_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
// CLI: --lat --lon --floor-min --floor-max (defaults: Austin airport)
|
||||
const argv = Object.fromEntries(process.argv.slice(2).map((a) => a.split('=')).filter((x) => x.length === 2).map(([k, v]) => [k.replace(/^--/, ''), Number(v)]));
|
||||
const SITE = {
|
||||
lat: Number.isFinite(argv.lat) ? argv.lat : 30.197,
|
||||
lon: Number.isFinite(argv.lon) ? argv.lon : -97.666,
|
||||
floorMin: Number.isFinite(argv['floor-min']) ? argv['floor-min'] : 100,
|
||||
floorMax: Number.isFinite(argv['floor-max']) ? argv['floor-max'] : 250,
|
||||
};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system Chrome
|
||||
// (2026-07-30 lesson, already applied to the other harnesses): /Applications
|
||||
// auto-updates underneath the harnesses and its software-GL behavior shifts
|
||||
// across majors.
|
||||
const chrome = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
].filter(Boolean).find((p) => fs.existsSync(p));
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new', executablePath: chrome,
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist', '--no-sandbox'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1400, height: 900 });
|
||||
page.on('pageerror', (e) => console.log('[pageerror]', e.message));
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 120000 });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.dataManager && window.__godsEyeView?.viewer, { timeout: 150000 });
|
||||
await sleep(2000);
|
||||
|
||||
// Let the app's boot fly-to fully settle FIRST (it was overriding an early
|
||||
// setView, leaving the camera away from Austin — every probe then read
|
||||
// coarse global tiles at ~25 m and the oracle was garbage).
|
||||
await sleep(12000);
|
||||
await page.evaluate((site) => { window.__QA_SITE = site; }, SITE);
|
||||
const camPin = async () => page.evaluate(() => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
v.camera.cancelFlight();
|
||||
v.camera.setView({ destination: v.scene.globe.ellipsoid.cartographicToCartesian(C.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 3000)) });
|
||||
v.camera.moveEnd.raiseEvent();
|
||||
const c = v.camera.positionCartographic;
|
||||
return { lat: +(c.latitude * 180 / Math.PI).toFixed(3), lon: +(c.longitude * 180 / Math.PI).toFixed(3), h: Math.round(c.height) };
|
||||
});
|
||||
console.log('camera pinned:', JSON.stringify(await camPin()));
|
||||
await page.evaluate(async () => { await window.__godsEyeView.dataManager.toggle('flights'); });
|
||||
console.log('waiting 100s: tiles stream + 3 polls...');
|
||||
await sleep(100000);
|
||||
// re-pin + report readiness right before probing
|
||||
console.log('camera at probe time:', JSON.stringify(await camPin()));
|
||||
const ready = await page.evaluate(() => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const prims = v.scene.primitives;
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
const p = prims.get(i);
|
||||
if (p && p.tilesLoaded !== undefined && p.show) return { tilesLoaded: !!p.tilesLoaded };
|
||||
}
|
||||
return { tilesLoaded: null };
|
||||
});
|
||||
console.log('tileset ready:', JSON.stringify(ready));
|
||||
await sleep(8000);
|
||||
|
||||
const report = await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const v = gev.viewer;
|
||||
const layer = gev.dataManager.layers.get('flights')?.module;
|
||||
if (!layer) return { err: 'no flights module' };
|
||||
const ell = v.scene.globe.ellipsoid;
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
const center = ell.cartographicToCartesian(C.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 200));
|
||||
const nearby = layer.getNearby(center, 15000, 60);
|
||||
// getNearby() intentionally reports the raw hidden-billboard position for an
|
||||
// untracked contact whose 3D model owns the visual. The detection surface is
|
||||
// already welded to whichever primitive actually owns that visual: model
|
||||
// centre, tracked visual, or billboard. Reuse that production render anchor
|
||||
// here instead of treating the deliberately unfloored raw datum as buried.
|
||||
const visualByIcao = new Map(layer.getDetectableObjects().map((object) => [
|
||||
String(object.sourceId || '').trim().toLowerCase(),
|
||||
object.position,
|
||||
]));
|
||||
// Exclude EVERY billboard from the probes — sprites are pickable, so an
|
||||
// unexcluded probe can return another aircraft's height as "the mesh".
|
||||
// Exclude every fleet/tracked 3D Model too: getNearby() also returns contacts
|
||||
// whose billboard is hidden because a model owns the visual, and those probes
|
||||
// would otherwise land on aircraft geometry. `activeAnimations` +
|
||||
// `minimumPixelSize` are Model-only — the 3D-Tiles tileset has neither, and
|
||||
// excluding IT would leave sampleHeight with no mesh to read at all.
|
||||
const excludes = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let pr; try { pr = coll.get(i); } catch { continue; }
|
||||
if (!pr) continue;
|
||||
if (typeof pr.length === 'number' && typeof pr.get === 'function') { walk(pr); continue; }
|
||||
if (pr.image !== undefined && pr.alignedAxis !== undefined) { excludes.push(pr); continue; }
|
||||
if (pr.activeAnimations !== undefined && pr.minimumPixelSize !== undefined) excludes.push(pr);
|
||||
}
|
||||
};
|
||||
walk(v.scene.primitives);
|
||||
const out = [];
|
||||
for (const p of nearby) {
|
||||
const raw = ell.cartesianToCartographic(p.position);
|
||||
const visualPosition = visualByIcao.get(String(p.icao24 || '').trim().toLowerCase());
|
||||
if (!visualPosition) {
|
||||
out.push({
|
||||
id: p.id,
|
||||
icao24: p.icao24,
|
||||
rawDatumAltM: +raw.height.toFixed(1),
|
||||
renderAltM: null,
|
||||
meshM: null,
|
||||
aboveMeshM: null,
|
||||
visualOffsetM: null,
|
||||
missingVisualAnchor: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const visual = ell.cartesianToCartographic(visualPosition);
|
||||
const latDeg = visual.latitude * 180 / Math.PI, lonDeg = visual.longitude * 180 / Math.PI;
|
||||
let meshH = null;
|
||||
try {
|
||||
const h = v.scene.sampleHeight(C.fromDegrees(lonDeg, latDeg), excludes);
|
||||
if (Number.isFinite(h)) meshH = h;
|
||||
} catch { /* ignore */ }
|
||||
out.push({
|
||||
id: p.id,
|
||||
icao24: p.icao24,
|
||||
rawDatumAltM: +raw.height.toFixed(1),
|
||||
renderAltM: +visual.height.toFixed(1),
|
||||
meshM: meshH != null ? +meshH.toFixed(1) : null,
|
||||
aboveMeshM: meshH != null ? +(visual.height - meshH).toFixed(1) : null,
|
||||
visualOffsetM: +(visual.height - raw.height).toFixed(1),
|
||||
missingVisualAnchor: false,
|
||||
});
|
||||
}
|
||||
// Visibility census (round 6): getNearby only returns contacts a sprite or a
|
||||
// 3D model is actually drawing, so also walk the raw collections — a culled
|
||||
// "invisible plane" shows up here.
|
||||
let shown = 0, hidden = 0;
|
||||
const censusWalk = (coll) => {
|
||||
const n = coll.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let pr; try { pr = coll.get(i); } catch { continue; }
|
||||
if (!pr) continue;
|
||||
if (typeof pr.length === 'number' && typeof pr.get === 'function') { censusWalk(pr); continue; }
|
||||
if (pr.image === undefined || pr.alignedAxis === undefined) continue;
|
||||
const cc = ell.cartesianToCartographic(pr.position);
|
||||
const la = cc.latitude * 180 / Math.PI, lo = cc.longitude * 180 / Math.PI;
|
||||
if (Math.abs(la - window.__QA_SITE.lat) > 0.08 || Math.abs(lo - window.__QA_SITE.lon) > 0.10) continue;
|
||||
if (pr.show) shown++; else hidden++;
|
||||
}
|
||||
};
|
||||
censusWalk(v.scene.primitives);
|
||||
return {
|
||||
ausContacts: out.length,
|
||||
contacts: out,
|
||||
missingVisualAnchors: out.filter((contact) => contact.missingVisualAnchor).length,
|
||||
spritesShown: shown,
|
||||
spritesHidden: hidden,
|
||||
};
|
||||
});
|
||||
console.log(JSON.stringify(report, null, 1));
|
||||
|
||||
// verdict — plausible mesh readings only (a wild meshM means the probe ray
|
||||
// missed the tileset entirely; those rows prove nothing either way)
|
||||
// Trust only probes that read a plausible AUS-area surface (~100..250 m
|
||||
// ellipsoidal) — anything else hit a coarse tile or nothing (proves nothing).
|
||||
const lows = (report.contacts || []).filter((c) =>
|
||||
c.renderAltM < SITE.floorMax + 450 && c.aboveMeshM != null && c.meshM > SITE.floorMin && c.meshM < SITE.floorMax);
|
||||
const buried = lows.filter((c) => c.aboveMeshM < -2);
|
||||
const missingVisuals = (report.contacts || []).filter((c) => c.missingVisualAnchor);
|
||||
console.log(`low contacts with plausible mesh readings: ${lows.length}; buried (< -2m): ${buried.length}`);
|
||||
for (const b of buried) {
|
||||
console.log(` BURIED ${b.id}: render ${b.renderAltM} m vs mesh ${b.meshM} m (${b.aboveMeshM} m)`);
|
||||
}
|
||||
for (const missing of missingVisuals) {
|
||||
console.log(` MISSING VISUAL ANCHOR ${missing.id} (${missing.icao24})`);
|
||||
}
|
||||
// A measured burial is always a failure. Otherwise, no plausible readings or
|
||||
// any missing render anchor is inconclusive: the harness must never turn an
|
||||
// unmeasured visible contact into a false pass.
|
||||
const verdict = buried.length > 0
|
||||
? 'FAIL'
|
||||
: (lows.length === 0 || missingVisuals.length > 0 ? 'INCONCLUSIVE' : 'PASS');
|
||||
console.log(`VERDICT: ${verdict}`);
|
||||
await browser.close();
|
||||
// Exit code (2026-08-19): this harness printed VERDICT: FAIL and still exited 0,
|
||||
// so a real regression (taxiing contacts rendering under the mesh) stayed
|
||||
// invisible to every automated caller. Now it reports like its siblings
|
||||
// (qa-height-datum / qa-cctv-v2): 0 = PASS, 1 = FAIL, 2 = could-not-measure.
|
||||
// INCONCLUSIVE is deliberately NOT 0 — a run that measured no usable contacts
|
||||
// proves nothing, and must not read as a pass. The VERDICT line is unchanged,
|
||||
// so anything scraping stdout (the L9 QA matrix) is unaffected.
|
||||
process.exit(verdict === 'PASS' ? 0 : (verdict === 'FAIL' ? 1 : 2));
|
||||
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Are the floor-hold pins actually load-bearing?
|
||||
*
|
||||
* A pin that is red only when you delete the whole feature proves very little.
|
||||
* This reverts each fix INDIVIDUALLY — the smallest edit that reintroduces the
|
||||
* original defect — and requires the floor suites to go red for it. Every entry
|
||||
* names the defect it restores, so the count is reproducible rather than
|
||||
* asserted in a commit message.
|
||||
*
|
||||
* node scripts/qa-floorhold-mutations.mjs
|
||||
*
|
||||
* Restores every file on exit, including on failure.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const TESTS = [
|
||||
'src/data/flights.test.mjs',
|
||||
'src/data/groundFloor.test.mjs',
|
||||
'src/data/meshFloorSampler.test.mjs',
|
||||
'src/data/renderAltitude.test.mjs',
|
||||
];
|
||||
|
||||
const FLIGHTS = 'src/data/flights.js';
|
||||
const FLOOR = 'src/data/groundFloor.js';
|
||||
const ALT = 'src/data/renderAltitude.js';
|
||||
|
||||
/** @type {Array<{defect: string, edits: Array<{file: string, from: string, to: string}>}>} */
|
||||
const MUTATIONS = [
|
||||
{
|
||||
defect: 'the display hold is gone — a cold cell clamps nothing (the original bug)',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (state.heldTier === \'own\' && !state.seeded) return state.heldM;',
|
||||
to: ' if (true) return null;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'the adjacent-cell tier is gone',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' const near = neighborFloorM(cell);',
|
||||
to: ' const near = null;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'a borrowed floor is owned forever — no upgrade when a better one warms',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (state.heldTier === \'own\' && !state.seeded) return state.heldM;',
|
||||
to: ' if (state.heldTier && !state.seeded) return state.heldM;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// The round-1 shape verbatim: memo on the RAW cell floor, checked BEFORE
|
||||
// the hold chain runs. A parked contact whose cell never warms then
|
||||
// returns its own unresolved answer forever.
|
||||
defect: 'a stationary unresolved contact memo-returns before the chain can retry',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' const floor = cachedGroundFloor(cell.lat, cell.lon);',
|
||||
to: ' const floor = cachedGroundFloor(cell.lat, cell.lon);\n'
|
||||
+ ' if (state && state.rawFloorM === floor\n'
|
||||
+ ' && Cesium.Cartesian3.equals(pos, state.in)) return state.out || pos;',
|
||||
},
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' next.effectiveM = effective;',
|
||||
to: ' next.effectiveM = effective;\n next.rawFloorM = floor;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'the hold survives an airborne / model-owned interval',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' _retireDisplayFloorState(icao24, nowMs);\n return pos;',
|
||||
to: ' return pos;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'the held floor is stretched without bound (no drift limit)',
|
||||
edits: [
|
||||
{ file: FLIGHTS, from: ' <= HELD_FLOOR_MAX_DRIFT_KM)) {', to: ' <= Infinity)) {' },
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'a hold release SNAPS instead of easing',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
|
||||
to: ' if (false && next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'rises are eased too — time spent under the mesh',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (!Number.isFinite(effective) || effective >= next.easedM) {',
|
||||
to: ' if (!Number.isFinite(effective)) {',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'the ease escapes its release scope — every floor drop eased',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
|
||||
to: ' if (next.easedM == null && Number.isFinite(stoodOnM)',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// The owner-playtest regression: a roof neighbour taken as ground.
|
||||
defect: 'the neighbour lean takes the HIGHEST cell and floats a contact onto a roof',
|
||||
edits: [
|
||||
{
|
||||
file: FLOOR,
|
||||
from: ' if (lowest == null || h < lowest) lowest = h;',
|
||||
to: ' if (lowest == null || h > lowest) lowest = h;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'a LONE neighbour is borrowed, with nothing to check it against',
|
||||
edits: [
|
||||
{
|
||||
file: FLOOR,
|
||||
from: ' return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;',
|
||||
to: ' return resolved >= 1 ? lowest : null;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'the geoid guess outranks a held render height again (the poll-step bug)',
|
||||
edits: [
|
||||
{ file: ALT, from: ' if (Number.isFinite(priorRenderM)) return null;', to: ' // guard removed' },
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'a re-probe onto a LOWER neighbour re-latches with a snap',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (next.easedM == null && wasHeld && Number.isFinite(stoodOnM)\n && Number.isFinite(effective) && effective < stoodOnM) {',
|
||||
to: ' if (next.easedM == null && Number.isFinite(floor) && wasHeld && Number.isFinite(stoodOnM)\n && Number.isFinite(effective) && effective < stoodOnM) {',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// The from/duration shape this replaced: a FIXED anchor interpolated by
|
||||
// CUMULATIVE elapsed time. Re-evaluated against a target that moved
|
||||
// mid-approach it jumps by the eased fraction of the change.
|
||||
defect: 'the approach interpolates a fixed anchor, so a mid-approach retarget is a seam',
|
||||
edits: [
|
||||
{ file: FLIGHTS, from: ' next.easeMs = nowMs;\n const closed', to: ' const closed' },
|
||||
{ file: FLIGHTS, from: ' next.easedM = value;', to: ' /* anchor stays fixed */' },
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'a global per-tick probe budget is reintroduced over the per-contact throttle',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' state.probeMs = nowMs;\n const near = neighborFloorM(cell);',
|
||||
to: ' globalThis.__mutGlobalProbeBudget = (globalThis.__mutGlobalProbeBudget || 0) + 1;\n'
|
||||
+ ' if (globalThis.__mutGlobalProbeBudget > 12) return state.heldM;\n'
|
||||
+ ' state.probeMs = nowMs;\n const near = neighborFloorM(cell);',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'the per-tick step is unclamped, so a delayed tick snaps',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' const closed = Math.min(FLOOR_EASE_MAX_STEP, 1 - Math.exp(-dtMs / FLOOR_EASE_TAU_MS));',
|
||||
to: ' const closed = 1 - Math.exp(-dtMs / FLOOR_EASE_TAU_MS);',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// The first cut: delete outright. An on_ground flap through a takeoff roll
|
||||
// then cold-starts the contact under the runway (field observation VIR138M).
|
||||
defect: 'retiring the hold DELETES it, so an on_ground flap cold-starts',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (state.retiredMs == null) {',
|
||||
to: ' _displayFloorState.delete(icao24);\n if (false) {',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'the rehydration seed never expires — an hour-old floor still answers',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' return state.retiredMs != null && nowMs - state.retiredMs > FLOOR_SEED_GRACE_MS;',
|
||||
to: ' return false;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Expiry checked ONLY on the retire path: a contact that is parked and then
|
||||
// makes no calls at all never reaches it, and rehydration cleared
|
||||
// `retiredMs` before anything looked at it. Measured: parked 198 s, reused.
|
||||
defect: 'seed age is never validated at rehydration, only while still retired',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (_seedExpired(next, nowMs)) _dropHeldFloor(next);',
|
||||
to: ' // rehydration takes whatever was parked, however old',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// The measured float: a 200 m seed reused 0.56 km away while the new
|
||||
// neighbourhood read 100 m and 105 m.
|
||||
defect: 'a rehydrated seed outranks fresh neighbour evidence',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (state.heldTier === \'own\' && !state.seeded) return state.heldM;',
|
||||
to: ' if (state.heldTier === \'own\') return state.heldM;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'a parked floor is not marked as a memory, so nothing can demote it',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' state.seeded = true; // what it answers with next is a memory, not a reading',
|
||||
to: ' state.seeded = false;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'the seed flag survives a live reading, so a measured floor stays demoted',
|
||||
edits: [
|
||||
{ file: FLIGHTS, from: ' state.seeded = false;\n return floorM;', to: ' return floorM;' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const read = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8');
|
||||
const write = (f, s) => fs.writeFileSync(path.join(ROOT, f), s);
|
||||
|
||||
function runTests() {
|
||||
try {
|
||||
execFileSync('node', ['--test', ...TESTS], { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
return { failed: 0, names: [] };
|
||||
} catch (e) {
|
||||
const raw = `${e.stdout || ''}${e.stderr || ''}`;
|
||||
const names = [...new Set([...raw.matchAll(/^✖ (.+?) \(/gm)].map((m) => m[1]))];
|
||||
const m = raw.match(/^ℹ fail (\d+)/m);
|
||||
return { failed: m ? Number(m[1]) : -1, names, raw };
|
||||
}
|
||||
}
|
||||
|
||||
const baseline = runTests();
|
||||
console.log(`baseline: ${baseline.failed} failing`);
|
||||
if (baseline.failed !== 0) {
|
||||
console.error('the suites must be green before mutating');
|
||||
if (baseline.raw) console.error(baseline.raw.slice(-2000));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let allRed = true;
|
||||
for (const mut of MUTATIONS) {
|
||||
const backups = new Map();
|
||||
let applied = true;
|
||||
for (const e of mut.edits) {
|
||||
if (!backups.has(e.file)) backups.set(e.file, read(e.file));
|
||||
const cur = read(e.file);
|
||||
if (!cur.includes(e.from)) {
|
||||
console.log(` [STALE] ${mut.defect} — anchor not found in ${e.file}`);
|
||||
applied = false;
|
||||
break;
|
||||
}
|
||||
write(e.file, cur.replace(e.from, e.to));
|
||||
}
|
||||
if (applied) {
|
||||
const r = runTests();
|
||||
if (r.failed > 0) {
|
||||
console.log(` [RED] ${mut.defect} — ${r.failed} failing`);
|
||||
for (const n of r.names) console.log(` ↳ ${n}`);
|
||||
} else {
|
||||
console.log(` [GREEN] ${mut.defect} — NOT PINNED`);
|
||||
allRed = false;
|
||||
}
|
||||
} else {
|
||||
allRed = false;
|
||||
}
|
||||
for (const [file, text] of backups) write(file, text);
|
||||
}
|
||||
|
||||
const after = runTests();
|
||||
console.log(`\nrestored: ${after.failed} failing (must be 0)`);
|
||||
console.log(allRed && after.failed === 0
|
||||
? `RESULT: ${MUTATIONS.length}/${MUTATIONS.length} defects pinned`
|
||||
: 'RESULT: FAIL');
|
||||
process.exit(allRed && after.failed === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* scripts/qa-floorhold-probe-cost.mjs — what does an unrationed adjacent-cell
|
||||
* probe actually cost?
|
||||
*
|
||||
* The display-floor hold borrows a floor from the eight cells adjacent to a
|
||||
* contact's own when that one is cold (`neighborFloorM`). An early draft
|
||||
* rationed those probes with a global per-tick budget and a fairness queue,
|
||||
* which produced two starvation defects in a row. This measures the thing the
|
||||
* scheduler was protecting, so the decision to delete it is a number rather
|
||||
* than an opinion.
|
||||
*
|
||||
* A probe is eight synchronous `Map` reads against the shared floor cache — no
|
||||
* fetch, no `sampleHeight`, nothing async. Every DEM request is driven by
|
||||
* `warmGroundFloor` from the poll loop instead, already bounded there by
|
||||
* DISPLAY_CORRIDOR_CELL_BUDGET and the resolver's single-flight chain.
|
||||
*
|
||||
* node scripts/qa-floorhold-probe-cost.mjs
|
||||
*/
|
||||
import {
|
||||
neighborFloorM, reportMeshFloorCell, setMeshFloorPreferred, _clearMeshFloorCellsForTest,
|
||||
} from '../src/data/groundFloor.js';
|
||||
|
||||
const CONTACTS = 200; // a dense airport view, every contact probing at once
|
||||
const TICK_MS = 80; // the fleet dead-reckoning cadence
|
||||
const THROTTLE_MS = 500; // NEIGHBOR_FLOOR_PROBE_MS
|
||||
|
||||
setMeshFloorPreferred(true);
|
||||
for (const [label, warm] of [
|
||||
['all cold (every neighbour a miss — the outage case)', false],
|
||||
['all warm (all eight neighbours resolved)', true],
|
||||
]) {
|
||||
_clearMeshFloorCellsForTest();
|
||||
const cells = [];
|
||||
for (let i = 0; i < CONTACTS; i += 1) {
|
||||
const cell = { lat: +(30 + i * 0.003).toFixed(3), lon: -97.66 };
|
||||
cells.push(cell);
|
||||
if (warm) {
|
||||
for (let dLat = -1; dLat <= 1; dLat += 1) {
|
||||
for (let dLon = -1; dLon <= 1; dLon += 1) {
|
||||
if (dLat === 0 && dLon === 0) continue; // the contact's OWN cell stays cold
|
||||
reportMeshFloorCell(cell.lat + dLat * 0.001, cell.lon + dLon * 0.001, 100 + i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let w = 0; w < 200; w += 1) for (const c of cells) neighborFloorM(c); // settle the JIT
|
||||
const runs = [];
|
||||
for (let r = 0; r < 60; r += 1) {
|
||||
const t0 = process.hrtime.bigint();
|
||||
for (const c of cells) neighborFloorM(c);
|
||||
runs.push(Number(process.hrtime.bigint() - t0) / 1e6);
|
||||
}
|
||||
runs.sort((a, b) => a - b);
|
||||
const median = runs[Math.floor(runs.length / 2)];
|
||||
const worst = runs[runs.length - 1];
|
||||
console.log(`\n${label}`);
|
||||
console.log(` ${CONTACTS} contacts x 8 cells on ONE tick : median ${median.toFixed(3)} ms, worst ${worst.toFixed(3)} ms`);
|
||||
console.log(` share of one ${TICK_MS} ms fleet tick : ${(worst / TICK_MS * 100).toFixed(2)}%`);
|
||||
console.log(` sustained under the ${THROTTLE_MS} ms throttle : ${(median * 1000 / THROTTLE_MS).toFixed(2)} ms per second`);
|
||||
}
|
||||
console.log('\nA global scheduler over this protects single-digit milliseconds.\n');
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* scripts/qa-floorhold-staircase.mjs — what a grounded contact DOES while its
|
||||
* floor data arrives, tick by tick.
|
||||
*
|
||||
* The unit pins assert the clamp's answer for a given cache state. They cannot
|
||||
* see the shape of the transition, which is what an owner actually watches: a
|
||||
* contact that reaches the right height by way of a jump into midair and a
|
||||
* visible stair-step down is wrong even though every individual answer is
|
||||
* defensible. An field test found exactly that — planes floating at
|
||||
* terminal gates — and this is the rig that reproduces it.
|
||||
*
|
||||
* A stationary grounded contact at a cold cell, driven at the 80 ms fleet
|
||||
* cadence, with cells warming on a fixed schedule. Reports time-to-surface,
|
||||
* time visibly buried, time visibly FLOATING, and the visible step count.
|
||||
*
|
||||
* node scripts/qa-floorhold-staircase.mjs
|
||||
*
|
||||
* Pure module-level: no browser, no network, no GPU.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { pickRenderAltitudeM } from '../src/data/renderAltitude.js';
|
||||
import {
|
||||
_floorGroundedDisplayPositionForTest, _clearDisplayFloorStateForTest,
|
||||
} from '../src/data/flights.js';
|
||||
import {
|
||||
reportMeshFloorCell, setMeshFloorPreferred, _clearMeshFloorCellsForTest, GROUND_FLOOR_LIFT_M,
|
||||
} from '../src/data/groundFloor.js';
|
||||
|
||||
const LAT = 30.2004, LON = -97.6604; // own cell 30.200 / -97.660
|
||||
const APRON = -22.0, ROOF = -4.0, START = -32.0; // ellipsoidal; SFO-ish geoid start
|
||||
const TARGET = APRON + GROUND_FLOOR_LIFT_M; // -20.5
|
||||
const N = { up: [30.2014, LON], down: [30.1994, LON], left: [LAT, -97.6614], right: [LAT, -97.6594] };
|
||||
|
||||
const SCENARIOS = [
|
||||
['S1 flat apron cold start (own cell warms last)', [
|
||||
[1120, () => { reportMeshFloorCell(...N.up, APRON); reportMeshFloorCell(...N.down, APRON); }],
|
||||
[5200, () => reportMeshFloorCell(LAT, LON, APRON)],
|
||||
]],
|
||||
['S2 apron beside a terminal (one neighbour is a roof)', [
|
||||
[1120, () => { reportMeshFloorCell(...N.up, ROOF); reportMeshFloorCell(...N.down, APRON); }],
|
||||
[5200, () => reportMeshFloorCell(LAT, LON, APRON)],
|
||||
]],
|
||||
['S3 own cell never warms (roof neighbour only)', [
|
||||
[1120, () => reportMeshFloorCell(...N.up, ROOF)],
|
||||
]],
|
||||
['S3b own cell never warms (roof + apron neighbours)', [
|
||||
[1120, () => { reportMeshFloorCell(...N.up, ROOF); reportMeshFloorCell(...N.down, APRON);
|
||||
reportMeshFloorCell(...N.left, APRON); }],
|
||||
]],
|
||||
];
|
||||
|
||||
const summary = [];
|
||||
for (const [name, schedule] of SCENARIOS) {
|
||||
_clearDisplayFloorStateForTest(); _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true);
|
||||
const pos = Cesium.Cartesian3.fromDegrees(LON, LAT, START);
|
||||
const samples = [];
|
||||
let pending = [...schedule];
|
||||
for (let t = 0; t <= 12000; t += 80) {
|
||||
while (pending.length && pending[0][0] <= t) pending.shift()[1]();
|
||||
const out = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, name, 1000 + t);
|
||||
samples.push([t, Cesium.Cartographic.fromCartesian(out, Cesium.Ellipsoid.WGS84).height]);
|
||||
}
|
||||
const settle = samples.find(([, h]) => Math.abs(h - TARGET) < 0.1);
|
||||
const buried = samples.filter(([, h]) => h < APRON - 0.05).length * 80;
|
||||
const floatMax = Math.max(0, ...samples.map(([, h]) => h - TARGET));
|
||||
const floatMs = samples.filter(([, h]) => h > TARGET + 0.5).length * 80;
|
||||
const steps = [];
|
||||
for (let i = 1; i < samples.length; i += 1) {
|
||||
const d = samples[i][1] - samples[i - 1][1];
|
||||
if (Math.abs(d) > 0.5) steps.push([samples[i][0], d]);
|
||||
}
|
||||
const plateaus = new Set(samples.map(([, h]) => h.toFixed(1))).size;
|
||||
console.log(`\n### ${name}`);
|
||||
console.log(` target = ${TARGET.toFixed(2)} m start = ${START.toFixed(2)} m`);
|
||||
console.log(` time-to-surface = ${settle ? `${settle[0]} ms` : 'NEVER'}`);
|
||||
console.log(` time visibly BURIED = ${buried} ms`);
|
||||
console.log(` time visibly FLOATING = ${floatMs} ms (worst +${floatMax.toFixed(1)} m)`);
|
||||
console.log(` visible steps (>0.5m) = ${steps.length} (up ${steps.filter(([, d]) => d > 0).length} / down ${steps.filter(([, d]) => d < 0).length})`);
|
||||
console.log(` distinct plateaus = ${plateaus}`);
|
||||
summary.push(` ${name} :: settle=${settle ? settle[0] : 'NEVER'} steps=${steps.length} float=${floatMs}ms(+${floatMax.toFixed(1)}m) buried=${buried}ms`);
|
||||
}
|
||||
console.log(`\nSUMMARY (this tree, post-fix)\n${summary.join('\n')}\n`);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// F1 — takeoff roll with the on_ground flag FLAPPING (field observation: VIR138M
|
||||
// at JFK, 45 kt, "clearly on good ground, then suddenly popped below the
|
||||
// ground, then popped back up").
|
||||
//
|
||||
// Two mechanisms meet here. OpenSky's on_ground flag is not clean through a
|
||||
// rotation, and the fix's own height source switches at the same moment: a
|
||||
// grounded fix is the resolved surface, an airborne one with baro just
|
||||
// appearing is baro + geoid N, which at a sea-level field IS the geoid — below
|
||||
// the ground the contact is still rolling on. Whether that dip is VISIBLE is
|
||||
// then entirely down to whether the display clamp still remembers a floor.
|
||||
//
|
||||
// EVERY tick below is counted, the airborne one included. That tick renders
|
||||
// under the runway and always will: the display clamp passes airborne positions
|
||||
// through by design — an airborne height is the fix-time clamp's job, and
|
||||
// flooring one would put a rotating aircraft back on the ground. So this rig
|
||||
// reports two numbers, not one. The grounded count is what the hold owns and it
|
||||
// must be zero. The airborne count is the ACCEPTED transition residual: the
|
||||
// flap's own cost at the source, one tick wide, bounded by the field's geoid
|
||||
// separation (~4 m here). What the hold changed is what happens AFTER the flap
|
||||
// — 12 of 22 grounded ticks buried and not recovering, versus none.
|
||||
// ---------------------------------------------------------------------------
|
||||
const JFK_GROUND = -28.5; // ~4 m MSL field, geoid N ~ -32.5 m
|
||||
const JFK_GEOID = -32.5;
|
||||
const JFK_LON = -73.78;
|
||||
|
||||
console.log('\n### F1 takeoff roll, on_ground flapping for one poll');
|
||||
console.log(' poll-path height source at the flap:');
|
||||
console.log(` grounded, own cell warm = ${pickRenderAltitudeM({ geoAltM: null, baroAltM: null, onGround: true, surfaceM: JFK_GROUND, geoidN: JFK_GEOID })} m`);
|
||||
console.log(` airborne, baro appears at 0 ft = ${pickRenderAltitudeM({ geoAltM: null, baroAltM: 0, onGround: false, surfaceM: null, geoidN: JFK_GEOID })} m`);
|
||||
console.log(` the source switch alone drops the fix ${(JFK_GROUND - JFK_GEOID).toFixed(1)} m, to below the runway`);
|
||||
|
||||
_clearDisplayFloorStateForTest(); _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true);
|
||||
// Only the cell it STARTED on is warm: at 23 m/s it outruns its own floor data,
|
||||
// which is the whole reason the hold exists.
|
||||
reportMeshFloorCell(40.64, JFK_LON, JFK_GROUND);
|
||||
let rollLat = 40.64;
|
||||
const rollRows = [];
|
||||
for (let i = 0; i <= 22; i += 1) {
|
||||
const onGround = i !== 10; // ONE airborne poll mid-roll
|
||||
const pos = Cesium.Cartesian3.fromDegrees(JFK_LON, rollLat, JFK_GEOID);
|
||||
const out = _floorGroundedDisplayPositionForTest({ onGround }, pos, false, 'VIR138M', 1000 + i * 80);
|
||||
rollRows.push([i, onGround, Cesium.Cartographic.fromCartesian(out, Cesium.Ellipsoid.WGS84).height]);
|
||||
rollLat += 0.00021; // ~23 m per 80 ms tick
|
||||
}
|
||||
const below = ([, , h]) => h < JFK_GROUND - 0.05;
|
||||
for (const row of rollRows) {
|
||||
const [i, og, h] = row;
|
||||
const mark = below(row)
|
||||
? (og ? ' <-- BELOW THE RUNWAY' : ' <-- below the runway (airborne pass-through)')
|
||||
: '';
|
||||
console.log(` tick ${String(i).padStart(2)} onGround=${og ? 'T' : 'F'} render=${h.toFixed(2)}${mark}`);
|
||||
}
|
||||
const groundedRows = rollRows.filter(([, og]) => og);
|
||||
const airborneRows = rollRows.filter(([, og]) => !og);
|
||||
const dipped = rollRows.filter(below);
|
||||
console.log(`\n ticks rendering BELOW the runway: ${dipped.length} / ${rollRows.length} (every tick counted)`);
|
||||
console.log(` grounded : ${groundedRows.filter(below).length} / ${groundedRows.length} <- what the hold owns; must be 0`);
|
||||
console.log(` airborne : ${airborneRows.filter(below).length} / ${airborneRows.length} <- pass-through by design; accepted transition residual`);
|
||||
const worst = Math.min(JFK_GROUND, ...dipped.map(([, , h]) => h));
|
||||
console.log(` worst burial = ${(worst - JFK_GROUND).toFixed(2)} m (the field's geoid separation, one tick wide)`);
|
||||
@@ -0,0 +1,605 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* fly_route cinematic evidence — drives the REAL voice runner headlessly and
|
||||
* measures the REAL camera (Cesium heading/pitch/roll + position) every
|
||||
* rendered frame, so the proof is the shot the owner will watch, not our own
|
||||
* internal numbers.
|
||||
*
|
||||
* node scripts/qa-flyroute-cinema.mjs --url http://localhost:4247
|
||||
*
|
||||
* Writes a screenshot sequence plus a JSON trace to qa-shots/flyroute/.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import puppeteer from 'puppeteer';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const getOpt = (flag, fallback) => {
|
||||
const i = process.argv.indexOf(flag);
|
||||
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
|
||||
};
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4247');
|
||||
const OUT_DIR = getOpt('--out', path.join(ROOT, 'qa-shots', 'flyroute'));
|
||||
const MIRROR_DIR = getOpt('--mirror', '');
|
||||
const SHOT_EVERY_MS = Number(getOpt('--shot-ms', '2000'));
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
].filter(Boolean);
|
||||
const CHROME_EXECUTABLE = CHROME_CANDIDATES.find((c) => { try { return fs.existsSync(c); } catch { return false; } });
|
||||
|
||||
// A 6-waypoint downtown Austin route: north, right, left, right, left.
|
||||
const ROUTE_POINTS = [
|
||||
{ latitude: 30.2620, longitude: -97.7431 },
|
||||
{ latitude: 30.2650, longitude: -97.7431 },
|
||||
{ latitude: 30.2650, longitude: -97.7397 },
|
||||
{ latitude: 30.2680, longitude: -97.7397 },
|
||||
{ latitude: 30.2680, longitude: -97.7363 },
|
||||
{ latitude: 30.2712, longitude: -97.7363 },
|
||||
];
|
||||
|
||||
const results = [];
|
||||
const report = (ok, name, detail = '') => {
|
||||
results.push({ ok, name, detail });
|
||||
const mark = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` ${mark} ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
};
|
||||
const note = (name, detail) => {
|
||||
results.push({ ok: null, name, detail });
|
||||
console.log(` \x1b[33mNOTE\x1b[0m ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
};
|
||||
const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
|
||||
const wrapDeg = (deg) => ((deg + 540) % 360) - 180;
|
||||
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
if (MIRROR_DIR) fs.mkdirSync(MIRROR_DIR, { recursive: true });
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
// Real GPU when the host has one: the dolly is frame-rate independent, but
|
||||
// a higher sample rate makes the roll and the ease ramps far easier to see.
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1500,950',
|
||||
],
|
||||
protocolTimeout: 240000,
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1500, height: 950 });
|
||||
page.on('pageerror', (e) => console.log(` [page error] ${String(e).slice(0, 160)}`));
|
||||
|
||||
// Cold-corridor proof: hold the terrain proxy back so the dolly has to survive
|
||||
// a corridor that has no floor data when the flight is asked for. Without this
|
||||
// the harness only ever measures a machine whose cache happens to be warm.
|
||||
const TERRAIN_DELAY_MS = Number(getOpt('--terrain-delay-ms', '0'));
|
||||
let terrainRequests = 0;
|
||||
if (TERRAIN_DELAY_MS > 0) {
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('/api/terrain/heights')) {
|
||||
terrainRequests += 1;
|
||||
setTimeout(() => { request.continue().catch(() => {}); }, TERRAIN_DELAY_MS);
|
||||
return;
|
||||
}
|
||||
request.continue().catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
/** Install a postRender sampler: one row per RENDERED frame. */
|
||||
async function installSampler() {
|
||||
await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
window.__gevFlyTrace = { rows: [], marks: [] };
|
||||
if (window.__gevFlyTraceRemove) window.__gevFlyTraceRemove();
|
||||
let frame = 0;
|
||||
const listener = () => {
|
||||
const cam = viewer.camera;
|
||||
const carto = cam.positionCartographic;
|
||||
// Every 6th frame, ask the RENDERED WORLD what is under the camera. This
|
||||
// is the only measurement that can prove "never below terrain": it reads
|
||||
// the surface the user is actually looking at, not our own floor cache.
|
||||
let surfaceM = null;
|
||||
frame += 1;
|
||||
if (frame % 6 === 0 && typeof viewer.scene.sampleHeight === 'function') {
|
||||
try {
|
||||
const probe = viewer.scene.sampleHeight(carto.clone());
|
||||
if (Number.isFinite(probe)) surfaceM = probe;
|
||||
} catch { /* tiles not loaded under the camera */ }
|
||||
}
|
||||
window.__gevFlyTrace.rows.push({
|
||||
t: performance.now(),
|
||||
lon: (carto.longitude * 180) / Math.PI,
|
||||
lat: (carto.latitude * 180) / Math.PI,
|
||||
height: carto.height,
|
||||
headingDeg: (cam.heading * 180) / Math.PI,
|
||||
pitchDeg: (cam.pitch * 180) / Math.PI,
|
||||
rollDeg: (cam.roll * 180) / Math.PI,
|
||||
surfaceM,
|
||||
});
|
||||
};
|
||||
viewer.scene.postRender.addEventListener(listener);
|
||||
window.__gevFlyTraceRemove = () => viewer.scene.postRender.removeEventListener(listener);
|
||||
});
|
||||
}
|
||||
|
||||
async function readTrace() {
|
||||
return page.evaluate(() => ({
|
||||
rows: window.__gevFlyTrace.rows.slice(),
|
||||
marks: window.__gevFlyTrace.marks.slice(),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Metres between two samples, on the ground plane plus height. */
|
||||
function sampleDistanceM(a, b) {
|
||||
const mPerDegLat = 111320;
|
||||
const mPerDegLon = 111320 * Math.cos((a.lat * Math.PI) / 180);
|
||||
const dx = (b.lon - a.lon) * mPerDegLon;
|
||||
const dy = (b.lat - a.lat) * mPerDegLat;
|
||||
return Math.hypot(dx, dy);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`\nfly_route cinematic evidence — ${APP_URL}`);
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__gevVoiceCommands?.runner && window.__gevAnnotations,
|
||||
{ timeout: 150000, polling: 250 },
|
||||
);
|
||||
const run = (name, args = {}) => page.evaluate(
|
||||
(n, a) => window.__gevVoiceCommands.runner(n, a), name, args,
|
||||
);
|
||||
|
||||
// ── Setup: get over downtown Austin, draw the route ───────────────────
|
||||
await run('fly_to_location', { query: 'Texas State Capitol, Austin' });
|
||||
await sleep(9000);
|
||||
await run('clear_annotations', {});
|
||||
await sleep(500);
|
||||
const drawn = await run('annotate_map', {
|
||||
annotations: [{
|
||||
type: 'route', mode: 'driving', label: 'cinema evidence route', points: ROUTE_POINTS,
|
||||
}],
|
||||
});
|
||||
await sleep(6000);
|
||||
const routeInfo = await page.evaluate(() => {
|
||||
const route = (window.__gevAnnotations.list?.() || []).filter((a) => a.type === 'route').at(-1);
|
||||
return route ? { label: route.label, waypoints: route.path?.length ?? 0 } : null;
|
||||
});
|
||||
report(Boolean(routeInfo?.waypoints >= 2), 'route drawn on the board',
|
||||
`waypoints=${routeInfo?.waypoints} drawn=${drawn?.drawn}`);
|
||||
if (!routeInfo) throw new Error('no route on the board — cannot fly it');
|
||||
|
||||
// ── Run 1: the full cinematic flight ──────────────────────────────────
|
||||
await installSampler();
|
||||
const flight = await run('fly_route', { label: 'cinema evidence', speed: 'normal' });
|
||||
await page.evaluate(() => window.__gevFlyTrace.marks.push({ label: 'flight-start', t: performance.now() }));
|
||||
report(flight?.ok === true, 'fly_route accepted',
|
||||
`distanceM=${flight?.distanceM} durationS=${flight?.durationS} waypoints=${flight?.waypoints}`);
|
||||
if (!flight?.ok) throw new Error(`fly_route refused: ${flight?.error}`);
|
||||
const coldPath = await page.evaluate(async () => {
|
||||
try {
|
||||
const mod = await import('/src/cameraVerbs.js');
|
||||
return mod.getActiveCameraMotion?.() ?? null;
|
||||
} catch { return 'module-unavailable'; }
|
||||
});
|
||||
if (coldPath && coldPath !== 'module-unavailable') {
|
||||
report(true, 'cold-path telemetry read from the live flight',
|
||||
`arming=${coldPath.arming} floorKnown=${coldPath.floorKnown} viaMeshProbe=${coldPath.floorFromMeshProbe}`
|
||||
+ (TERRAIN_DELAY_MS ? ` (terrain proxy held back ${TERRAIN_DELAY_MS} ms, ${terrainRequests} request(s))` : ''));
|
||||
}
|
||||
|
||||
// Liveness is read off the CAMERA, never off a module import: under Vite the
|
||||
// dev server hands a dynamic import its own module instance after any HMR
|
||||
// update, whose motion slot is empty no matter what the app is doing.
|
||||
const stillForMs = () => page.evaluate(() => {
|
||||
const rows = window.__gevFlyTrace.rows;
|
||||
if (rows.length < 3) return 0;
|
||||
const last = rows.at(-1);
|
||||
for (let i = rows.length - 2; i >= 0; i -= 1) {
|
||||
const moved = Math.abs(rows[i].lon - last.lon) + Math.abs(rows[i].lat - last.lat)
|
||||
+ (Math.abs(rows[i].height - last.height) / 1e5);
|
||||
if (moved > 1e-7) return last.t - rows[i].t;
|
||||
}
|
||||
return last.t - rows[0].t;
|
||||
});
|
||||
|
||||
const budgetMs = Math.min(180000, ((flight.durationS || 40) + 10) * 1000);
|
||||
const shots = [];
|
||||
const startedAt = Date.now();
|
||||
let shotIndex = 0;
|
||||
while (Date.now() - startedAt < budgetMs) {
|
||||
const file = path.join(OUT_DIR, `flight-${String(shotIndex).padStart(2, '0')}.png`);
|
||||
const at = await page.evaluate(() => performance.now());
|
||||
await page.screenshot({ path: file });
|
||||
shots.push({ file, at });
|
||||
shotIndex += 1;
|
||||
await sleep(SHOT_EVERY_MS);
|
||||
if (Date.now() - startedAt > 5000 && (await stillForMs()) > 1500) break;
|
||||
}
|
||||
const trace = await readTrace();
|
||||
await page.evaluate(() => window.__gevFlyTraceRemove?.());
|
||||
report(shots.length >= 6, 'screenshot sequence captured', `${shots.length} frames @ ${SHOT_EVERY_MS}ms`);
|
||||
|
||||
// ── Measure the REAL camera ───────────────────────────────────────────
|
||||
// Only the dolly's own frames count. The sampler is installed before the
|
||||
// flight, so the pre-flight view and the single frame that jumps the camera
|
||||
// onto the route start are dropped — neither is dolly motion.
|
||||
const flightStartT = trace.marks.find((m) => m.label === 'flight-start')?.t ?? 0;
|
||||
const rows = trace.rows.filter((r) => r.t > flightStartT).slice(1);
|
||||
const fps = rows.length / Math.max(0.001, (rows.at(-1).t - rows[0].t) / 1000);
|
||||
report(rows.length > 60, 'camera sampled every rendered frame',
|
||||
`${rows.length} dolly samples (${trace.rows.length} total) at ${fps.toFixed(1)} fps`);
|
||||
|
||||
const rolls = rows.map((r) => wrapDeg(r.rollDeg));
|
||||
const peakRoll = Math.max(...rolls.map(Math.abs));
|
||||
report(peakRoll <= 10.5, 'bank never exceeds the 10° cap', `peak |roll| = ${peakRoll.toFixed(2)}°`);
|
||||
report(peakRoll > 3, 'turns actually bank', `peak |roll| = ${peakRoll.toFixed(2)}°`);
|
||||
report(Math.abs(rolls[0]) < 1 && Math.abs(rolls.at(-1)) < 3,
|
||||
'the flight starts and finishes near wings level',
|
||||
`first=${rolls[0].toFixed(2)}° last=${rolls.at(-1).toFixed(2)}°`);
|
||||
|
||||
// Roll follows the turn: while banked, roll sign must match heading rate.
|
||||
// Sampled over ~1 s of heading change, and only where BOTH the roll and the
|
||||
// turn are unambiguous (a roll-out trails its turn by design, so the tail of
|
||||
// every corner is deliberately outside the window).
|
||||
let agree = 0;
|
||||
let disagree = 0;
|
||||
const span = Math.max(2, Math.round(fps));
|
||||
for (let i = span; i < rows.length; i += 1) {
|
||||
const dt = (rows[i].t - rows[i - span].t) / 1000;
|
||||
if (!(dt > 0)) continue;
|
||||
const headingRate = wrapDeg(rows[i].headingDeg - rows[i - span].headingDeg) / dt;
|
||||
const roll = wrapDeg(rows[i].rollDeg);
|
||||
if (Math.abs(roll) < 2 || Math.abs(headingRate) < 3) continue;
|
||||
if (Math.sign(roll) === Math.sign(headingRate)) agree += 1; else disagree += 1;
|
||||
}
|
||||
const agreement = agree / Math.max(1, agree + disagree);
|
||||
report(agreement > 0.85, 'the camera rolls INTO the turn (right turn → right bank)',
|
||||
`${(agreement * 100).toFixed(1)}% of banked samples agree (${agree}/${agree + disagree})`);
|
||||
|
||||
// Every rate below is measured over ~250 ms windows. A per-frame difference
|
||||
// is dominated by the pairing jitter between the motion tick's own clock and
|
||||
// postRender (a 36 ms frame next to an 8 ms one doubles any per-frame rate),
|
||||
// which measures the harness, not the dolly.
|
||||
const RATE_WINDOW_MS = 250;
|
||||
const peakRate = (valueAt) => {
|
||||
let worst = 0;
|
||||
for (let i = 0, j = 0; i < rows.length; i += 1) {
|
||||
while (j < rows.length - 1 && rows[j].t - rows[i].t < RATE_WINDOW_MS) j += 1;
|
||||
const dt = (rows[j].t - rows[i].t) / 1000;
|
||||
if (dt >= RATE_WINDOW_MS / 2000) worst = Math.max(worst, Math.abs(valueAt(j) - valueAt(i)) / dt);
|
||||
}
|
||||
return worst;
|
||||
};
|
||||
|
||||
const peakRollRate = peakRate((i) => rolls[i]);
|
||||
report(peakRollRate < 20, 'the roll enters and exits smoothly, never snaps',
|
||||
`peak roll rate ${peakRollRate.toFixed(1)} °/s over ${RATE_WINDOW_MS} ms`);
|
||||
|
||||
// Speed: eased at both ends, no step in between. Measured over 400 ms
|
||||
// windows — the per-frame delta is dominated by the pairing jitter between
|
||||
// the motion tick's own clock and postRender, not by the dolly.
|
||||
const cumulative = [0];
|
||||
for (let i = 1; i < rows.length; i += 1) {
|
||||
cumulative.push(cumulative[i - 1] + sampleDistanceM(rows[i - 1], rows[i]));
|
||||
}
|
||||
const speeds = [];
|
||||
for (let i = 0, j = 0; i < rows.length; i += 1) {
|
||||
while (j < rows.length - 1 && rows[j].t - rows[i].t < 400) j += 1;
|
||||
const dt = (rows[j].t - rows[i].t) / 1000;
|
||||
if (dt >= 0.3) speeds.push({ t: rows[i].t - rows[0].t, v: (cumulative[j] - cumulative[i]) / dt });
|
||||
}
|
||||
const window1s = (from, to) => {
|
||||
const inWindow = speeds.filter((s) => s.t >= from && s.t <= to);
|
||||
return inWindow.length ? inWindow.reduce((sum, s) => sum + s.v, 0) / inWindow.length : Number.NaN;
|
||||
};
|
||||
const peakV = Math.max(...speeds.map((s) => s.v));
|
||||
const firstSecond = window1s(0, 1200);
|
||||
report(firstSecond < peakV * 0.5, 'the dolly eases IN (no velocity step at the start)',
|
||||
`first 1.2 s ${firstSecond.toFixed(1)} m/s vs peak ${peakV.toFixed(1)} m/s`);
|
||||
|
||||
// Ease-out is measured as the SHAPE of the decay, not as a terminal window.
|
||||
// The sampler keeps running after the dolly stops, so a trailing average
|
||||
// includes stationary frames — under which a hard stop also reports ~0 m/s
|
||||
// and passes. How long the speed takes to fall from 90% to 10% of peak is
|
||||
// immune to that tail: a ramp spreads it over a second or more, a hard stop
|
||||
// collapses it into a single frame.
|
||||
const lastAbove = (fraction) => {
|
||||
for (let i = speeds.length - 1; i >= 0; i -= 1) if (speeds[i].v >= peakV * fraction) return speeds[i].t;
|
||||
return Number.NaN;
|
||||
};
|
||||
const decayMs = lastAbove(0.1) - lastAbove(0.9);
|
||||
report(decayMs > 800, 'the dolly eases OUT over a real ramp, not a hard stop',
|
||||
`speed fell 90% → 10% of peak over ${decayMs.toFixed(0)} ms (a hard stop collapses to one frame)`);
|
||||
const riseMs = (() => {
|
||||
const first = (fraction) => speeds.find((s) => s.v >= peakV * fraction)?.t ?? Number.NaN;
|
||||
return first(0.9) - first(0.1);
|
||||
})();
|
||||
report(riseMs > 800, 'and eases IN over one too',
|
||||
`speed rose 10% → 90% of peak over ${riseMs.toFixed(0)} ms`);
|
||||
report(peakV < 40 * 1.35, 'the easing keeps the shipped pace — the plateau IS the speed word',
|
||||
`peak ${peakV.toFixed(1)} m/s over a 40 m/s mean (${(flight.distanceM / flight.durationS).toFixed(1)} m/s reported)`);
|
||||
// Acceleration, differenced across NON-overlapping speed windows so the
|
||||
// 400 ms averaging is not differentiated against itself.
|
||||
let peakAccel = 0;
|
||||
for (let i = 0, j = 0; i < speeds.length; i += 1) {
|
||||
while (j < speeds.length - 1 && speeds[j].t - speeds[i].t < 400) j += 1;
|
||||
const dt = (speeds[j].t - speeds[i].t) / 1000;
|
||||
if (dt >= 0.3) peakAccel = Math.max(peakAccel, Math.abs(speeds[j].v - speeds[i].v) / dt);
|
||||
}
|
||||
report(peakAccel < 40, 'no velocity discontinuity anywhere on the route',
|
||||
`peak |acceleration| ${peakAccel.toFixed(1)} m/s² (a hard start would read in the hundreds)`);
|
||||
|
||||
// Altitude shaping and terrain clearance, read off the real camera.
|
||||
const heights = rows.map((r) => r.height);
|
||||
const floors = await page.evaluate(async (samples) => {
|
||||
try {
|
||||
const mod = await import('/src/data/groundFloor.js');
|
||||
return samples.map(({ lat, lon }) => mod.cachedGroundFloor(lat, lon));
|
||||
} catch { return samples.map(() => null); }
|
||||
}, rows.map((r) => ({ lat: r.lat, lon: r.lon })));
|
||||
// The strongest terrain check available: the eye against the RENDERED
|
||||
// surface under it, sampled live. Independent of our own floor cache, and
|
||||
// therefore the one that would catch flying inside a building or a hillside.
|
||||
const probed = rows.filter((r) => Number.isFinite(r.surfaceM));
|
||||
if (probed.length > 20) {
|
||||
let worst = Infinity;
|
||||
let worstAt = null;
|
||||
for (const row of probed) {
|
||||
const clearance = row.height - row.surfaceM;
|
||||
if (clearance < worst) { worst = clearance; worstAt = row; }
|
||||
}
|
||||
report(worst > 0, 'the eye is never inside the RENDERED world',
|
||||
`min clearance over the rendered surface ${worst.toFixed(1)} m across ${probed.length} live probes`
|
||||
+ (worstAt ? ` (worst at ${worstAt.lat.toFixed(5)}, ${worstAt.lon.toFixed(5)})` : ''));
|
||||
} else {
|
||||
note('rendered-surface clearance', `only ${probed.length} live mesh probes answered`);
|
||||
}
|
||||
const warm = floors.map((f, i) => (Number.isFinite(f) ? heights[i] - f : null)).filter((v) => v !== null);
|
||||
if (warm.length > 20) {
|
||||
const minAgl = Math.min(...warm);
|
||||
const maxAgl = Math.max(...warm);
|
||||
report(minAgl >= 90, 'the eye always clears the rendered floor',
|
||||
`min AGL ${minAgl.toFixed(1)} m, max ${maxAgl.toFixed(1)} m over ${warm.length} warm samples`);
|
||||
} else {
|
||||
note('terrain clearance', `only ${warm.length} warm floor cells under the route — clearance clamp is pinned in npm test`);
|
||||
}
|
||||
// The floor ACQUISITION — the one frame where a cold-corridor safety seed is
|
||||
// replaced by real terrain — is deliberately a single step, and it lands in
|
||||
// the same moment the camera teleports onto the route start. It is measured
|
||||
// separately from the shaping, which must be a swell for the whole flight.
|
||||
const cruiseFrom = rows.findIndex((r) => r.t - rows[0].t > 1500);
|
||||
const cruiseRows = cruiseFrom > 0 ? rows.slice(cruiseFrom) : rows;
|
||||
const cruiseHeights = cruiseRows.map((r) => r.height);
|
||||
// Split the two directions: a DESCENT is capped by the dolly (never drop the
|
||||
// eye toward ground it is still learning about), while a CLIMB is deliberately
|
||||
// uncapped — rising is the safety direction, and a terrain rise plus the
|
||||
// shaping swell can legitimately exceed the descent cap.
|
||||
let peakDescentMps = 0;
|
||||
let peakClimbMps = 0;
|
||||
for (let i = 0, j = 0; i < cruiseRows.length; i += 1) {
|
||||
while (j < cruiseRows.length - 1 && cruiseRows[j].t - cruiseRows[i].t < RATE_WINDOW_MS) j += 1;
|
||||
const dt = (cruiseRows[j].t - cruiseRows[i].t) / 1000;
|
||||
if (dt < RATE_WINDOW_MS / 2000) continue;
|
||||
const rate = (cruiseHeights[j] - cruiseHeights[i]) / dt;
|
||||
if (rate < 0) peakDescentMps = Math.max(peakDescentMps, -rate);
|
||||
else peakClimbMps = Math.max(peakClimbMps, rate);
|
||||
}
|
||||
const peakVerticalMps = Math.max(peakDescentMps, peakClimbMps);
|
||||
const acquisitionM = Math.abs(heights[0] - cruiseHeights[0]);
|
||||
const cruiseRangeM = Math.max(...cruiseHeights) - Math.min(...cruiseHeights);
|
||||
report(cruiseRangeM > 5, 'altitude breathes rather than sitting flat',
|
||||
`${cruiseRangeM.toFixed(1)} m of vertical range in cruise`);
|
||||
report(cruiseRangeM < 120, 'and the cruise altitude never wanders far from its mean',
|
||||
`${cruiseRangeM.toFixed(1)} m of range after a ${acquisitionM.toFixed(0)} m floor acquisition at the start`);
|
||||
// Smoothness is measured on the camera's ABSOLUTE vertical motion, because
|
||||
// that is what a viewer sees. It is tempting to difference AGL instead, to
|
||||
// separate "our shaping" from "the hill" — but the floor is a ~111 m
|
||||
// staircase, so an AGL series steps at every cell boundary even when the eye
|
||||
// is gliding. Differencing it measures the quantization, not the ride
|
||||
// (measured: 31.6 m/s of "AGL rate" while the eye moved at 8.9 m/s).
|
||||
report(peakDescentMps < 10.5, 'the eye is never DROPPED — descent stays inside its cap',
|
||||
`peak descent ${peakDescentMps.toFixed(1)} m/s (cap 10 m/s)`);
|
||||
report(peakClimbMps < 20, 'and climbs stay a swell rather than a lurch',
|
||||
`peak climb ${peakClimbMps.toFixed(1)} m/s against ~40 m/s of ground speed`);
|
||||
|
||||
const pitches = rows.map((r) => r.pitchDeg);
|
||||
const pitchSpread = Math.max(...pitches) - Math.min(...pitches);
|
||||
report(pitchSpread < 2, 'the look-down angle stays locked (no pitch wobble)',
|
||||
`pitch ${Math.min(...pitches).toFixed(1)}°..${Math.max(...pitches).toFixed(1)}°`);
|
||||
|
||||
// ── Run 2: interrupt the dolly MID-BANK ───────────────────────────────
|
||||
// Cutting a level camera proves nothing about levelling, so this waits for
|
||||
// the live camera to actually be rolled before it grabs the controls.
|
||||
await sleep(1500);
|
||||
await installSampler();
|
||||
const second = await run('fly_route', { label: 'cinema evidence', speed: 'normal' });
|
||||
report(second?.ok === true, 'second flight starts for the interrupt case');
|
||||
const liveRollDeg = () => page.evaluate(
|
||||
() => (window.__godsEyeView.viewer.camera.roll * 180) / Math.PI,
|
||||
);
|
||||
let rollBeforeCut = 0;
|
||||
for (let waited = 0; waited < 90000; waited += 400) {
|
||||
rollBeforeCut = wrapDeg(await liveRollDeg());
|
||||
if (Math.abs(rollBeforeCut) >= 3) break;
|
||||
await sleep(400);
|
||||
}
|
||||
report(Math.abs(rollBeforeCut) >= 3, 'the dolly is genuinely banked before the cut',
|
||||
`live camera roll ${rollBeforeCut.toFixed(2)}°`);
|
||||
await page.screenshot({ path: path.join(OUT_DIR, 'interrupt-0-banked.png') });
|
||||
|
||||
const cut = await page.evaluate(async () => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const canvas = viewer.scene.canvas;
|
||||
// Read the motion slot BEFORE the cut too: under Vite a dynamic import can
|
||||
// hand back a second module instance whose slot is always empty, and an
|
||||
// "empty after" that was already empty before proves nothing.
|
||||
let read = null;
|
||||
let slotBefore = 'module-unavailable';
|
||||
let slotAfter = 'module-unavailable';
|
||||
try {
|
||||
const mod = await import('/src/cameraVerbs.js');
|
||||
read = () => mod.getActiveCameraMotion?.() ?? null;
|
||||
slotBefore = read();
|
||||
} catch { /* dev-only module read */ }
|
||||
const rollBefore = (viewer.camera.roll * 180) / Math.PI;
|
||||
window.__gevFlyTrace.marks.push({ label: 'pointerdown', t: performance.now() });
|
||||
canvas.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true }));
|
||||
// Same synchronous turn as the pointerdown — no frame has rendered yet.
|
||||
const rollAfter = (viewer.camera.roll * 180) / Math.PI;
|
||||
if (read) slotAfter = read();
|
||||
return { slotBefore, slotAfter, rollBefore, rollAfter };
|
||||
});
|
||||
// The camera-side proof, which needs no module identity at all: a banked
|
||||
// horizon is level again inside the same synchronous turn as the pointerdown.
|
||||
report(
|
||||
Math.abs(wrapDeg(cut.rollBefore)) >= 3 && Math.abs(wrapDeg(cut.rollAfter)) < 0.01,
|
||||
'the release levels the horizon synchronously — no tilt left behind',
|
||||
`roll ${wrapDeg(cut.rollBefore).toFixed(2)}° → ${wrapDeg(cut.rollAfter).toFixed(4)}° in the same turn`,
|
||||
);
|
||||
if (cut.slotBefore && cut.slotBefore !== 'module-unavailable') {
|
||||
report(cut.slotAfter === null, 'a manual pointerdown frees the motion slot synchronously',
|
||||
`active before the cut: ${cut.slotBefore.kind}@${(cut.slotBefore.progress * 100).toFixed(0)}% → after: ${JSON.stringify(cut.slotAfter)}`);
|
||||
} else {
|
||||
note('motion-slot read',
|
||||
'the dev server handed the harness a second module instance (HMR); the camera-side roll and freeze checks carry the proof');
|
||||
}
|
||||
await sleep(2500);
|
||||
const rollAfterSettle = wrapDeg(await liveRollDeg());
|
||||
report(Math.abs(rollAfterSettle) < 0.01, 'and the horizon STAYS level after the cut',
|
||||
`roll ${rollAfterSettle.toFixed(4)}° 2.5 s later`);
|
||||
await page.screenshot({ path: path.join(OUT_DIR, 'interrupt-1-level.png') });
|
||||
const cutTrace = await readTrace();
|
||||
await page.evaluate(() => window.__gevFlyTraceRemove?.());
|
||||
|
||||
const cutAt = cutTrace.marks.at(-1)?.t ?? 0;
|
||||
const before = cutTrace.rows.filter((r) => r.t < cutAt);
|
||||
const after = cutTrace.rows.filter((r) => r.t >= cutAt);
|
||||
const movementBefore = before.length > 2
|
||||
? sampleDistanceM(before.at(-3), before.at(-1)) : Number.NaN;
|
||||
let movementAfter = 0;
|
||||
for (let i = 1; i < after.length; i += 1) {
|
||||
movementAfter = Math.max(movementAfter, sampleDistanceM(after[i - 1], after[i]));
|
||||
}
|
||||
report(movementBefore > 0.5 && movementAfter < 0.5,
|
||||
'the camera freezes on the cut frame — no coast, no snap-back',
|
||||
`moved ${movementBefore.toFixed(2)} m/frame before, max ${movementAfter.toFixed(3)} m/frame after (${after.length} frames)`);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(OUT_DIR, 'trace.json'),
|
||||
JSON.stringify({
|
||||
url: APP_URL,
|
||||
flight,
|
||||
shots: shots.map((s) => path.basename(s.file)),
|
||||
samples: rows.length,
|
||||
peakRollDeg: peakRoll,
|
||||
peakRollRateDegS: peakRollRate,
|
||||
rollTurnAgreement: agreement,
|
||||
peakSpeedMps: peakV,
|
||||
peakAccelMps2: peakAccel,
|
||||
peakVerticalMps,
|
||||
peakDescentMps,
|
||||
peakClimbMps,
|
||||
easeInMps: firstSecond,
|
||||
easeInRampMs: riseMs,
|
||||
easeOutRampMs: decayMs,
|
||||
heightRangeM: [Math.min(...heights), Math.max(...heights)],
|
||||
cruiseHeightRangeM: cruiseRangeM,
|
||||
floorAcquisitionM: acquisitionM,
|
||||
pitchRangeDeg: [Math.min(...pitches), Math.max(...pitches)],
|
||||
finalRollDeg: rolls.at(-1),
|
||||
terrainDelayMs: TERRAIN_DELAY_MS,
|
||||
interrupt: {
|
||||
movementBefore,
|
||||
movementAfter,
|
||||
framesAfter: after.length,
|
||||
rollBeforeCutDeg: wrapDeg(cut.rollBefore),
|
||||
rollAfterCutDeg: wrapDeg(cut.rollAfter),
|
||||
rollAfterSettleDeg: rollAfterSettle,
|
||||
},
|
||||
series: rows.map((r) => ({
|
||||
t: Number((r.t - rows[0].t).toFixed(0)),
|
||||
roll: Number(wrapDeg(r.rollDeg).toFixed(3)),
|
||||
heading: Number(r.headingDeg.toFixed(2)),
|
||||
pitch: Number(r.pitchDeg.toFixed(2)),
|
||||
height: Number(r.height.toFixed(1)),
|
||||
})),
|
||||
}, null, 2),
|
||||
);
|
||||
// Contact sheet: the sequence in one image, each tile stamped with the
|
||||
// elapsed time and the roll the camera was actually holding at that moment.
|
||||
const nearestRow = (at) => rows.reduce(
|
||||
(best, row) => (Math.abs(row.t - at) < Math.abs(best.t - at) ? row : best), rows[0],
|
||||
);
|
||||
const cols = 5;
|
||||
const tileW = 384;
|
||||
const tileH = Math.round((950 / 1500) * tileW);
|
||||
const labelH = 26;
|
||||
const gap = 6;
|
||||
const sheetRows = Math.ceil(shots.length / cols);
|
||||
const sheetW = (cols * tileW) + ((cols + 1) * gap);
|
||||
const sheetH = (sheetRows * (tileH + labelH)) + ((sheetRows + 1) * gap);
|
||||
const composites = [];
|
||||
const labels = [];
|
||||
for (let i = 0; i < shots.length; i += 1) {
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
const left = gap + (col * (tileW + gap));
|
||||
const top = gap + (row * (tileH + labelH + gap));
|
||||
composites.push({
|
||||
input: await sharp(shots[i].file).resize(tileW, tileH, { fit: 'fill' }).toBuffer(),
|
||||
left,
|
||||
top,
|
||||
});
|
||||
const near = nearestRow(shots[i].at);
|
||||
const elapsed = ((near.t - rows[0].t) / 1000).toFixed(0);
|
||||
const roll = wrapDeg(near.rollDeg);
|
||||
const sign = roll >= 0 ? '+' : '−';
|
||||
labels.push(`<text x="${left + 6}" y="${top + tileH + 18}" font-family="monospace" font-size="15" fill="#8fe9ff">`
|
||||
+ `${String(i).padStart(2, '0')} t=${elapsed}s roll ${sign}${Math.abs(roll).toFixed(1)}° alt ${near.height.toFixed(0)}m</text>`);
|
||||
}
|
||||
composites.push({
|
||||
input: Buffer.from(`<svg width="${sheetW}" height="${sheetH}">${labels.join('')}</svg>`),
|
||||
left: 0,
|
||||
top: 0,
|
||||
});
|
||||
const sheetPath = path.join(OUT_DIR, 'sequence-contact-sheet.jpg');
|
||||
await sharp({ create: { width: sheetW, height: sheetH, channels: 3, background: '#05080d' } })
|
||||
.composite(composites)
|
||||
.jpeg({ quality: 86 })
|
||||
.toFile(sheetPath);
|
||||
console.log(` contact sheet → ${sheetPath}`);
|
||||
|
||||
if (MIRROR_DIR) {
|
||||
// Mirror as JPEG: the PNG sequence is ~80 MB, which is a poor thing to
|
||||
// hand a human who just wants to flip through the shot.
|
||||
for (const entry of fs.readdirSync(OUT_DIR)) {
|
||||
const from = path.join(OUT_DIR, entry);
|
||||
if (entry.endsWith('.png')) {
|
||||
await sharp(from).jpeg({ quality: 80 })
|
||||
.toFile(path.join(MIRROR_DIR, entry.replace(/\.png$/, '.jpg')));
|
||||
} else {
|
||||
fs.copyFileSync(from, path.join(MIRROR_DIR, entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
report(false, 'harness completed', String(error?.message || error).slice(0, 200));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const failed = results.filter((r) => r.ok === false).length;
|
||||
const passed = results.filter((r) => r.ok === true).length;
|
||||
console.log(`\n ${passed} passed, ${failed} failed → ${OUT_DIR}`);
|
||||
process.exitCode = failed ? 1 : 0;
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Are the fly_route pins actually load-bearing?
|
||||
*
|
||||
* A pin that is red only when you delete the whole feature proves very little.
|
||||
* This reverts each fix INDIVIDUALLY — the smallest edit that reintroduces the
|
||||
* original defect — and requires src/routeCinematics.test.mjs to go red for it.
|
||||
* Every entry names the defect it restores, so the count is reproducible rather
|
||||
* than asserted in a commit message.
|
||||
*
|
||||
* node scripts/qa-flyroute-mutations.mjs
|
||||
*
|
||||
* Restores the file on exit, including on failure.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SOURCE = path.join(ROOT, 'src', 'cameraVerbs.js');
|
||||
const TESTS = 'src/routeCinematics.test.mjs';
|
||||
|
||||
/** @type {Array<{defect: string, from: string, to: string}>} */
|
||||
const MUTATIONS = [
|
||||
{
|
||||
defect: 'interrupt leaves the horizon tilted (round-1 behaviour)',
|
||||
from: 'const leveled = wasRoute && !tracking ? levelCameraRoll() : false;',
|
||||
to: 'const leveled = false;',
|
||||
},
|
||||
{
|
||||
defect: 'completion freezes a bank into the final frame',
|
||||
from: 'const appliedBankDeg = state.bankDeg * profile.speed;',
|
||||
to: 'const appliedBankDeg = state.bankDeg;',
|
||||
},
|
||||
{
|
||||
defect: 'heading lerps in Cartesian space and cannot cross a U-turn',
|
||||
from: ' const turned = Cesium.Matrix3.multiplyByVector(rotation, state.headingDir, _frameDir);',
|
||||
to: ' const turned = Cesium.Cartesian3.lerp(state.headingDir, gaze, k, _frameDir);',
|
||||
},
|
||||
{
|
||||
defect: 'the exact-180° bank direction becomes a floating-point coin toss',
|
||||
from: ' if (cosTurn <= -1 + ANTIPODAL_EPS && Math.abs(sinLeft) < ANTIPODAL_EPS) return Math.PI;',
|
||||
to: ' // branch removed',
|
||||
},
|
||||
{
|
||||
defect: 'a cold cell is read as sea level (the 1.3 km underground bug)',
|
||||
from: ' state.floorM = Number.isFinite(state.floorM) ? state.floorM : state.coldSeedFloorM;',
|
||||
to: ' state.floorM = carto.height;',
|
||||
},
|
||||
{
|
||||
defect: 'the flight never warms its own corridor',
|
||||
from: ' warmRouteCorridor(state, 0, ROUTE_WARM_START_M);',
|
||||
to: ' // warm removed',
|
||||
},
|
||||
{
|
||||
defect: 'the dolly races the fire-and-forget warm instead of arming for it',
|
||||
from: ' if (canAnswer && !state.floorKnown && !state.departed && state.armS < ROUTE_ARM_S) {',
|
||||
to: ' if (false) {',
|
||||
},
|
||||
{
|
||||
defect: 'no mesh probe, so a permanently cold DEM is flown blind',
|
||||
from: ' if (cold.length && !state.meshProbeSpent && typeof state.probeFn === \'function\') {',
|
||||
to: ' if (false) {',
|
||||
},
|
||||
{
|
||||
defect: 'the launch-altitude hold is dropped and the eye descends blind',
|
||||
from: ' if (!state.floorKnown && Number.isFinite(state.safeHoldHeightM)) {',
|
||||
to: ' if (false) {',
|
||||
},
|
||||
{
|
||||
defect: 'releasing the hold onto late data drops the eye without a limit',
|
||||
from: ' if (Number.isFinite(state.appliedHeightM)) {',
|
||||
to: ' if (false) {',
|
||||
},
|
||||
{
|
||||
defect: 'a late mid-flight floor is taken whole and snaps the eye',
|
||||
from: ' state.floorM = routeFloorHoldM(state.floorM, sampledFloor, step);\n state.floorKnown = true;',
|
||||
to: ' state.floorM = sampledFloor;\n state.floorKnown = true;',
|
||||
},
|
||||
{
|
||||
defect: 'the lookahead floor read is dropped (clearance only underfoot)',
|
||||
from: ' const warm = [here, ahead].filter((value) => Number.isFinite(value));',
|
||||
to: ' const warm = [here].filter((value) => Number.isFinite(value));',
|
||||
},
|
||||
{
|
||||
defect: 'the shaping floor adopts rises instantly and pops at cell edges',
|
||||
from: ' if (!Number.isFinite(previousM)) return sampledM;',
|
||||
to: ' if (!Number.isFinite(previousM) || sampledM >= previousM) return sampledM;',
|
||||
},
|
||||
{
|
||||
defect: 'the mesh probe fires every arming frame instead of once per route',
|
||||
from: ' if (cold.length && !state.meshProbeSpent && typeof state.probeFn === \'function\') {\n state.meshProbeSpent = true;',
|
||||
to: ' if (cold.length && typeof state.probeFn === \'function\') {\n state.meshProbeSpent = true;',
|
||||
},
|
||||
{
|
||||
defect: 'one warm cell resolves a corridor whose other cells are unknown',
|
||||
from: ' if (cold.length && !state.meshProbeCoveredCold) return false;',
|
||||
to: ' // partial-corridor guard removed',
|
||||
},
|
||||
{
|
||||
defect: 'the probe re-reads cells the cache already answered',
|
||||
from: ' const probe = state.probeFn(cold) || {};',
|
||||
to: ' const probe = state.probeFn(cells) || {};',
|
||||
},
|
||||
{
|
||||
defect: 'the hard clearance clamp reads the SMOOTHED floor, not the raw sample',
|
||||
from: ' (Number.isFinite(sampledFloor) ? sampledFloor : state.floorM) + ROUTE_MIN_CLEARANCE_M,',
|
||||
to: ' state.floorM + ROUTE_MIN_CLEARANCE_M,',
|
||||
},
|
||||
{
|
||||
defect: 'levelling also RE-FRAMES the camera (destination passed with the HPR)',
|
||||
from: ' cam.setView({ orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 } });',
|
||||
to: ' cam.setView({ destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000), orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 } });',
|
||||
},
|
||||
{
|
||||
defect: 'the OS reduced-motion preference is never consulted',
|
||||
from: ' reducedMotion: prefersReducedMotion(),',
|
||||
to: ' reducedMotion: false,',
|
||||
},
|
||||
];
|
||||
|
||||
const original = fs.readFileSync(SOURCE, 'utf8');
|
||||
let caught = 0;
|
||||
const missed = [];
|
||||
|
||||
process.on('exit', () => fs.writeFileSync(SOURCE, original));
|
||||
|
||||
console.log(`\nfly_route pin strength — ${MUTATIONS.length} individual reverts\n`);
|
||||
for (const { defect, from, to } of MUTATIONS) {
|
||||
if (!original.includes(from)) {
|
||||
missed.push(`${defect} (ANCHOR MISSING — the mutation no longer applies)`);
|
||||
console.log(` \x1b[31mSTALE\x1b[0m ${defect}`);
|
||||
continue;
|
||||
}
|
||||
fs.writeFileSync(SOURCE, original.replace(from, to));
|
||||
let red = false;
|
||||
let by = '';
|
||||
try {
|
||||
execFileSync('node', ['--test', TESTS], { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' });
|
||||
} catch (error) {
|
||||
red = true;
|
||||
const failed = String(error.stdout || '').split('\n')
|
||||
.filter((line) => line.trim().startsWith('✖') && line.includes('('))
|
||||
.map((line) => line.trim().slice(2).split(' (')[0]);
|
||||
by = [...new Set(failed)].slice(0, 2).join('; ');
|
||||
}
|
||||
if (red) {
|
||||
caught += 1;
|
||||
console.log(` \x1b[32mRED \x1b[0m ${defect}\n caught by: ${by}`);
|
||||
} else {
|
||||
missed.push(defect);
|
||||
console.log(` \x1b[31mGREEN\x1b[0m ${defect} <-- no pin covers this`);
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(SOURCE, original);
|
||||
|
||||
console.log(`\n ${caught}/${MUTATIONS.length} defects caught by the pins`);
|
||||
if (missed.length) {
|
||||
console.log('\n uncovered:');
|
||||
for (const item of missed) console.log(` - ${item}`);
|
||||
}
|
||||
process.exitCode = missed.length ? 1 : 0;
|
||||
@@ -0,0 +1,721 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Moving visual evidence for focus de-emphasis and aircraft recession.
|
||||
*
|
||||
* The operator owns the live dev server; this script never starts one:
|
||||
* node scripts/qa-focus-evidence.mjs --url http://localhost:4173 \
|
||||
* --screenshots-dir qa-shots/focus-evidence \
|
||||
* --json qa-shots/focus-evidence/report.json
|
||||
* node scripts/qa-focus-evidence.mjs --headful --smoke
|
||||
* node scripts/qa-focus-evidence.mjs --url http://localhost:4173 \
|
||||
* --params '{"focus":{"dimFloor":0.35},"horizon":{"scaleFloor":0.5}}'
|
||||
* node scripts/qa-focus-evidence.mjs --headful --basemap osm
|
||||
*
|
||||
* The harness owns the Cesium frame clock during capture: every frame applies
|
||||
* synthetic updates, advances explicit virtual time, calls scene.render(),
|
||||
* then screenshots. Headless mode keeps SwiftShader for CI-relative evidence;
|
||||
* --headful drops those flags and is the real-GPU visual sign-off path.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, fallback = null) => {
|
||||
const index = argv.indexOf(name);
|
||||
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
||||
};
|
||||
const hasFlag = (name) => argv.includes(name);
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const JSON_PATH = path.resolve(getOpt('--json', 'qa-shots/focus-evidence/report.json'));
|
||||
const SCREENSHOTS_DIR = path.resolve(getOpt('--screenshots-dir', 'qa-shots/focus-evidence'));
|
||||
const HEADFUL = hasFlag('--headful');
|
||||
const SMOKE = hasFlag('--smoke');
|
||||
const MAP_STACK_IDS = Object.freeze(['photoreal', 'bing-aerial', 'bing-labels', 'osm']);
|
||||
const BASEMAP = getOpt('--basemap', 'photoreal');
|
||||
const VIEWPORT = Object.freeze({ width: 1440, height: 900 });
|
||||
const FRAME_COUNT = SMOKE ? 6 : 30;
|
||||
const FRAME_MS = 100;
|
||||
const TILE_SETTLE_TIMEOUT_MS = 45_000;
|
||||
const TARGET_ID = 'f0c001';
|
||||
const _firstCaptureScenarios = new Set();
|
||||
if (!MAP_STACK_IDS.includes(BASEMAP)) {
|
||||
throw new Error(`Invalid --basemap ${BASEMAP}; expected one of ${MAP_STACK_IDS.join(', ')}`);
|
||||
}
|
||||
const TARGET = Object.freeze({
|
||||
id: TARGET_ID,
|
||||
callsign: 'FOCUS1',
|
||||
longitude: -97.7431,
|
||||
latitude: 30.2672,
|
||||
altitudeM: 3_000,
|
||||
klass: 'airliner',
|
||||
});
|
||||
// Owner's 13:58 horizon composition: a low, east-facing regional view whose
|
||||
// distant field sits close to the geometric limb instead of a whole-globe view.
|
||||
const HORIZON_CAMERA = Object.freeze([
|
||||
-100, 30, 1_000_000, Math.PI / 2, -0.52,
|
||||
]);
|
||||
|
||||
function parseParams() {
|
||||
const raw = getOpt('--params');
|
||||
if (!raw) return { focus: {}, horizon: {} };
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid --params JSON: ${error.message}`);
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('--params must be a JSON object');
|
||||
}
|
||||
if (parsed.focus || parsed.horizon) {
|
||||
if ((parsed.focus && (typeof parsed.focus !== 'object' || Array.isArray(parsed.focus)))
|
||||
|| (parsed.horizon && (typeof parsed.horizon !== 'object' || Array.isArray(parsed.horizon)))) {
|
||||
throw new Error('--params focus and horizon values must be JSON objects');
|
||||
}
|
||||
return { focus: parsed.focus || {}, horizon: parsed.horizon || {} };
|
||||
}
|
||||
const focusKeys = new Set([
|
||||
'paddingPx', 'dimFloor', 'nearerBehavior', 'hysteresisPx',
|
||||
'distanceHysteresisRatio', 'attackMs', 'releaseMs', 'writeEpsilon',
|
||||
]);
|
||||
const horizonKeys = new Set([
|
||||
'startLimbRatio', 'scaleFloor', 'alphaFloor', 'combinedAlphaFloor',
|
||||
'globeViewBlendStartM', 'globeViewBlendEndM', 'globeViewHeightM',
|
||||
'earthRadiusM', 'writeEpsilon',
|
||||
]);
|
||||
const focus = {};
|
||||
const horizon = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (focusKeys.has(key)) focus[key] = value;
|
||||
if (horizonKeys.has(key)) horizon[key] = value;
|
||||
}
|
||||
return { focus, horizon };
|
||||
}
|
||||
|
||||
function add(a, b) {
|
||||
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
||||
}
|
||||
|
||||
function scale(v, amount) {
|
||||
return [v[0] * amount, v[1] * amount, v[2] * amount];
|
||||
}
|
||||
|
||||
function screenPlanePosition(basis, xPx, yPx, fartherM = 0) {
|
||||
return add(
|
||||
add(
|
||||
add(basis.target, scale(basis.right, xPx * basis.metresPerPixel)),
|
||||
scale(basis.up, yPx * basis.metresPerPixel),
|
||||
),
|
||||
scale(basis.away, fartherM),
|
||||
);
|
||||
}
|
||||
|
||||
function shotPath(scenario, frame) {
|
||||
return path.join(SCREENSHOTS_DIR, `${scenario}-${frame}.png`);
|
||||
}
|
||||
|
||||
async function readTileReadiness(page) {
|
||||
return page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const controller = gev.mapStackController;
|
||||
const activeStack = controller?.getActiveId?.() || null;
|
||||
const tileset = controller?.googleTileset || gev.tileset || null;
|
||||
const applicable = activeStack === 'photoreal' && tileset?.show !== false;
|
||||
return {
|
||||
tilesSettled: !applicable || tileset?.tilesLoaded === true,
|
||||
tileSettleApplicable: applicable,
|
||||
activeStack,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function awaitTilesSettled(page, scenario) {
|
||||
const result = await page.evaluate(async (timeoutMs) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const controller = gev.mapStackController;
|
||||
const activeStack = controller?.getActiveId?.() || null;
|
||||
// The map-stack controller owns the authoritative Google tileset handle;
|
||||
// the bootstrap field is retained only as a compatibility fallback.
|
||||
const tileset = controller?.googleTileset || gev.tileset || null;
|
||||
const applicable = activeStack === 'photoreal' && tileset?.show !== false;
|
||||
if (!applicable) {
|
||||
return {
|
||||
tilesSettled: true,
|
||||
tileSettleApplicable: false,
|
||||
activeStack,
|
||||
timedOut: false,
|
||||
};
|
||||
}
|
||||
if (tileset.tilesLoaded === true) {
|
||||
return {
|
||||
tilesSettled: true,
|
||||
tileSettleApplicable: true,
|
||||
activeStack,
|
||||
timedOut: false,
|
||||
};
|
||||
}
|
||||
|
||||
let allTilesLoadedObserved = false;
|
||||
const removeListener = tileset.allTilesLoaded?.addEventListener?.(() => {
|
||||
allTilesLoadedObserved = true;
|
||||
});
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
gev.viewer.scene.render(gev.viewer.clock.currentTime);
|
||||
if (tileset.tilesLoaded === true || allTilesLoadedObserved) {
|
||||
return {
|
||||
tilesSettled: true,
|
||||
tileSettleApplicable: true,
|
||||
activeStack,
|
||||
timedOut: false,
|
||||
};
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return {
|
||||
tilesSettled: false,
|
||||
tileSettleApplicable: true,
|
||||
activeStack,
|
||||
timedOut: true,
|
||||
};
|
||||
} finally {
|
||||
if (typeof removeListener === 'function') removeListener();
|
||||
}
|
||||
}, TILE_SETTLE_TIMEOUT_MS);
|
||||
const status = result.tilesSettled ? 'settled' : `NOT settled after ${TILE_SETTLE_TIMEOUT_MS / 1000}s`;
|
||||
console.log(` tiles (${scenario}): ${status}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function capture(page, scenario, frame) {
|
||||
if (!_firstCaptureScenarios.has(scenario)) {
|
||||
_firstCaptureScenarios.add(scenario);
|
||||
await awaitTilesSettled(page, scenario);
|
||||
}
|
||||
const tileReadiness = await readTileReadiness(page);
|
||||
const out = shotPath(scenario, frame);
|
||||
// Chromium's Page.captureScreenshot can wedge while a manually driven
|
||||
// WebGL surface is paused. Reading the explicitly rendered canvas captures
|
||||
// that same frame without handing frame ownership back to the browser.
|
||||
const dataUrl = await page.evaluate(() => (
|
||||
window.__godsEyeView.viewer.scene.canvas.toDataURL('image/png')
|
||||
));
|
||||
fs.writeFileSync(out, Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64'));
|
||||
return { file: out, ...tileReadiness };
|
||||
}
|
||||
|
||||
async function captureSequenceFrame(page, scenario, frameIndex, update) {
|
||||
if (SMOKE) console.log(` frame ${frameIndex + 1}/${FRAME_COUNT}: update`);
|
||||
await update();
|
||||
if (SMOKE) console.log(` frame ${frameIndex + 1}/${FRAME_COUNT}: render`);
|
||||
await advanceEvidenceFrame(page, FRAME_MS);
|
||||
if (SMOKE) console.log(` frame ${frameIndex + 1}/${FRAME_COUNT}: screenshot`);
|
||||
return capture(page, scenario, `frame-${String(frameIndex).padStart(2, '0')}`);
|
||||
}
|
||||
|
||||
async function takeFrameClock(page) {
|
||||
const result = await page.evaluate(() => {
|
||||
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
|
||||
return seam.takeFrameClock();
|
||||
});
|
||||
if (!result?.ok) throw new Error('Unable to take ownership of the evidence frame clock');
|
||||
}
|
||||
|
||||
async function advanceEvidenceFrame(page, deltaMs = FRAME_MS) {
|
||||
return page.evaluate((stepMs) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const viewer = gev.viewer;
|
||||
const seam = gev.dataManager.layers.get('flights').module.__focusEvidence;
|
||||
const nowMs = seam.advanceFrameClock(stepMs);
|
||||
const JulianDate = viewer.clock.currentTime.constructor;
|
||||
JulianDate.addSeconds(viewer.clock.currentTime, stepMs / 1000, viewer.clock.currentTime);
|
||||
viewer.dataSourceDisplay?.update(viewer.clock.currentTime);
|
||||
viewer.scene.render(viewer.clock.currentTime);
|
||||
// Cross a task boundary so Chrome can commit the explicitly rendered
|
||||
// surface before capture. Cesium's drained default loop stays disabled.
|
||||
return new Promise((resolve) => setTimeout(() => resolve(nowMs), 0));
|
||||
}, deltaMs);
|
||||
}
|
||||
|
||||
async function advanceEvidenceDuration(page, durationMs) {
|
||||
// Cross one 80 ms consumer quantum to publish newly changed desired state,
|
||||
// then jump to the requested endpoint. A zero-delta render is insufficient:
|
||||
// the production focus passes correctly retain their cadence gate.
|
||||
const totalMs = Math.max(0, durationMs);
|
||||
const publishMs = Math.min(80, totalMs);
|
||||
await advanceEvidenceFrame(page, publishMs);
|
||||
if (totalMs > publishMs) await advanceEvidenceFrame(page, totalMs - publishMs);
|
||||
}
|
||||
|
||||
async function releaseFrameClock(page) {
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('flights').module.__focusEvidence.releaseFrameClock();
|
||||
});
|
||||
}
|
||||
|
||||
async function installSyntheticFetches(page) {
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
const realFetch = window.fetch.bind(window);
|
||||
const json = (body) => Promise.resolve(new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}));
|
||||
window.fetch = (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input?.url || '';
|
||||
if (url.includes('/api/opensky-track')) return json({ path: [] });
|
||||
if (url.includes('/api/adsblol/trace')) return json({ timestamp: Date.now() / 1000, trace: [] });
|
||||
if (url.includes('/api/opensky')) return json({ time: Math.floor(Date.now() / 1000), states: [] });
|
||||
if (url.includes('/api/adsbdb/')) return json({ found: false });
|
||||
if (url.includes('/api/ais-live')) return json({ status: 'open', rows: [] });
|
||||
return realFetch(input, init);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForApp(page) {
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
await page.waitForFunction(
|
||||
() => Boolean(window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager),
|
||||
{ timeout: 60_000, polling: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
async function setBasemap(page, stackId) {
|
||||
const result = await page.evaluate(async (id) => (
|
||||
window.__godsEyeView.styleManager.setMapStack(id)
|
||||
), stackId);
|
||||
if (!result?.ok) {
|
||||
throw new Error(`Unable to activate --basemap ${stackId}: ${result?.error || 'unknown error'}`);
|
||||
}
|
||||
return result.activeStack;
|
||||
}
|
||||
|
||||
async function enableLayer(page, layerId) {
|
||||
await page.evaluate(async (id) => {
|
||||
await window.__godsEyeView.dataManager.setEnabled(id, true);
|
||||
}, layerId);
|
||||
}
|
||||
|
||||
async function requireEvidenceSeams(page) {
|
||||
const result = await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
return {
|
||||
flights: Boolean(manager.layers.get('flights')?.module?.__focusEvidence),
|
||||
vessels: Boolean(manager.layers.get('ais-live-vessels')?.module?.__focusEvidence),
|
||||
};
|
||||
});
|
||||
if (!result.flights || !result.vessels) {
|
||||
throw new Error('Focus evidence seams are unavailable. Use the Vite dev server (not a production preview/build).');
|
||||
}
|
||||
}
|
||||
|
||||
async function setCamera(page, values) {
|
||||
await page.evaluate(([lon, lat, height, heading, pitch]) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const ellipsoid = viewer.scene.globe.ellipsoid;
|
||||
viewer.trackedEntity = undefined;
|
||||
viewer.camera.cancelFlight?.();
|
||||
viewer.scene.tweens?.removeAll?.();
|
||||
viewer.camera.setView({
|
||||
destination: ellipsoid.cartographicToCartesian({
|
||||
longitude: lon * Math.PI / 180,
|
||||
latitude: lat * Math.PI / 180,
|
||||
height,
|
||||
}),
|
||||
orientation: { heading, pitch, roll: 0 },
|
||||
});
|
||||
}, values);
|
||||
await advanceEvidenceDuration(page, 300);
|
||||
}
|
||||
|
||||
async function injectAndTrackTarget(page, extraAircraft = [], target = TARGET) {
|
||||
const result = await page.evaluate(({ targetRecord, extras }) => {
|
||||
const layer = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
const injected = layer.__focusEvidence.setAircraft([targetRecord, ...extras]);
|
||||
const tracked = layer.trackById(targetRecord.id);
|
||||
return { injected, tracked };
|
||||
}, { targetRecord: target, extras: extraAircraft });
|
||||
if (!result.injected?.ok || !result.tracked) {
|
||||
throw new Error(`Synthetic target setup failed: ${JSON.stringify(result)}`);
|
||||
}
|
||||
await page.waitForFunction(
|
||||
() => Boolean(window.__godsEyeView.viewer.trackedEntity),
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await advanceEvidenceDuration(page, 400);
|
||||
}
|
||||
|
||||
async function readTrackedBasis(page) {
|
||||
return page.evaluate((targetId) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const layer = gev.dataManager.layers.get('flights').module;
|
||||
const target = layer.getAllPositions(100).find((entry) => entry.id === targetId)?.position;
|
||||
const camera = gev.viewer.camera;
|
||||
if (!target) throw new Error('Tracked target position unavailable');
|
||||
const toTarget = {
|
||||
x: target.x - camera.positionWC.x,
|
||||
y: target.y - camera.positionWC.y,
|
||||
z: target.z - camera.positionWC.z,
|
||||
};
|
||||
const distance = Math.hypot(toTarget.x, toTarget.y, toTarget.z);
|
||||
const canvasHeight = gev.viewer.scene.canvas.clientHeight;
|
||||
const metresPerPixel = (2 * distance * Math.tan(camera.frustum.fovy / 2)) / canvasHeight;
|
||||
return {
|
||||
target: [target.x, target.y, target.z],
|
||||
right: [camera.rightWC.x, camera.rightWC.y, camera.rightWC.z],
|
||||
up: [camera.upWC.x, camera.upWC.y, camera.upWC.z],
|
||||
away: [toTarget.x / distance, toTarget.y / distance, toTarget.z / distance],
|
||||
metresPerPixel,
|
||||
targetDistanceM: distance,
|
||||
};
|
||||
}, TARGET_ID);
|
||||
}
|
||||
|
||||
async function flightSnapshot(page) {
|
||||
return page.evaluate(() => (
|
||||
window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence.snapshot()
|
||||
));
|
||||
}
|
||||
|
||||
async function setTuning(page, params) {
|
||||
return page.evaluate((next) => (
|
||||
window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence.setTuning(next)
|
||||
), params);
|
||||
}
|
||||
|
||||
async function runControlledCrossing(page, effectiveParams) {
|
||||
const scenario = 's1-controlled-crossing';
|
||||
if (SMOKE) console.log(' setup: inject + track');
|
||||
await injectAndTrackTarget(page, [{ ...TARGET, id: 'f0c002', callsign: 'CROSS2' }]);
|
||||
if (SMOKE) console.log(' setup: read tracked basis');
|
||||
const basis = await readTrackedBasis(page);
|
||||
// Stay beyond the default 8% range-side hysteresis band so S1 exercises
|
||||
// the farther-contact dim path instead of hovering in its neutral band.
|
||||
const crossingDepthM = Math.max(600, basis.targetDistanceM * 0.12);
|
||||
const start = screenPlanePosition(basis, -180, 0, crossingDepthM);
|
||||
await page.evaluate((position) => {
|
||||
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
|
||||
seam.moveAircraft([{ id: 'f0c002', cartesian: position, trackDeg: 90 }]);
|
||||
}, start);
|
||||
// The placeholder begins at the target so tracking can establish its camera
|
||||
// first. Restore fully off-target before frame 0; otherwise the sequence
|
||||
// would start with inherited dimming rather than showing a true attack.
|
||||
if (SMOKE) console.log(' setup: settle off-target contact');
|
||||
await advanceEvidenceDuration(page, effectiveParams.focus.releaseMs + 150);
|
||||
const frames = [];
|
||||
for (let i = 0; i < FRAME_COUNT; i += 1) {
|
||||
const xPx = -180 + (360 * i) / (FRAME_COUNT - 1);
|
||||
const cartesian = screenPlanePosition(basis, xPx, 0, crossingDepthM);
|
||||
const captured = await captureSequenceFrame(page, scenario, i, () => page.evaluate((position) => {
|
||||
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
|
||||
seam.moveAircraft([{ id: 'f0c002', cartesian: position, trackDeg: 90 }]);
|
||||
}, cartesian));
|
||||
const snapshot = await flightSnapshot(page);
|
||||
frames.push({ index: i, xPx, ...captured, contacts: snapshot });
|
||||
}
|
||||
return { id: scenario, frameCount: frames.length, basis, crossingDepthM, frames };
|
||||
}
|
||||
|
||||
async function vesselRowsAroundTarget(page) {
|
||||
return page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const layer = gev.dataManager.layers.get('flights').module;
|
||||
const target = layer.getAllPositions(100).find((entry) => entry.id === 'f0c001')?.position;
|
||||
const Cartographic = gev.viewer.camera.positionCartographic.constructor;
|
||||
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
|
||||
const rows = [];
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
const angle = (i / 12) * Math.PI * 2;
|
||||
const eastM = Math.cos(angle) * (15 + (i % 3) * 12);
|
||||
const northM = Math.sin(angle) * (15 + (i % 3) * 12);
|
||||
// Cesium.Transforms is not global; a small local lat/lon offset is
|
||||
// sufficient at harbor scale and keeps the injected vessel at sea level.
|
||||
const carto = Cartographic.fromCartesian(target, ellipsoid);
|
||||
const lat = carto.latitude * 180 / Math.PI + northM / 111_320;
|
||||
const lon = carto.longitude * 180 / Math.PI
|
||||
+ eastM / (111_320 * Math.cos(carto.latitude));
|
||||
rows.push({
|
||||
mmsi: String(990000000 + i),
|
||||
name: `HARBOR ${String(i + 1).padStart(2, '0')}`,
|
||||
lat,
|
||||
lon,
|
||||
speed: 4 + (i % 5),
|
||||
course: (i * 31) % 360,
|
||||
type: i % 3 === 0 ? 'Cargo' : 'Tug',
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
}
|
||||
|
||||
async function runHarborClutter(page, effectiveParams) {
|
||||
const scenario = 's2-harbor-clutter';
|
||||
const lowTarget = { ...TARGET, altitudeM: 50 };
|
||||
// Harbor contacts straddle the tracked low-altitude subject's range. This
|
||||
// scenario deliberately exercises the documented tunable all-overlap policy
|
||||
// instead of being neutralized by the production `allow` range band.
|
||||
const clutterFocus = { ...effectiveParams.focus, nearerBehavior: 'dim' };
|
||||
await injectAndTrackTarget(page, [], lowTarget);
|
||||
const rows = await vesselRowsAroundTarget(page);
|
||||
await page.evaluate((vessels) => {
|
||||
const seam = window.__godsEyeView.dataManager.layers
|
||||
.get('ais-live-vessels').module.__focusEvidence;
|
||||
seam.setVessels(vessels);
|
||||
}, rows);
|
||||
|
||||
await setTuning(page, { focus: { ...clutterFocus, dimFloor: 1 } });
|
||||
await advanceEvidenceDuration(page, 450);
|
||||
const beforeCapture = await capture(page, scenario, 'before');
|
||||
const before = await page.evaluate(() => (
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('ais-live-vessels').module.__focusEvidence.snapshot()
|
||||
));
|
||||
|
||||
await setTuning(page, { ...effectiveParams, focus: clutterFocus });
|
||||
await advanceEvidenceDuration(page, Math.max(500, effectiveParams.focus.attackMs + 200));
|
||||
const afterCapture = await capture(page, scenario, 'after');
|
||||
const after = await page.evaluate(() => (
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('ais-live-vessels').module.__focusEvidence.snapshot()
|
||||
));
|
||||
await setTuning(page, effectiveParams);
|
||||
return {
|
||||
id: scenario,
|
||||
focusParams: clutterFocus,
|
||||
frames: [
|
||||
{ phase: 'before', ...beforeCapture, contacts: before },
|
||||
{ phase: 'after', ...afterCapture, contacts: after },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function runAirportTraffic(page, effectiveParams) {
|
||||
const scenario = 's3-airport-traffic';
|
||||
const placeholders = Array.from({ length: 40 }, (_, index) => ({
|
||||
...TARGET,
|
||||
id: `f3${String(index).padStart(4, '0')}`,
|
||||
callsign: `APT${String(index).padStart(2, '0')}`,
|
||||
klass: index % 7 === 0 ? 'widebody' : index % 5 === 0 ? 'fastjet' : 'airliner',
|
||||
}));
|
||||
await injectAndTrackTarget(page, placeholders);
|
||||
const basis = await readTrackedBasis(page);
|
||||
const trafficDepthM = Math.max(600, basis.targetDistanceM * 0.12);
|
||||
const startingMoves = placeholders.map((record, index) => {
|
||||
const angle = (index / placeholders.length) * Math.PI * 2;
|
||||
return {
|
||||
id: record.id,
|
||||
cartesian: screenPlanePosition(
|
||||
basis,
|
||||
Math.cos(angle) * 220,
|
||||
Math.sin(angle) * 220 * 0.55,
|
||||
trafficDepthM + index * 3,
|
||||
),
|
||||
trackDeg: (angle * 180 / Math.PI + 180) % 360,
|
||||
};
|
||||
});
|
||||
await page.evaluate((positions) => {
|
||||
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
|
||||
seam.moveAircraft(positions);
|
||||
}, startingMoves);
|
||||
await advanceEvidenceDuration(page, effectiveParams.focus.releaseMs + 150);
|
||||
const frames = [];
|
||||
for (let frame = 0; frame < FRAME_COUNT; frame += 1) {
|
||||
const progress = frame / (FRAME_COUNT - 1);
|
||||
const radiusPx = 220 - 205 * progress;
|
||||
const moves = placeholders.map((record, index) => {
|
||||
const angle = (index / placeholders.length) * Math.PI * 2 + progress * 0.35;
|
||||
return {
|
||||
id: record.id,
|
||||
cartesian: screenPlanePosition(
|
||||
basis,
|
||||
Math.cos(angle) * radiusPx,
|
||||
Math.sin(angle) * radiusPx * 0.55,
|
||||
trafficDepthM + index * 3,
|
||||
),
|
||||
trackDeg: (angle * 180 / Math.PI + 180) % 360,
|
||||
};
|
||||
});
|
||||
const captured = await captureSequenceFrame(page, scenario, frame, () => page.evaluate((positions) => {
|
||||
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
|
||||
seam.moveAircraft(positions);
|
||||
}, moves));
|
||||
frames.push({ index: frame, radiusPx, ...captured, contacts: await flightSnapshot(page) });
|
||||
}
|
||||
return { id: scenario, frameCount: frames.length, basis, frames };
|
||||
}
|
||||
|
||||
async function runHorizonBand(page, effectiveParams) {
|
||||
const scenario = 's4-horizon-band';
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.dataManager.layers.get('flights').module.stopTracking();
|
||||
});
|
||||
const contacts = [];
|
||||
for (let row = 0; row < 6; row += 1) {
|
||||
for (let column = 0; column < 10; column += 1) {
|
||||
contacts.push({
|
||||
id: `f4${String(row * 10 + column).padStart(4, '0')}`,
|
||||
callsign: `LIMB${String(row * 10 + column).padStart(2, '0')}`,
|
||||
longitude: -70.5 + column * 0.42,
|
||||
latitude: 27.5 + row * 0.95,
|
||||
altitudeM: 10_000 + (column % 3) * 800,
|
||||
klass: column % 4 === 0 ? 'widebody' : 'airliner',
|
||||
});
|
||||
}
|
||||
}
|
||||
await page.evaluate((records) => {
|
||||
window.__godsEyeView.dataManager.layers.get('flights')
|
||||
.module.__focusEvidence.setAircraft(records);
|
||||
}, contacts);
|
||||
await setCamera(page, HORIZON_CAMERA);
|
||||
|
||||
await setTuning(page, {
|
||||
horizon: { ...effectiveParams.horizon, scaleFloor: 1, alphaFloor: 1 },
|
||||
});
|
||||
await advanceEvidenceDuration(page, 450);
|
||||
const beforeCapture = await capture(page, scenario, 'before');
|
||||
const before = await flightSnapshot(page);
|
||||
|
||||
await setTuning(page, effectiveParams);
|
||||
await advanceEvidenceDuration(page, 450);
|
||||
const afterCapture = await capture(page, scenario, 'after');
|
||||
const after = await flightSnapshot(page);
|
||||
return {
|
||||
id: scenario,
|
||||
camera: HORIZON_CAMERA,
|
||||
frames: [
|
||||
{ phase: 'before', ...beforeCapture, contacts: before },
|
||||
{ phase: 'after', ...afterCapture, contacts: after },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function browserContext(page, browser) {
|
||||
const gl = await page.evaluate(() => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
||||
const debug = context?.getExtension('WEBGL_debug_renderer_info');
|
||||
return {
|
||||
userAgent: navigator.userAgent,
|
||||
dpr: window.devicePixelRatio,
|
||||
vendor: debug
|
||||
? context.getParameter(debug.UNMASKED_VENDOR_WEBGL)
|
||||
: context?.getParameter(context.VENDOR) || 'unavailable',
|
||||
renderer: debug
|
||||
? context.getParameter(debug.UNMASKED_RENDERER_WEBGL)
|
||||
: context?.getParameter(context.RENDERER) || 'unavailable',
|
||||
};
|
||||
});
|
||||
return {
|
||||
browser: await browser.version(),
|
||||
userAgent: gl.userAgent,
|
||||
webglVendor: gl.vendor,
|
||||
webglRenderer: gl.renderer,
|
||||
viewport: { ...VIEWPORT, dpr: gl.dpr },
|
||||
machine: {
|
||||
hostname: os.hostname(),
|
||||
platform: os.platform(),
|
||||
arch: os.arch(),
|
||||
},
|
||||
caveat: HEADFUL
|
||||
? 'Headful capture omits SwiftShader flags and is the real-GPU visual sign-off path.'
|
||||
: 'SwiftShader/headless output is relative-only; rerun with --headful for real-GPU sign-off.',
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const requestedParams = parseParams();
|
||||
const response = await fetch(APP_URL).catch((error) => ({ ok: false, statusText: error.message }));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Live dev server unavailable at ${APP_URL}: ${response.status || response.statusText}`);
|
||||
}
|
||||
fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
|
||||
fs.mkdirSync(path.dirname(JSON_PATH), { recursive: true });
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
protocolTimeout: 300000,
|
||||
args: [
|
||||
...(HEADFUL ? [] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']),
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
`--window-size=${VIEWPORT.width},${VIEWPORT.height}`,
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport(VIEWPORT);
|
||||
const consoleMessages = [];
|
||||
page.on('console', (message) => {
|
||||
if (['error', 'warning', 'warn'].includes(message.type())) {
|
||||
consoleMessages.push({ type: message.type(), text: message.text() });
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => consoleMessages.push({ type: 'pageerror', text: error.message }));
|
||||
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
url: APP_URL,
|
||||
screenshotsDir: SCREENSHOTS_DIR,
|
||||
requestedParams,
|
||||
requestedBasemap: BASEMAP,
|
||||
activeBasemap: null,
|
||||
smoke: SMOKE,
|
||||
effectiveParams: null,
|
||||
context: null,
|
||||
scenarios: [],
|
||||
consoleMessages,
|
||||
};
|
||||
try {
|
||||
await installSyntheticFetches(page);
|
||||
await waitForApp(page);
|
||||
report.activeBasemap = await setBasemap(page, BASEMAP);
|
||||
await enableLayer(page, 'flights');
|
||||
await enableLayer(page, 'ais-live-vessels');
|
||||
await requireEvidenceSeams(page);
|
||||
await takeFrameClock(page);
|
||||
report.context = await browserContext(page, browser);
|
||||
report.effectiveParams = await setTuning(page, requestedParams);
|
||||
|
||||
console.log(`Focus evidence → ${APP_URL}`);
|
||||
console.log(` screenshots: ${SCREENSHOTS_DIR}`);
|
||||
console.log(` renderer : ${report.context.webglRenderer}`);
|
||||
console.log(` basemap : ${report.activeBasemap}`);
|
||||
console.log(` params : ${JSON.stringify(report.effectiveParams)}`);
|
||||
console.log(` mode : ${SMOKE ? 'smoke (S1, 6 frames)' : 'full (S1-S4)'}`);
|
||||
console.log(` caveat : ${report.context.caveat}`);
|
||||
|
||||
const scenarios = [
|
||||
['S1 controlled crossing', () => runControlledCrossing(page, report.effectiveParams)],
|
||||
['S2 harbor clutter', () => runHarborClutter(page, report.effectiveParams)],
|
||||
['S3 airport traffic', () => runAirportTraffic(page, report.effectiveParams)],
|
||||
['S4 horizon band', () => runHorizonBand(page, report.effectiveParams)],
|
||||
];
|
||||
for (const [label, run] of (SMOKE ? scenarios.slice(0, 1) : scenarios)) {
|
||||
console.log(` capture : ${label}`);
|
||||
await page.bringToFront();
|
||||
report.scenarios.push(await run());
|
||||
}
|
||||
} finally {
|
||||
if (!page.isClosed()) await releaseFrameClock(page).catch(() => {});
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
fs.writeFileSync(JSON_PATH, `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(` report : ${JSON_PATH}`);
|
||||
if (consoleMessages.some((message) => message.type === 'error' || message.type === 'pageerror')) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error?.stack || error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,726 @@
|
||||
/**
|
||||
* qa-height-datum.mjs — height/vertical-datum numeric proof harness.
|
||||
*
|
||||
* Scaffold reused verbatim from qa-cctv-v2.mjs: the puppeteer launcher
|
||||
* (Chrome executable discovery, headless flags), `QA_BASE_URL` env,
|
||||
* `record()`/tally pattern, and the Google-Maps-key-injection dev-server
|
||||
* recipe documented in its header comment. See that file for the reasoning
|
||||
* behind the launch flags and the SwiftShader caveats they share.
|
||||
*
|
||||
* Unlike qa-cctv-v2 (frustum GEOMETRY / raycast-count invariants), this
|
||||
* harness asserts NUMBERS: ground heights, altitudes, and terrain-provider
|
||||
* identity. Every assertion is GL-independent (no pixel/screenshot
|
||||
* comparisons) — it reads plain numbers off live Cesium entities and the
|
||||
* app's own public module APIs, exactly like qa-cctv-v2's SERIALIZE_GEOM_SRC
|
||||
* pattern.
|
||||
*
|
||||
* Three assertion groups (brief order):
|
||||
*
|
||||
* 1. CCTV ground correctness — for 5 London + 5 Austin + 3 SF cameras,
|
||||
* assert each record's placed mount ground reads finitely, then compare
|
||||
* its separately exposed `groundPriorM` against the
|
||||
* authoritative Re:Earth ellipsoidal value fetched through the app's
|
||||
* OWN `/api/terrain/heights` proxy (not a second upstream call — the
|
||||
* proxy IS the oracle here, matching Task 2/3's contract). Keeping the
|
||||
* prior separate is essential because Google-3D can legitimately refine
|
||||
* the rendered ground to the photogrammetric mesh while the immutable
|
||||
* Re:Earth prior remains the fallback/datum reference. The prior check is
|
||||
* `|groundPriorM - reearthEllipsoid| < 6m`. Google-3D after
|
||||
* the one-shot snap: London/Austin active-camera ground must fall in
|
||||
* the brief's bands (tileset-vs-DEM legitimately differs by building
|
||||
* height, so bands not exact-match).
|
||||
*
|
||||
* 2. Aircraft — sample >=10 live aircraft via the flights layer's public
|
||||
* API. `renderAltitudeM` isn't exposed as a named field on any public
|
||||
* method (only `flights.js`/`militaryFlights.js` module-private
|
||||
* `_flightData` carries `geoAltitudeM`/`renderAltitudeM` directly), so
|
||||
* this harness reconstructs both sides purely from public methods:
|
||||
* - render height: `getAllPositions()[i].altitudeM`, which is
|
||||
* `Cartographic.fromCartesian(billboard.position).height` — i.e.
|
||||
* exactly the height passed to `Cartesian3.fromDegrees(lon, lat,
|
||||
* renderAltitudeM)` at the placement site (flights.js ~:2100).
|
||||
* - aviation/baro height: `getNearby()`/`findByQuery()`'s
|
||||
* `altitudeM`, which is `info.altitude` (the UNTOUCHED sticky
|
||||
* barometric field — flights.js ~:2111) — this is the "geoAltM ??
|
||||
* baroAltM" input side, not the render output.
|
||||
* This is the brief's documented fallback ("if geo/baro fields aren't
|
||||
* exposed on the public state, assert the weaker but still-meaningful
|
||||
* under-terrain + finite/plausible checks") PLUS a same-process,
|
||||
* Node-side independent recomputation of `baroM + geoidHeight(lat,lon)`
|
||||
* via a direct import of `src/data/geoid.js` (a pure module, imported
|
||||
* the same way qa-cctv-v2.mjs imports `computeFrustumGeometry` from
|
||||
* cctv.js) — giving a real numeric cross-check without reaching into
|
||||
* the layer's private closure state.
|
||||
*
|
||||
* Under-terrain detector fingerprint (tightened): a real datum-miss
|
||||
* renders an aircraft at the ~0m ellipsoidal SENTINEL (the pre-existing
|
||||
* "no data yet" default) while its actual terrain ground is nowhere
|
||||
* near 0 — that combination is unambiguous. A non-zero under-terrain
|
||||
* reading is NOT hard-failed: a legitimately climbing/descending
|
||||
* aircraft can transiently render close to real ground near an airport
|
||||
* (e.g. a climb-out a few dozen metres above a field that itself sits
|
||||
* well above sea level), which is correct behavior, not a bug. Those
|
||||
* ambiguous cases are recorded INCONCLUSIVE with the raw numbers
|
||||
* rather than FAILed.
|
||||
*
|
||||
* 3. Regime C re-resolve — switch the map stack to keyless OSM in-page via
|
||||
* `mapStackController.setStack('osm')`, assert
|
||||
* `viewer.terrainProvider` is NOT an `EllipsoidTerrainProvider`
|
||||
* (constructor-name check, matching qa-cctv-v2's "borrow statics off a
|
||||
* live instance's constructor" pattern — no `window.Cesium` global
|
||||
* exists), and that CCTV grounds re-resolve within one event cycle
|
||||
* (`gev:map-stack-changed` → cctv.js's regime re-arm — see its Task 5
|
||||
* handler) by serializing a camera's ground height before/after the
|
||||
* switch settles and asserting it moved OFF any old fabricated-prior
|
||||
* value.
|
||||
*
|
||||
* Upstream-dependent assertions (live Re:Earth `/api/terrain/heights`, live
|
||||
* OpenSky aircraft) are marked INCONCLUSIVE — not FAIL — when the upstream
|
||||
* is unreachable this run, mirroring qa-cctv-v2's tiles-timeout handling.
|
||||
* GL-dependent tile settling (the Google-3D one-shot ground snap actually
|
||||
* completing under headless SwiftShader) is likewise inconclusive-on-timeout.
|
||||
*
|
||||
* Run: QA_BASE_URL=http://localhost:4300 node scripts/qa-height-datum.mjs
|
||||
*
|
||||
* Exit 0 = no hard failures. Non-zero = at least one hard FAIL (or harness error).
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, dflt) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
const getFlag = (name) => argv.includes(name);
|
||||
|
||||
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const APP_URL = getOpt('--url', BASE_URL);
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots', 'height-datum-qa');
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
|
||||
try {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
const tag = ok === null ? '\x1b[33mINCONCLUSIVE\x1b[0m' : ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/**
|
||||
* Waits for the scene's 3D tileset to report tilesLoaded===true. Copied from
|
||||
* qa-cctv-v2.mjs (see its header comment for why this must be re-awaited
|
||||
* fresh before each action that depends on a REAL ground sample, not
|
||||
* reused from an earlier wait).
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {number} [timeoutMs=15000]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
function waitForTilesLoaded(page, timeoutMs = 15000) {
|
||||
return page.waitForFunction(
|
||||
() => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
const prims = scene.primitives;
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
const p = prims.get(i);
|
||||
if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') {
|
||||
return p.tilesLoaded === true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout: timeoutMs }
|
||||
).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a camera's placed mount-point cartographic height off its
|
||||
* `cctv-<id>-ray-tl` polyline entity (first position = the mount, per
|
||||
* cctv.js `buildCoverageEntities`/`applyFrustumGeometry`), returns
|
||||
* `{ mountAltM, groundM }` where `groundM = mountAltM - mountHeightM` — the
|
||||
* SAME arithmetic cctv.js itself uses in reverse
|
||||
* (`mountAlt = ground + mountHeightM` in `computeFrustumGeometry`).
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {string} camId
|
||||
* @returns {Promise<{mountAltM:number, mountHeightM:number, groundM:number}|null>}
|
||||
*/
|
||||
async function readCameraGround(page, camId) {
|
||||
return page.evaluate((id) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const time = viewer.clock.currentTime;
|
||||
const ent = viewer.entities.getById(`cctv-${id}-ray-tl`);
|
||||
if (!ent || !ent.polyline) return null;
|
||||
const positions = ent.polyline.positions.getValue(time);
|
||||
if (!Array.isArray(positions) || positions.length < 1) return null;
|
||||
const mount = positions[0];
|
||||
// No window.Cesium global (qa-cctv-v2 precedent) — borrow Cartographic's
|
||||
// static fromCartesian off a live Cartographic instance's constructor.
|
||||
const carto = viewer.scene.globe.ellipsoid.cartesianToCartographic(mount);
|
||||
const mountAltM = carto.height;
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
const cam = mod.getUIState().cameras.find((c) => c.id === id);
|
||||
const mountHeightM = cam ? cam.mountHeightM : null;
|
||||
return {
|
||||
mountAltM,
|
||||
mountHeightM,
|
||||
groundM: Number.isFinite(mountHeightM) ? mountAltM - mountHeightM : null,
|
||||
};
|
||||
}, camId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the authoritative Re:Earth ellipsoidal ground height for a
|
||||
* (lat, lon) pair through the APP'S OWN `/api/terrain/heights` proxy (the
|
||||
* same oracle terrainHeights.js's `resolveEllipsoidalGround` uses) — this is
|
||||
* the ground-truth reference for assertion group 1, fetched directly (not
|
||||
* through in-page code) so a proxy hiccup can be told apart from an app bug.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @returns {Promise<number|null>} ellipsoid height in metres, or null on failure.
|
||||
*/
|
||||
async function fetchReearthEllipsoid(lat, lon) {
|
||||
const url = `${APP_URL}/api/terrain/heights?points=${encodeURIComponent(`${lon.toFixed(5)},${lat.toFixed(5)}`)}`;
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
|
||||
if (!res.ok) return null;
|
||||
const body = await res.json();
|
||||
const v = Number(body?.results?.[0]?.ellipsoid);
|
||||
return Number.isFinite(v) ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`\nHeight-Datum Fix Numeric Proof`);
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Mode : ${HEADFUL ? 'headful' : 'headless'}\n`);
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL, { method: 'GET' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||
|
||||
// Node-side geoid grid, used only for the aircraft baro+N cross-check
|
||||
// (independent of the app's own bundled copy — same package, separate
|
||||
// process, so this is a real recomputation, not reading the app's cache).
|
||||
await ensureGeoidReady();
|
||||
|
||||
const chromeExecutable = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
],
|
||||
});
|
||||
|
||||
const consoleErrors = [];
|
||||
let exitCode = 0;
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
const t = msg.text();
|
||||
if (!/Failed to load resource|net::ERR|status of 4\d\d|status of 5\d\d/.test(t)) {
|
||||
consoleErrors.push(t);
|
||||
}
|
||||
}
|
||||
});
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
|
||||
{ timeout: 60000 }
|
||||
);
|
||||
await sleep(4000);
|
||||
|
||||
// =========================================================================
|
||||
// Group 1: CCTV ground correctness
|
||||
// =========================================================================
|
||||
console.log('Enabling CCTV layer (full city-packs catalog)...');
|
||||
await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const entry = dm.layers.get('cctv');
|
||||
if (!entry.enabled) await dm.toggle('cctv');
|
||||
});
|
||||
|
||||
const camCount = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().count);
|
||||
console.log(`CCTV catalog loaded: ${camCount} cameras. Waiting for the geometry-load queue to drain...`);
|
||||
// The full-catalog drain is NOT a precondition for group 1's assertions
|
||||
// below: every record renders correctly from `record.groundPrior` the
|
||||
// instant the Re:Earth prior batch lands (Task 5 contract #1/#4 — "the
|
||||
// prior IS the resolution" on globe stacks, and the fallback path in
|
||||
// google-3d before a real sample resolves), which is exactly what makes
|
||||
// the per-camera ground reads meaningful even mid-drain. The queue drain
|
||||
// itself additionally attempts ONE REAL scene.sampleHeight per camera in
|
||||
// google-3d regime (Task 5 contract #3/#5 — the ≤1×N invariant
|
||||
// qa-cctv-v2 also locks), and with the full city-packs catalog
|
||||
// size (800 cameras: 250 Austin + 300 Caltrans + 250 TfL — measured
|
||||
// directly probing this harness's own dev server) that can take many
|
||||
// minutes under headless SwiftShader (empirically ~2-6s/sample once
|
||||
// real tile contention kicks in past the first few hundred). Waiting for
|
||||
// FULL completion here would make routine harness runs impractically
|
||||
// slow for no assertion gain, so this bounds the wait to a fixed,
|
||||
// reasonable ceiling and reports the outcome as environmental/scale
|
||||
// dependent (inconclusive, not a hard fail) rather than failing the
|
||||
// whole run — mirroring qa-cctv-v2's own tiles-timeout handling.
|
||||
const DRAIN_WAIT_CEILING_MS = 120000;
|
||||
const drained = await page.waitForFunction(
|
||||
() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
const ui = mod.getUIState();
|
||||
return ui.loading && ui.loading.active === false;
|
||||
},
|
||||
{ timeout: DRAIN_WAIT_CEILING_MS }
|
||||
).then(() => true).catch(() => false);
|
||||
record(`CCTV geometry-load queue drains within ${Math.round(DRAIN_WAIT_CEILING_MS / 1000)}s (N=${camCount})`,
|
||||
drained ? true : null,
|
||||
drained
|
||||
? 'loading.active === false'
|
||||
: `did not fully drain within the ceiling at N=${camCount} — environmental/scale-dependent under headless SwiftShader ` +
|
||||
'(each un-drained camera still renders correctly from its Re:Earth prior; group 1 assertions below read that prior directly, so this does not block them)');
|
||||
|
||||
// Pull the full camera list, then bucket by city so we can pick 5
|
||||
// London + 5 Austin + 3 SF regardless of catalog ordering. TfL tags
|
||||
// `city: 'London'` and Austin's own pack tags `city: 'Austin'` exactly
|
||||
// (vite.config.js); Caltrans tags `city` with an arbitrary upstream
|
||||
// `nearbyPlace` string (NOT a stable "San Francisco" literal), so SF-area
|
||||
// Caltrans cameras are identified by proximity to the SF anchor instead
|
||||
// (same anchor vite.config.js's CALTRANS_ANCHORS uses for prioritization).
|
||||
const allCameras = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().cameras);
|
||||
console.log(`Camera catalog: ${allCameras.length} total.`);
|
||||
|
||||
const SF_ANCHOR = { lat: 37.7793, lon: -122.4193 };
|
||||
const haversineKm = (lat1, lon1, lat2, lon2) => {
|
||||
const R = 6371;
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
};
|
||||
|
||||
const londonCams = allCameras.filter((c) => c.city === 'London').slice(0, 5);
|
||||
const austinCams = allCameras.filter((c) => c.city === 'Austin').slice(0, 5);
|
||||
const sfCams = allCameras
|
||||
.filter((c) => c.provider === 'Caltrans' && haversineKm(c.lat, c.lon, SF_ANCHOR.lat, SF_ANCHOR.lon) < 60)
|
||||
.slice(0, 3);
|
||||
|
||||
record('found >=5 London (TfL) test cameras', londonCams.length >= 5, `found ${londonCams.length}`);
|
||||
record('found >=5 Austin test cameras', austinCams.length >= 5, `found ${austinCams.length}`);
|
||||
record('found >=3 SF-area (Caltrans) test cameras', sfCams.length >= 3 ? true : null,
|
||||
sfCams.length >= 3
|
||||
? `found ${sfCams.length}`
|
||||
: `found ${sfCams.length} — live Caltrans catalog unavailable/partial this run (upstream INCONCLUSIVE, not an app datum failure)`);
|
||||
|
||||
const cityBuckets = [
|
||||
{ label: 'London', cams: londonCams },
|
||||
{ label: 'Austin', cams: austinCams },
|
||||
{ label: 'SF', cams: sfCams },
|
||||
];
|
||||
|
||||
const groundRows = []; // for the report/console table
|
||||
|
||||
for (const { label, cams } of cityBuckets) {
|
||||
for (const cam of cams) {
|
||||
const state = await page.evaluate((id) => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
return mod.getUIState().cameras.find((camera) => camera.id === id) || null;
|
||||
}, cam.id);
|
||||
const geom = await readCameraGround(page, cam.id);
|
||||
if (!geom || !Number.isFinite(geom.groundM)) {
|
||||
record(`${label} ${cam.id}: record ground reads finitely`, false, 'ray-tl entity missing or non-finite height');
|
||||
continue;
|
||||
}
|
||||
record(`${label} ${cam.id}: rendered ground reads finitely`, true,
|
||||
`renderedGround=${geom.groundM.toFixed(1)}m`);
|
||||
const priorM = state?.groundPriorM;
|
||||
if (!Number.isFinite(priorM)) {
|
||||
record(`${label} ${cam.id}: Re:Earth ground prior resolved`, null,
|
||||
`renderedGround=${geom.groundM.toFixed(1)}m; prior unavailable this run`);
|
||||
groundRows.push({ label, id: cam.id, groundM: geom.groundM, priorM: null, reearthM: null });
|
||||
continue;
|
||||
}
|
||||
const reearth = await fetchReearthEllipsoid(cam.lat, cam.lon);
|
||||
if (reearth === null) {
|
||||
record(`${label} ${cam.id}: |ground prior - Re:Earth ellipsoid| < 6m`, null,
|
||||
`Re:Earth proxy unreachable this run — prior=${priorM.toFixed(1)}m rendered=${geom.groundM.toFixed(1)}m (upstream INCONCLUSIVE, not a harness/app failure)`);
|
||||
groundRows.push({ label, id: cam.id, groundM: geom.groundM, priorM, reearthM: null });
|
||||
continue;
|
||||
}
|
||||
const delta = Math.abs(priorM - reearth);
|
||||
record(`${label} ${cam.id}: |ground prior - Re:Earth ellipsoid| < 6m`, delta < 6,
|
||||
`prior=${priorM.toFixed(1)}m rendered=${geom.groundM.toFixed(1)}m reearth=${reearth.toFixed(1)}m Δ=${delta.toFixed(1)}m`);
|
||||
groundRows.push({ label, id: cam.id, groundM: geom.groundM, priorM, reearthM: reearth });
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n Ground-height table (city, camera, rendered ground, stored prior, Re:Earth ellipsoid):');
|
||||
for (const row of groundRows) {
|
||||
console.log(` ${row.label.padEnd(8)} ${row.id.padEnd(24)} rendered=${row.groundM.toFixed(1)}m` +
|
||||
(row.priorM !== null ? ` prior=${row.priorM.toFixed(1)}m` : ' prior=N/A') +
|
||||
(row.reearthM !== null ? ` reearth=${row.reearthM.toFixed(1)}m` : ' reearth=N/A'));
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Google-3D band check: activate the London and Austin cameras nearest
|
||||
// the viewer (mirrors qa-cctv-v2's focusNearest activation pattern),
|
||||
// wait for the one-shot ground snap to complete (tilesLoaded gate), then
|
||||
// assert the ACTIVE camera's ground falls in the brief's band. Bands
|
||||
// (not exact) because the Google tileset surface legitimately differs
|
||||
// from the Re:Earth DEM by building height once the snap refines it.
|
||||
// -----------------------------------------------------------------------
|
||||
console.log('Checking Google-3D active-camera ground BANDS (post-snap)...');
|
||||
async function checkActiveCameraBand(cityLabel, targetCamId, band) {
|
||||
await page.evaluate((id) => {
|
||||
window.__godsEyeView.dataManager.layers.get('cctv').module.selectCamera(id, { focus: true, durationSec: 0.1 });
|
||||
}, targetCamId);
|
||||
await sleep(600);
|
||||
const tilesReady = await waitForTilesLoaded(page, 30000);
|
||||
// Give the one-shot completion pass a beat to land even after tiles
|
||||
// report ready (update() ticks on its own interval — see cctv.js).
|
||||
await sleep(1500);
|
||||
const geom = await readCameraGround(page, targetCamId);
|
||||
if (!tilesReady) {
|
||||
record(`${cityLabel} active camera (${targetCamId}) google-3d ground ∈ [${band[0]},${band[1]}]m`, null,
|
||||
`tiles never reported loaded within 30s under headless GL — environmental, inconclusive (recordGround=${geom?.groundM?.toFixed?.(1) ?? 'n/a'}m)`);
|
||||
return;
|
||||
}
|
||||
if (!geom || !Number.isFinite(geom.groundM)) {
|
||||
record(`${cityLabel} active camera (${targetCamId}) google-3d ground ∈ [${band[0]},${band[1]}]m`, false, 'ground unreadable');
|
||||
return;
|
||||
}
|
||||
const inBand = geom.groundM >= band[0] && geom.groundM <= band[1];
|
||||
record(`${cityLabel} active camera (${targetCamId}) google-3d ground ∈ [${band[0]},${band[1]}]m`, inBand,
|
||||
`recordGround=${geom.groundM.toFixed(1)}m`);
|
||||
}
|
||||
|
||||
if (londonCams.length) {
|
||||
await checkActiveCameraBand('London', londonCams[0].id, [45, 75]);
|
||||
} else {
|
||||
record('London active camera google-3d ground ∈ [45,75]m', null, 'no London camera available to test');
|
||||
}
|
||||
if (austinCams.length) {
|
||||
await checkActiveCameraBand('Austin', austinCams[0].id, [110, 135]);
|
||||
} else {
|
||||
record('Austin active camera google-3d ground ∈ [110,135]m', null, 'no Austin camera available to test');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Group 2: Aircraft render altitude
|
||||
// =========================================================================
|
||||
console.log('Enabling flights layer, waiting for a live aircraft sample...');
|
||||
await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const entry = dm.layers.get('flights');
|
||||
if (!entry.enabled) await dm.toggle('flights');
|
||||
});
|
||||
|
||||
// OpenSky polls on its own interval; give it real time to land a batch
|
||||
// (the layer polls every ~30s per docs/CURRENT-STATE.md).
|
||||
const gotAircraft = await page.waitForFunction(
|
||||
() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
return mod.getAllPositions(1).length > 0;
|
||||
},
|
||||
{ timeout: 45000 }
|
||||
).then(() => true).catch(() => false);
|
||||
|
||||
if (!gotAircraft) {
|
||||
record('>=10 live aircraft sampled from OpenSky', null,
|
||||
'no aircraft appeared within 45s — OpenSky upstream INCONCLUSIVE this run (not an app failure)');
|
||||
} else {
|
||||
// Let a full second poll cycle land (updateInterval: 30000 — see
|
||||
// flights.js) before sampling. A just-appeared ON-GROUND aircraft can
|
||||
// legitimately render at the sticky `alt` default (0m ellipsoidal) for
|
||||
// ONE tick while `_warmGroundedAircraftSurfaceCache`'s batch resolve is
|
||||
// still in flight (fire-and-forget; picked up by cachedEllipsoidalGround
|
||||
// on a LATER poll — see flights.js's own comment on that function). That
|
||||
// window is a documented, time-bounded product behavior, not a
|
||||
// datum-fix regression — sampling immediately after the FIRST aircraft
|
||||
// appears would catch exactly that transient and misreport it as an
|
||||
// under-terrain violation. Waiting past a second poll interval lets the
|
||||
// cache warm before the under-terrain detector runs.
|
||||
console.log('Waiting through a second poll cycle so on-ground aircraft ground-cache warm-up completes...');
|
||||
await sleep(32000);
|
||||
|
||||
// getAllPositions gives render-height (carto.height off the actual
|
||||
// billboard Cartesian3 — i.e. renderAltitudeM as placed by
|
||||
// Cartesian3.fromDegrees(lon, lat, renderAltitudeM)); getNearby gives
|
||||
// the untouched aviation/baro field (info.altitude) per-icao24. Cross
|
||||
// the two public APIs by icao24/id to reconstruct both sides without
|
||||
// reaching into the layer's private _flightData closure.
|
||||
const allPositions = await page.evaluate(() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
return mod.getAllPositions(60);
|
||||
});
|
||||
// getNearby needs an ECEF center + range; use the live camera position
|
||||
// (a real Cartesian3 instance — no window.Cesium global available, so
|
||||
// this borrows a live instance rather than constructing one, matching
|
||||
// qa-cctv-v2's pattern) with an effectively unbounded range so it
|
||||
// returns everything currently shown regardless of viewer position.
|
||||
const nearbyList = await page.evaluate(() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
const center = window.__godsEyeView.viewer.camera.position;
|
||||
return mod.getNearby(center, Number.MAX_VALUE, 200).map((a) => ({ icao24: a.icao24, altitudeM: a.altitudeM }));
|
||||
});
|
||||
const baroById = new Map(nearbyList.map((a) => [a.icao24, a.altitudeM]));
|
||||
|
||||
const sampleSize = Math.min(allPositions.length, 25);
|
||||
const sample = allPositions.slice(0, sampleSize);
|
||||
record(`>=10 live aircraft sampled from OpenSky (public getAllPositions/getNearby)`,
|
||||
sample.length >= 10 ? true : (sample.length > 0 ? null : false),
|
||||
`sampled ${sample.length} (getAllPositions total=${allPositions.length})`);
|
||||
|
||||
let plausibleCount = 0;
|
||||
let checkedCount = 0;
|
||||
const underTerrainDetails = [];
|
||||
const baroPlusNDeltas = [];
|
||||
for (const ac of sample) {
|
||||
const renderAltM = ac.altitudeM; // carto.height off the live billboard position
|
||||
const baroM = baroById.get(ac.id);
|
||||
|
||||
// Finite + plausible sanity: render altitude must be a real number in
|
||||
// a physically sane band (-500m .. 20000m covers everything from
|
||||
// Death-Valley-adjacent ground traffic to high-altitude cruise).
|
||||
const finitePlausible = Number.isFinite(renderAltM) && renderAltM > -500 && renderAltM < 20000;
|
||||
if (finitePlausible) plausibleCount += 1;
|
||||
|
||||
// Cross-check vs baro+geoidHeight when we have the baro side (weaker
|
||||
// per-aircraft equality check: geoAltitudeM isn't exposed as a named
|
||||
// field on any public flights-layer method — only the module-private
|
||||
// `_flightData` carries geoAltitudeM/renderAltitudeM directly — so
|
||||
// this harness can only recompute the FALLBACK branch's expected
|
||||
// value (baro + geoid N) from the public API and log the delta,
|
||||
// rather than assert it individually, for aircraft where the true
|
||||
// render source was actually geo_altitude, which can legitimately
|
||||
// differ from baro+N by tens of metres).
|
||||
if (Number.isFinite(baroM) && Number.isFinite(renderAltM)) {
|
||||
const n = geoidHeight(ac.latitude, ac.longitude);
|
||||
const expectedBaroPlusN = baroM + n;
|
||||
const deltaVsBaroPlusN = Math.abs(renderAltM - expectedBaroPlusN);
|
||||
// Not asserted individually (geo_altitude legitimately diverges from
|
||||
// baro+N) — logged into the report table so the cross-check is
|
||||
// actually visible, not silently discarded.
|
||||
baroPlusNDeltas.push({ id: ac.id, renderAltM, expectedBaroPlusN, deltaVsBaroPlusN });
|
||||
}
|
||||
checkedCount += 1;
|
||||
}
|
||||
record(`aircraft render altitudes are finite + physically plausible (-500..20000m)`,
|
||||
checkedCount > 0 ? plausibleCount === checkedCount : null,
|
||||
`${plausibleCount}/${checkedCount} plausible`);
|
||||
|
||||
if (baroPlusNDeltas.length) {
|
||||
console.log('\n baro+geoidN cross-check table (id, renderAlt, expected baro+N, |Δ|):');
|
||||
for (const d of baroPlusNDeltas) {
|
||||
console.log(` ${String(d.id).padEnd(10)} renderAlt=${d.renderAltM.toFixed(1)}m` +
|
||||
` expected=${d.expectedBaroPlusN.toFixed(1)}m Δ=${d.deltaVsBaroPlusN.toFixed(1)}m`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Under-terrain detector, tightened to its unambiguous fingerprint.
|
||||
// Ground truth is fetched fresh through the app's own
|
||||
// /api/terrain/heights proxy (same oracle as group 1), so this stays
|
||||
// GL-independent and upstream-verifiable rather than reaching into
|
||||
// private closures.
|
||||
//
|
||||
// The REAL bug (fixed by this task's Bug 1 change: flights.js's
|
||||
// _warmGroundedAircraftSurfaceCache now keys its warm-cache resolve off
|
||||
// the aircraft's RAW POLL lat/lon — the same coords
|
||||
// pickRenderAltitudeM's surfaceM read uses — instead of the
|
||||
// continuously dead-reckoned billboard position) has an unambiguous
|
||||
// numeric fingerprint: an aircraft rendering at the ~0m ellipsoidal
|
||||
// SENTINEL (the pre-existing "no geo/baro data yet" default) while its
|
||||
// terrain ground is nowhere near 0m. HARD-FAIL only on that exact
|
||||
// combination.
|
||||
//
|
||||
// Any OTHER under-terrain reading (non-zero renderAltitudeM below
|
||||
// ground - 50m) is ambiguous — it could legitimately be a climbing/
|
||||
// descending aircraft briefly near real ground elevation close to an
|
||||
// airport (e.g. a departure a few dozen metres up over a field that
|
||||
// itself sits well above sea level) — so those are recorded
|
||||
// INCONCLUSIVE with the raw values, not FAILed.
|
||||
const SENTINEL_EPS_M = 1; // Math.abs(renderAlt) < 1 is the exact 0m default
|
||||
const SENTINEL_GROUND_FLOOR_M = 100; // terrain must be unambiguously non-zero
|
||||
let underTerrainChecked = 0;
|
||||
let sentinelViolations = 0;
|
||||
let ambiguousUnderTerrain = 0;
|
||||
const ambiguousDetails = [];
|
||||
for (const gc of sample) {
|
||||
const ground = await fetchReearthEllipsoid(gc.latitude, gc.longitude);
|
||||
if (ground === null) continue; // upstream unreachable for this point — skip, don't fail
|
||||
underTerrainChecked += 1;
|
||||
if (gc.altitudeM >= ground - 50) continue; // not under terrain at all
|
||||
const isSentinelMiss = Math.abs(gc.altitudeM) < SENTINEL_EPS_M && Math.abs(ground) > SENTINEL_GROUND_FLOOR_M;
|
||||
if (isSentinelMiss) {
|
||||
sentinelViolations += 1;
|
||||
underTerrainDetails.push(`${gc.id}: renderAlt=${gc.altitudeM.toFixed(1)}m ground=${ground.toFixed(1)}m (0m SENTINEL fingerprint)`);
|
||||
} else {
|
||||
ambiguousUnderTerrain += 1;
|
||||
ambiguousDetails.push(`${gc.id}: renderAlt=${gc.altitudeM.toFixed(1)}m ground=${ground.toFixed(1)}m`);
|
||||
}
|
||||
}
|
||||
record('no aircraft renders at the 0m sentinel while terrain ground is far from 0m (datum-miss fingerprint)',
|
||||
underTerrainChecked > 0 ? sentinelViolations === 0 : null,
|
||||
underTerrainChecked > 0
|
||||
? `${sentinelViolations} violation(s) out of ${underTerrainChecked} checked${underTerrainDetails.length ? ': ' + underTerrainDetails.slice(0, 3).join(' | ') : ''}`
|
||||
: 'Re:Earth proxy unreachable for every sampled point this run — INCONCLUSIVE');
|
||||
if (ambiguousUnderTerrain > 0) {
|
||||
record('other under-terrain readings (non-zero, ambiguous — may be legit climb/descent near an airport)',
|
||||
null,
|
||||
`${ambiguousUnderTerrain} case(s): ${ambiguousDetails.slice(0, 5).join(' | ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Group 3: Regime C re-resolve
|
||||
// =========================================================================
|
||||
console.log('Switching map stack to keyless OSM (regime C)...');
|
||||
// Grab a London camera's ground BEFORE the switch (still whatever regime
|
||||
// we were in — Google-3D, given the app's default).
|
||||
const regimeCTargetId = londonCams[0]?.id || austinCams[0]?.id || allCameras[0]?.id;
|
||||
const groundBeforeSwitch = regimeCTargetId ? await readCameraGround(page, regimeCTargetId) : null;
|
||||
|
||||
await page.evaluate(async () => {
|
||||
await window.__godsEyeView.mapStackController.setStack('osm');
|
||||
});
|
||||
// Real (non-flat) terrain loads ASYNCHRONOUSLY on a globe stack, and by two
|
||||
// different code paths depending on whether a Cesium Ion token is present:
|
||||
// - NO token → keyless Re:Earth `CesiumTerrainProvider` via a DIRECT
|
||||
// assignment (synchronous — ready immediately).
|
||||
// - token → Cesium World Terrain via `scene.setTerrain(...)`, whose
|
||||
// provider resolves in the background: `viewer.terrainProvider`
|
||||
// is transiently `null` until the World Terrain layer.json
|
||||
// loads (can exceed 1.5 s under SwiftShader), THEN becomes a
|
||||
// `CesiumTerrainProvider`.
|
||||
// Either way the END STATE is a real `CesiumTerrainProvider`, never the flat
|
||||
// `EllipsoidTerrainProvider` regime C used to be stuck on. Poll for that end
|
||||
// state instead of a fixed sleep (a fixed sleep races the async World Terrain
|
||||
// load — the old failure mode). Report whatever it settled on.
|
||||
let terrainCtor = null;
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const p = window.__godsEyeView?.viewer?.terrainProvider;
|
||||
const name = p && p.constructor && p.constructor.name;
|
||||
return !!name && name !== 'EllipsoidTerrainProvider';
|
||||
}, { timeout: 12000, polling: 250 });
|
||||
} catch { /* timed out — fall through and report the settled ctor below */ }
|
||||
terrainCtor = await page.evaluate(() => {
|
||||
const provider = window.__godsEyeView.viewer.terrainProvider;
|
||||
return provider?.constructor?.name || null;
|
||||
});
|
||||
record('globe-stack terrain is a real CesiumTerrainProvider, not the flat EllipsoidTerrainProvider (regime B/C)',
|
||||
terrainCtor !== null && terrainCtor !== 'EllipsoidTerrainProvider',
|
||||
`constructor=${terrainCtor}`);
|
||||
|
||||
if (regimeCTargetId) {
|
||||
const groundAfterSwitch = await readCameraGround(page, regimeCTargetId);
|
||||
if (!groundBeforeSwitch || !groundAfterSwitch ||
|
||||
!Number.isFinite(groundBeforeSwitch.groundM) || !Number.isFinite(groundAfterSwitch.groundM)) {
|
||||
record(`CCTV grounds re-resolved for regime C (${regimeCTargetId})`, false, 'ground unreadable before or after switch');
|
||||
} else {
|
||||
// The prior-everywhere invariant (Task 5) means BOTH readings should
|
||||
// already be close to the Re:Earth ellipsoid value in every regime —
|
||||
// the real regression this guards is the OLD fabricated-prior bug
|
||||
// (London ~15m, Austin ~150m fixed catalog numbers regardless of
|
||||
// regime). Assert the post-switch ground is NOT stuck at either
|
||||
// known-bad fabricated constant, and is within a generous tolerance
|
||||
// of the pre-switch value (both should already reflect the same
|
||||
// Re:Earth prior — a big jump would indicate a stale/blank regime
|
||||
// record instead of a re-arm).
|
||||
const isLondonId = londonCams.some((c) => c.id === regimeCTargetId);
|
||||
const staleValue = isLondonId ? 15 : 150;
|
||||
const notStuckAtFabricated = Math.abs(groundAfterSwitch.groundM - staleValue) > 5;
|
||||
const stableAcrossSwitch = Math.abs(groundAfterSwitch.groundM - groundBeforeSwitch.groundM) < 20;
|
||||
record(`CCTV ground NOT stuck at the old fabricated prior (${staleValue}m) after regime C switch (${regimeCTargetId})`,
|
||||
notStuckAtFabricated,
|
||||
`before=${groundBeforeSwitch.groundM.toFixed(1)}m after=${groundAfterSwitch.groundM.toFixed(1)}m`);
|
||||
record(`CCTV ground re-resolved within one event cycle, consistent with the prior-everywhere invariant (${regimeCTargetId})`,
|
||||
stableAcrossSwitch,
|
||||
`Δ=${Math.abs(groundAfterSwitch.groundM - groundBeforeSwitch.groundM).toFixed(1)}m`);
|
||||
}
|
||||
} else {
|
||||
record('CCTV grounds re-resolved for regime C', null, 'no test camera available');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Screenshot for human visual review (not a pass/fail gate).
|
||||
// -----------------------------------------------------------------------
|
||||
await sleep(500);
|
||||
const shot = path.join(SHOTS_DIR, 'height-datum-regimeC.png');
|
||||
await page.screenshot({ path: shot });
|
||||
console.log(` screenshot (visual review only, not a gate) → ${path.relative(REPO_ROOT, shot)}`);
|
||||
|
||||
record('no console errors during height-datum exercise', consoleErrors.length === 0,
|
||||
consoleErrors.length ? consoleErrors.slice(0, 3).join(' | ') : 'clean');
|
||||
|
||||
for (const r of results) {
|
||||
if (r.ok === false) exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log('\n' + '─'.repeat(60));
|
||||
const pass = results.filter((r) => r.ok === true).length;
|
||||
const fail = results.filter((r) => r.ok === false).length;
|
||||
const inconclusive = results.filter((r) => r.ok === null).length;
|
||||
console.log(` RESULT: ${pass} passed, ${fail} failed, ${inconclusive} inconclusive`);
|
||||
console.log('─'.repeat(60) + '\n');
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('\x1b[31mHarness error:\x1b[0m', e);
|
||||
process.exit(3);
|
||||
});
|
||||
@@ -0,0 +1,422 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-label-readability.mjs — before/after evidence capture for ambient label
|
||||
* legibility over bright ground.
|
||||
*
|
||||
* Run:
|
||||
* node scripts/qa-label-readability.mjs --url http://localhost:4244 --tag after
|
||||
*
|
||||
* Every scene pins its own camera pose AND substitutes a deterministic
|
||||
* synthetic contact field for the live pollers, so a `before` run and an
|
||||
* `after` run produce pixel-comparable frames: same contacts, same callsigns,
|
||||
* same placements, same imagery. The only difference between the two tags is
|
||||
* the code under test.
|
||||
*
|
||||
* Scenes cover the cases the readability spec calls out:
|
||||
* bright-* — sunlit aerial imagery, one shot per visual style
|
||||
* dark-* — dark water/terrain, where a plate must NOT turn into a box
|
||||
* sats-* — space-tier contacts over the lit Earth disc
|
||||
* horizon-* — cockpit pose at the horizon, contacts against SKY, where the
|
||||
* plate must feather back toward the bare-text look
|
||||
* band-* — the horizon crossing the middle of the label field, so the
|
||||
* feather can be judged for smoothness rather than popping
|
||||
*
|
||||
* Each shot also reports a luminance histogram of the label band, so the
|
||||
* "did the plate actually darken anything" question has a number behind it and
|
||||
* not only an eyeball.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, fallback) => {
|
||||
const index = argv.indexOf(name);
|
||||
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
||||
};
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4244');
|
||||
const TAG = getOpt('--tag', 'after');
|
||||
/** Comma-separated scene-id prefixes. Empty runs the whole set. */
|
||||
const SCENE_FILTER = getOpt('--scenes', '')
|
||||
.split(',')
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean);
|
||||
const SHOT_DIR = path.resolve(getOpt('--out', 'qa-shots/labels'));
|
||||
const SETTLE_MAX_MS = 40_000;
|
||||
const SETTLE_MIN_MS = 4_000;
|
||||
|
||||
/**
|
||||
* Scenes are (camera pose × visual style). `field` describes the synthetic
|
||||
* contact cloud dropped around the camera target.
|
||||
*/
|
||||
/** Sunlit airport apron — the brightest ground the product routinely flies over. */
|
||||
const APRON = { lon: -97.6664, lat: 30.1975, height: 1100, heading: 0.35, pitch: -0.85 };
|
||||
|
||||
/**
|
||||
* Cockpit pose: cruise altitude, level, looking out at the horizon. From 10 km
|
||||
* the ellipsoid silhouette sits ~3.2° below the local horizontal, so a level
|
||||
* camera puts the horizon line just under frame centre and everything the
|
||||
* `horizon-*` field lands on is open sky.
|
||||
*/
|
||||
const COCKPIT = { lon: -98.35, lat: 30.05, height: 10_000, heading: 1.42, pitch: 0 };
|
||||
|
||||
/** Contacts biased into the upper frame: every one of them backs onto sky. */
|
||||
const SKY_FIELD = {
|
||||
kind: 'air', count: 30, near: 40_000, far: 120_000, spanY: 0.9, biasY: 0.3,
|
||||
};
|
||||
|
||||
const SCENES = [
|
||||
{
|
||||
id: 'bright-normal',
|
||||
style: 'normal',
|
||||
note: 'sunlit apron concrete — the failure case from the field report',
|
||||
camera: APRON,
|
||||
field: { kind: 'air', count: 30, near: 700, far: 2600 },
|
||||
},
|
||||
{
|
||||
id: 'bright-retro',
|
||||
style: 'retro',
|
||||
note: 'CRT amber over the same sunlit ground',
|
||||
camera: APRON,
|
||||
field: { kind: 'air', count: 30, near: 700, far: 2600 },
|
||||
},
|
||||
{
|
||||
id: 'bright-surveillance',
|
||||
style: 'surveillance',
|
||||
note: 'NVG green over the same sunlit ground',
|
||||
camera: APRON,
|
||||
field: { kind: 'air', count: 30, near: 700, far: 2600 },
|
||||
},
|
||||
{
|
||||
id: 'bright-thermal',
|
||||
style: 'thermal',
|
||||
note: 'FLIR over the same sunlit ground',
|
||||
camera: APRON,
|
||||
field: { kind: 'air', count: 30, near: 700, far: 2600 },
|
||||
},
|
||||
{
|
||||
id: 'dark-normal',
|
||||
style: 'normal',
|
||||
note: 'dark water — a plate here must stay a whisper, not a box',
|
||||
camera: { lon: 139.05, lat: 34.45, height: 9000, heading: 0.2, pitch: -0.62 },
|
||||
field: { kind: 'air', count: 30, near: 6000, far: 26_000 },
|
||||
},
|
||||
{
|
||||
id: 'sats-normal',
|
||||
style: 'normal',
|
||||
note: 'space tier over the lit Earth disc',
|
||||
camera: { lon: -60, lat: 20, height: 21_000_000, heading: 0, pitch: -Math.PI / 2 },
|
||||
field: { kind: 'sat', count: 30, near: 14_000_000, far: 20_000_000 },
|
||||
},
|
||||
{
|
||||
id: 'horizon-normal',
|
||||
style: 'normal',
|
||||
note: 'cockpit at the horizon — the sky backdrop the plate must feather off',
|
||||
camera: COCKPIT,
|
||||
field: SKY_FIELD,
|
||||
},
|
||||
{
|
||||
id: 'horizon-retro',
|
||||
style: 'retro',
|
||||
note: 'CRT amber against the same sky',
|
||||
camera: COCKPIT,
|
||||
field: SKY_FIELD,
|
||||
},
|
||||
{
|
||||
id: 'horizon-surveillance',
|
||||
style: 'surveillance',
|
||||
note: 'NVG green against the same sky',
|
||||
camera: COCKPIT,
|
||||
field: SKY_FIELD,
|
||||
},
|
||||
{
|
||||
id: 'horizon-thermal',
|
||||
style: 'thermal',
|
||||
note: 'FLIR against the same sky',
|
||||
camera: COCKPIT,
|
||||
field: SKY_FIELD,
|
||||
},
|
||||
{
|
||||
id: 'band-normal',
|
||||
style: 'normal',
|
||||
note: 'horizon crossing the label field — feather smoothness, both sides in one frame',
|
||||
camera: COCKPIT,
|
||||
// Shallower depths keep the low contacts above ground rather than inside it,
|
||||
// so the frame carries genuine terrain-backed AND sky-backed labels.
|
||||
field: {
|
||||
kind: 'air', count: 30, near: 15_000, far: 60_000, spanY: 1.0, biasY: -0.1,
|
||||
},
|
||||
// The band scene's contacts sit lower in the frame than the default crop.
|
||||
crop: { x: 0.16, y: 0.28, w: 0.52, h: 0.54 },
|
||||
},
|
||||
];
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Pin puppeteer's Chrome-for-Testing: system Chrome auto-updates underneath
|
||||
// the harnesses and its software-GL behaviour shifts across majors.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
].filter(Boolean);
|
||||
|
||||
function findChrome() {
|
||||
for (const candidate of CHROME_CANDIDATES) {
|
||||
try { if (fs.existsSync(candidate)) return candidate; } catch { /* fall through */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Decode a base64 PNG inside the page and return luminance statistics. */
|
||||
async function measureShot(page, base64) {
|
||||
return page.evaluate(async (b64) => {
|
||||
const image = new Image();
|
||||
await new Promise((resolve, reject) => {
|
||||
image.onload = resolve;
|
||||
image.onerror = reject;
|
||||
image.src = `data:image/png;base64,${b64}`;
|
||||
});
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(image, 0, 0);
|
||||
// Sample the central band only: the vignette and the fixed chrome rails
|
||||
// would otherwise dominate a whole-frame histogram.
|
||||
const x0 = Math.round(image.width * 0.2);
|
||||
const x1 = Math.round(image.width * 0.8);
|
||||
const y0 = Math.round(image.height * 0.15);
|
||||
const y1 = Math.round(image.height * 0.8);
|
||||
const data = ctx.getImageData(x0, y0, x1 - x0, y1 - y0).data;
|
||||
const values = [];
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
values.push(data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114);
|
||||
}
|
||||
values.sort((a, b) => a - b);
|
||||
const at = (q) => Math.round(values[Math.min(values.length - 1, Math.floor(values.length * q))]);
|
||||
return { p05: at(0.05), p25: at(0.25), p50: at(0.5), p95: at(0.95), samples: values.length };
|
||||
}, base64);
|
||||
}
|
||||
|
||||
/** Wait until consecutive frames stop changing, so tiles are fully resolved. */
|
||||
async function settle(page) {
|
||||
const started = Date.now();
|
||||
let previous = null;
|
||||
while (Date.now() - started < SETTLE_MAX_MS) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const state = await page.evaluate(() => {
|
||||
const { viewer, tileset } = window.__godsEyeView;
|
||||
return {
|
||||
tilesLoaded: tileset?.tilesLoaded !== false,
|
||||
pending: viewer.scene.globe?._surface?._tileLoadQueueHigh?.length ?? 0,
|
||||
};
|
||||
});
|
||||
const settled = state.tilesLoaded && state.pending === 0;
|
||||
if (settled && previous && Date.now() - started > SETTLE_MIN_MS) return true;
|
||||
previous = settled;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\nLabel readability evidence');
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Tag : ${TAG}`);
|
||||
console.log(` Output : ${SHOT_DIR}\n`);
|
||||
|
||||
try {
|
||||
const response = await fetch(APP_URL);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
console.error(`Dev server not reachable at ${APP_URL}: ${error.message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
||||
const executablePath = findChrome();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox',
|
||||
'--use-gl=angle', '--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage', '--disable-web-security',
|
||||
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
],
|
||||
});
|
||||
|
||||
const results = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 1 });
|
||||
page.on('pageerror', (error) => console.warn(` ! pageerror: ${error.message}`));
|
||||
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.styleManager,
|
||||
{ timeout: 60_000, polling: 100 },
|
||||
);
|
||||
// flyToAustin arrives ~500 ms after init; let it start and land before any
|
||||
// scene sets its own pose, or the arrival overwrites the first camera.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2500));
|
||||
|
||||
const selected = SCENE_FILTER.length
|
||||
? SCENES.filter((scene) => SCENE_FILTER.some((prefix) => scene.id.startsWith(prefix)))
|
||||
: SCENES;
|
||||
if (selected.length === 0) throw new Error(`--scenes matched nothing: ${SCENE_FILTER}`);
|
||||
|
||||
for (const scene of selected) {
|
||||
process.stdout.write(` ${scene.id} … `);
|
||||
await page.evaluate((spec) => {
|
||||
const { viewer, dataManager, styleManager } = window.__godsEyeView;
|
||||
viewer.camera.cancelFlight();
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
|
||||
viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(
|
||||
spec.camera.lon, spec.camera.lat, spec.camera.height,
|
||||
),
|
||||
orientation: {
|
||||
heading: spec.camera.heading,
|
||||
pitch: spec.camera.pitch,
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Contacts are placed by SCREEN position rather than by geography: a
|
||||
// pitched camera looks well ahead of its own ground point, so a cloud
|
||||
// centred on the camera coordinate mostly lands behind the frustum and
|
||||
// the frame comes back with two labels instead of a field. Building
|
||||
// straight off the camera basis guarantees every contact lands inside
|
||||
// the keyhole, at a known depth, in both the before and after runs.
|
||||
const camera = viewer.camera;
|
||||
const frustum = camera.frustum;
|
||||
const halfHeight = Math.tan((frustum.fovy ?? frustum.fov ?? 1.0) / 2);
|
||||
const halfWidth = halfHeight * (frustum.aspectRatio || 1.6);
|
||||
const basis = {
|
||||
px: camera.position.x, py: camera.position.y, pz: camera.position.z,
|
||||
dx: camera.direction.x, dy: camera.direction.y, dz: camera.direction.z,
|
||||
ux: camera.up.x, uy: camera.up.y, uz: camera.up.z,
|
||||
rx: camera.right.x, ry: camera.right.y, rz: camera.right.z,
|
||||
};
|
||||
// A 6x5 jittered lattice over the central 76% of the frame. Deterministic
|
||||
// by construction — no RNG anywhere in the harness.
|
||||
const pointAt = (ndcX, ndcY, distance) => {
|
||||
const sx = ndcX * halfWidth * distance;
|
||||
const sy = ndcY * halfHeight * distance;
|
||||
return new Cartesian3(
|
||||
basis.px + basis.dx * distance + basis.rx * sx + basis.ux * sy,
|
||||
basis.py + basis.dy * distance + basis.ry * sx + basis.uy * sy,
|
||||
basis.pz + basis.dz * distance + basis.rz * sx + basis.uz * sy,
|
||||
);
|
||||
};
|
||||
|
||||
const build = (prefix) => {
|
||||
const rows = [];
|
||||
const cols = 6;
|
||||
const isSat = spec.field.kind === 'sat';
|
||||
for (let i = 0; i < spec.field.count; i++) {
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
const jitter = ((i * 37) % 11) / 11 - 0.5;
|
||||
// spanY/biasY let a scene aim the lattice at sky, at ground, or
|
||||
// straight across the horizon line without moving the camera.
|
||||
const spanX = spec.field.spanX ?? 1.52;
|
||||
const spanY = spec.field.spanY ?? 1.34;
|
||||
const biasY = spec.field.biasY ?? 0;
|
||||
const ndcX = ((col + 0.5) / cols - 0.5) * spanX + jitter * 0.06;
|
||||
const ndcY = ((row + 0.5) / Math.ceil(spec.field.count / cols) - 0.5)
|
||||
* spanY + biasY + jitter * 0.05;
|
||||
const depth = spec.field.near
|
||||
+ (spec.field.far - spec.field.near) * (((i * 53) % 17) / 17);
|
||||
const flightLevel = 20 + ((i * 29) % 380);
|
||||
rows.push({
|
||||
sourceId: `${prefix}-${String(i).padStart(3, '0')}`,
|
||||
id: isSat ? `SAT-${String(1000 + i)}` : `JA${String(20000 + i * 7)}`,
|
||||
metric: isSat ? 'LEO' : `FL${String(flightLevel).padStart(3, '0')}`,
|
||||
position: pointAt(ndcX, ndcY, depth),
|
||||
type: isSat ? 'SAT' : 'AIR',
|
||||
tier: isSat ? 'space' : (i % 6 === 0 ? 'military' : 'civil'),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
const assignment = spec.field.kind === 'sat'
|
||||
? { satellites: build('s'), flights: [], military: [] }
|
||||
: { flights: build('f'), military: [], satellites: [] };
|
||||
window.__LABEL_EVIDENCE = assignment;
|
||||
for (const layerId of Object.keys(assignment)) {
|
||||
const entry = dataManager.layers.get(layerId);
|
||||
if (entry?.module) {
|
||||
entry.module.getDetectableObjects = () => window.__LABEL_EVIDENCE[layerId];
|
||||
}
|
||||
}
|
||||
|
||||
styleManager.setStyle(spec.style, { applyPreset: false });
|
||||
// Set detection AFTER the style: military presets auto-arm Panoptic at
|
||||
// 0% density, which would otherwise leave the frame without callouts.
|
||||
styleManager.setDetection({
|
||||
enabled: true,
|
||||
densityPct: 100,
|
||||
allocationStrategy: 'elastic',
|
||||
});
|
||||
}, scene);
|
||||
|
||||
const converged = await settle(page);
|
||||
const base64 = await page.screenshot({ encoding: 'base64' });
|
||||
fs.writeFileSync(
|
||||
path.join(SHOT_DIR, `${TAG}-${scene.id}.png`),
|
||||
Buffer.from(base64, 'base64'),
|
||||
);
|
||||
// A 2x blow-up of the label field. Ambient callouts are 10px mono, and
|
||||
// the taste call this evidence exists for cannot be made at 1:1.
|
||||
const crop = await page.evaluate(async ({ b64, box }) => {
|
||||
const image = new Image();
|
||||
await new Promise((resolve, reject) => {
|
||||
image.onload = resolve;
|
||||
image.onerror = reject;
|
||||
image.src = `data:image/png;base64,${b64}`;
|
||||
});
|
||||
const sx = Math.round(image.width * box.x);
|
||||
const sy = Math.round(image.height * box.y);
|
||||
const sw = Math.round(image.width * box.w);
|
||||
const sh = Math.round(image.height * box.h);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = sw * 2;
|
||||
canvas.height = sh * 2;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(image, sx, sy, sw, sh, 0, 0, sw * 2, sh * 2);
|
||||
return canvas.toDataURL('image/png').split(',')[1];
|
||||
}, { b64: base64, box: scene.crop ?? { x: 0.16, y: 0.14, w: 0.52, h: 0.5 } });
|
||||
fs.writeFileSync(
|
||||
path.join(SHOT_DIR, `${TAG}-${scene.id}-crop.png`),
|
||||
Buffer.from(crop, 'base64'),
|
||||
);
|
||||
const stats = await measureShot(page, base64);
|
||||
const diagnostics = await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.getDetectionState?.() || {},
|
||||
);
|
||||
results.push({ scene: scene.id, converged, ...stats });
|
||||
console.log(
|
||||
`p05=${stats.p05} p25=${stats.p25} p50=${stats.p50} p95=${stats.p95}`
|
||||
+ `${converged ? '' : ' (tiles did not fully settle)'}`
|
||||
+ ` [${diagnostics.detectionMode || '?'}]`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const summary = path.join(SHOT_DIR, `${TAG}-summary.json`);
|
||||
fs.writeFileSync(summary, `${JSON.stringify(results, null, 2)}\n`);
|
||||
console.log(`\n ${results.length} shots → ${SHOT_DIR}`);
|
||||
console.log(` summary → ${summary}\n`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,600 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Deterministic QA for the detection overlay's bounded two-lane label pipeline.
|
||||
*
|
||||
* Run:
|
||||
* node scripts/qa-labels.mjs --url http://localhost:4173
|
||||
*
|
||||
* The harness replaces only the three detection read APIs in the loaded page.
|
||||
* It does not enable their network pollers or mutate repository data. The field
|
||||
* contains 12,000 stable observations and is sampled while the global camera
|
||||
* makes a small orbit.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getFlag = (name) => argv.includes(name);
|
||||
const getOpt = (name, fallback) => {
|
||||
const index = argv.indexOf(name);
|
||||
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
||||
};
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const APP_ORIGIN = new URL(APP_URL).origin;
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const SHOT_DIR = path.resolve('qa-shots/labels');
|
||||
const STORAGE_KEY = 'gev:detection-allocation:v1';
|
||||
const OBSERVATION_COUNT = 12000;
|
||||
const SYNTHETIC_SAMPLE_MS = 10000;
|
||||
const NORMAL_SAMPLE_MS = 6000;
|
||||
const SAMPLE_TIMEOUT_MS = 120000;
|
||||
const MIN_SYNTHETIC_SOLVES = 20;
|
||||
const MIN_SYNTHETIC_SPAN_MS = SYNTHETIC_SAMPLE_MS;
|
||||
const MIN_NORMAL_FRAMES = 20;
|
||||
const SETTLED_SOLVE_COUNT = 3;
|
||||
const GLOBAL_CAMERA_MIN_HEIGHT_M = 20_000_000;
|
||||
const GLOBAL_CAMERA_MAX_HEIGHT_M = 30_000_000;
|
||||
const FIELD_COUNTS = Object.freeze({
|
||||
flights: 7200,
|
||||
military: 1000,
|
||||
satellites: 3800,
|
||||
});
|
||||
const NORMAL_COUNTS = Object.freeze({
|
||||
flights: 4300,
|
||||
military: 70,
|
||||
satellites: 830,
|
||||
});
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
|
||||
try {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
} catch {
|
||||
// Let Puppeteer use its bundled browser.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
function percentile(values, pct) {
|
||||
const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);
|
||||
if (sorted.length === 0) return Number.NaN;
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * pct) - 1)];
|
||||
}
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function analyzeMembership(samples) {
|
||||
let replacements = 0;
|
||||
let exposure = 0;
|
||||
const transitions = new Map();
|
||||
|
||||
for (let index = 0; index < samples.length; index++) {
|
||||
const sample = samples[index];
|
||||
const current = new Set(sample.labeledKeys);
|
||||
if (index > 0) {
|
||||
const previous = new Set(samples[index - 1].labeledKeys);
|
||||
const removed = [...previous].filter((key) => !current.has(key)).length;
|
||||
const added = [...current].filter((key) => !previous.has(key)).length;
|
||||
replacements += Math.max(removed, added);
|
||||
const elapsedSec = Math.max(0.001, (sample.t - samples[index - 1].t) / 1000);
|
||||
exposure += Math.max(1, (previous.size + current.size) / 2) * elapsedSec;
|
||||
}
|
||||
for (const key of current) {
|
||||
if (!transitions.has(key)) transitions.set(key, []);
|
||||
transitions.get(key).push(sample.t);
|
||||
}
|
||||
}
|
||||
|
||||
let showHideShow = 0;
|
||||
for (const times of transitions.values()) {
|
||||
for (let index = 1; index < times.length; index++) {
|
||||
const previousIndex = samples.findIndex((sample) => sample.t === times[index - 1]);
|
||||
const nextIndex = samples.findIndex((sample) => sample.t === times[index]);
|
||||
if (nextIndex - previousIndex > 1 && times[index] - times[index - 1] <= 2000) {
|
||||
showHideShow++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const churnPctPerSec = exposure > 0
|
||||
? replacements / exposure * 100
|
||||
: 0;
|
||||
return {
|
||||
churnPctPerSec,
|
||||
showHideShow,
|
||||
replacements,
|
||||
exposure,
|
||||
transitions: Math.max(0, samples.length - 1),
|
||||
minimumPopulation: samples.length
|
||||
? Math.min(...samples.map((sample) => sample.labeledKeys.length))
|
||||
: 0,
|
||||
maximumPopulation: samples.length
|
||||
? Math.max(...samples.map((sample) => sample.labeledKeys.length))
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForConclusiveSamples(page, {
|
||||
minimumElapsedMs,
|
||||
minimumSolves = 0,
|
||||
minimumSolveSpanMs = 0,
|
||||
minimumNormalFrames = 0,
|
||||
}) {
|
||||
const deadline = Date.now() + SAMPLE_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const state = await page.evaluate(() => {
|
||||
const qa = window.__LABEL_QA;
|
||||
const solves = qa?.solve || [];
|
||||
const matchingFrames = (qa?.frames || []).filter(
|
||||
(sample) => sample.observationCount === 5200,
|
||||
);
|
||||
return {
|
||||
elapsedMs: performance.now() - (qa?.startedAt || performance.now()),
|
||||
solveCount: solves.length,
|
||||
solveSpanMs: solves.length > 1 ? solves.at(-1).t - solves[0].t : 0,
|
||||
normalFrameCount: matchingFrames.length,
|
||||
};
|
||||
});
|
||||
if (state.elapsedMs >= minimumElapsedMs
|
||||
&& state.solveCount >= minimumSolves
|
||||
&& state.solveSpanMs >= minimumSolveSpanMs
|
||||
&& state.normalFrameCount >= minimumNormalFrames) return state;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function waitForSettledSyntheticField(page) {
|
||||
await page.waitForFunction(
|
||||
({ observationCount, settledSolveCount, minHeight, maxHeight }) => {
|
||||
const solves = window.__LABEL_QA?.solve || [];
|
||||
if (solves.length < settledSolveCount) return false;
|
||||
return solves.slice(-settledSolveCount).every((sample) => (
|
||||
sample.observationCount === observationCount
|
||||
&& sample.labeledKeys.length > 0
|
||||
&& sample.selectedCount === sample.labeledKeys.length
|
||||
&& sample.cameraHeight >= minHeight
|
||||
&& sample.cameraHeight <= maxHeight
|
||||
));
|
||||
},
|
||||
{ timeout: SAMPLE_TIMEOUT_MS, polling: 100 },
|
||||
{
|
||||
observationCount: OBSERVATION_COUNT,
|
||||
settledSolveCount: SETTLED_SOLVE_COUNT,
|
||||
minHeight: GLOBAL_CAMERA_MIN_HEIGHT_M,
|
||||
maxHeight: GLOBAL_CAMERA_MAX_HEIGHT_M,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\nDetection Label QA');
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Observations : ${OBSERVATION_COUNT}`);
|
||||
console.log(` Sample window: >=${SYNTHETIC_SAMPLE_MS / 1000}s synthetic + >=${NORMAL_SAMPLE_MS / 1000}s normal\n`);
|
||||
|
||||
try {
|
||||
const response = await fetch(APP_URL);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
console.error(`Dev server not reachable at ${APP_URL}: ${error.message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
||||
const executablePath = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
],
|
||||
});
|
||||
|
||||
const consoleErrors = [];
|
||||
const failedResponses = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 1 });
|
||||
page.on('console', (message) => {
|
||||
if (message.type() !== 'error') return;
|
||||
const text = message.text();
|
||||
const sourceUrl = message.location()?.url || '';
|
||||
if (!/Failed to load resource.*404/i.test(text)) {
|
||||
consoleErrors.push(sourceUrl ? `${text} [${sourceUrl}]` : text);
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => consoleErrors.push(`pageerror: ${error.message}`));
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 500) {
|
||||
failedResponses.push(`HTTP ${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
|
||||
await page.evaluateOnNewDocument((storageKey, appOrigin) => {
|
||||
localStorage.setItem(storageKey, 'WEIGHTED');
|
||||
const realFetch = window.fetch.bind(window);
|
||||
window.fetch = (input, init) => {
|
||||
const requestUrl = typeof input === 'string' || input instanceof URL
|
||||
? String(input)
|
||||
: input?.url;
|
||||
const url = new URL(requestUrl, window.location.href);
|
||||
if (url.origin === appOrigin && url.pathname === '/api/openai/hud-summary') {
|
||||
return Promise.resolve(new Response(JSON.stringify({ summary: 'QA globe ready' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}));
|
||||
}
|
||||
return realFetch(input, init);
|
||||
};
|
||||
}, STORAGE_KEY, APP_ORIGIN);
|
||||
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.styleManager,
|
||||
{ timeout: 60000, polling: 100 },
|
||||
);
|
||||
|
||||
// flyToAustin schedules its 600 m arrival 500 ms after initialization.
|
||||
// Let that callback start, then cancel it before establishing the global
|
||||
// measurement camera so startup motion cannot invalidate the sample.
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
await page.evaluate(() => window.__godsEyeView.viewer.camera.cancelFlight());
|
||||
|
||||
const stateChecks = await page.evaluate((storageKey) => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const restored = manager.getDetectionState();
|
||||
manager._syncDetectionUiFromEngine();
|
||||
const storedAfterPassiveSync = localStorage.getItem(storageKey);
|
||||
const off = manager.setDetection({ enabled: false, densityPct: 25 });
|
||||
const restoredFromOff = manager.setDetection({ enabled: true });
|
||||
return { restored, storedAfterPassiveSync, off, restoredFromOff };
|
||||
}, STORAGE_KEY);
|
||||
|
||||
record(
|
||||
'allocation preference survives passive UI sync',
|
||||
stateChecks.storedAfterPassiveSync === 'WEIGHTED',
|
||||
`share/engine=${stateChecks.restored.allocationStrategy}, stored=${stateChecks.storedAfterPassiveSync}`,
|
||||
);
|
||||
record(
|
||||
'density update while OFF restores the matching profile',
|
||||
stateChecks.off.detectionMode === 'OFF'
|
||||
&& stateChecks.off.densityPct === 25
|
||||
&& stateChecks.restoredFromOff.detectionMode === 'SPARSE'
|
||||
&& stateChecks.restoredFromOff.densityPct === 25,
|
||||
`OFF=${stateChecks.off.densityPct}%, restore=${stateChecks.restoredFromOff.detectionMode}/${stateChecks.restoredFromOff.densityPct}%`,
|
||||
);
|
||||
|
||||
const injected = await page.evaluate(({ fieldCounts, normalCounts }) => {
|
||||
const { viewer, dataManager, styleManager } = window.__godsEyeView;
|
||||
viewer.camera.cancelFlight();
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
const field = { flights: [], military: [], satellites: [] };
|
||||
const layerIds = Object.keys(field);
|
||||
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
|
||||
|
||||
for (const layerId of layerIds) {
|
||||
const count = fieldCounts[layerId];
|
||||
for (let localIndex = 0; localIndex < count; localIndex++) {
|
||||
const radius = 57 * Math.sqrt((localIndex + 0.5) / count);
|
||||
const angle = localIndex * goldenAngle + layerIds.indexOf(layerId) * 0.7;
|
||||
const lat = Math.max(-65, Math.min(75, 30 + radius * Math.sin(angle)));
|
||||
const lon = -97 + radius * Math.cos(angle) / Math.max(0.4, Math.cos(lat * Math.PI / 180));
|
||||
const sourceId = `${layerId.slice(0, 1)}-${String(localIndex).padStart(4, '0')}`;
|
||||
field[layerId].push({
|
||||
sourceId,
|
||||
id: sourceId.toUpperCase(),
|
||||
metric: layerId === 'satellites' ? 'LEO' : `${250 + (localIndex % 450)}KT`,
|
||||
position: Cartesian3.fromDegrees(lon, lat, layerId === 'satellites' ? 550000 : 9000),
|
||||
type: layerId === 'satellites' ? 'SAT' : 'AIR',
|
||||
tier: layerId === 'military' ? 'military' : (layerId === 'satellites' ? 'space' : 'civil'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.__LABEL_QA_FIELD = field;
|
||||
window.__LABEL_QA_NORMAL_COUNTS = normalCounts;
|
||||
for (const layerId of layerIds) {
|
||||
const entry = dataManager.layers.get(layerId);
|
||||
entry.module.getDetectableObjects = () => window.__LABEL_QA_FIELD[layerId];
|
||||
}
|
||||
|
||||
viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(-97, 30, 25000000),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
styleManager.setDetection({
|
||||
enabled: true,
|
||||
densityPct: 100,
|
||||
allocationStrategy: 'elastic',
|
||||
});
|
||||
|
||||
window.__LABEL_QA = {
|
||||
solve: [],
|
||||
frames: [],
|
||||
lastRevision: -1,
|
||||
lastFrameNumber: -1,
|
||||
startedAt: performance.now(),
|
||||
};
|
||||
let lastOrbitAt = performance.now();
|
||||
window.__LABEL_QA_ORBIT = setInterval(() => {
|
||||
const now = performance.now();
|
||||
viewer.camera.rotateRight((now - lastOrbitAt) * 0.0000012);
|
||||
lastOrbitAt = now;
|
||||
viewer.scene.requestRender();
|
||||
}, 100);
|
||||
const sample = () => {
|
||||
const diagnostics = styleManager.getDetectionDiagnostics();
|
||||
const frameNumber = viewer.scene.frameState?.frameNumber ?? -1;
|
||||
if (diagnostics && frameNumber !== window.__LABEL_QA.lastFrameNumber) {
|
||||
window.__LABEL_QA.lastFrameNumber = frameNumber;
|
||||
window.__LABEL_QA.frames.push({
|
||||
didSolve: diagnostics.didSolve,
|
||||
placementBuildCount: diagnostics.placementBuildCount,
|
||||
selectedCount: diagnostics.selectedCount,
|
||||
fadingCount: diagnostics.fadingCount,
|
||||
cohortCount: diagnostics.cohortCount,
|
||||
observationCount: diagnostics.observationCount,
|
||||
frameTotalMs: diagnostics.frameTotalMs,
|
||||
paintMs: diagnostics.paintMs,
|
||||
throttleSkipCount: diagnostics.throttleSkipCount,
|
||||
});
|
||||
}
|
||||
const dataset = document.querySelector('#world-overlay-canvas')?.dataset;
|
||||
const revision = Number(dataset?.solveRevision || 0);
|
||||
if (dataset && revision > 0 && revision !== window.__LABEL_QA.lastRevision) {
|
||||
window.__LABEL_QA.lastRevision = revision;
|
||||
window.__LABEL_QA.solve.push({
|
||||
t: performance.now(),
|
||||
revision,
|
||||
solveMs: Number(dataset.solveMs),
|
||||
frameTotalMs: Number(dataset.frameTotalMs),
|
||||
paintMs: Number(dataset.paintMs),
|
||||
throttleSkipCount: Number(dataset.throttleSkipCount),
|
||||
placementBuildCount: Number(dataset.placementBuildCount),
|
||||
observationCount: Number(dataset.observationCount),
|
||||
selectedCount: Number(dataset.selectedCount),
|
||||
fadingCount: Number(dataset.fadingCount),
|
||||
cameraHeight: viewer.camera.positionCartographic.height,
|
||||
demandByLayer: JSON.parse(dataset.demandByLayer || '{}'),
|
||||
cohortByLayer: JSON.parse(dataset.cohortByLayer || '{}'),
|
||||
entitlementByLayer: JSON.parse(dataset.entitlementByLayer || '{}'),
|
||||
labeledKeys: JSON.parse(dataset.labeledKeys || '[]'),
|
||||
});
|
||||
}
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
return Object.fromEntries(layerIds.map((layerId) => [layerId, field[layerId].length]));
|
||||
}, { fieldCounts: FIELD_COUNTS, normalCounts: NORMAL_COUNTS });
|
||||
|
||||
record(
|
||||
'deterministic field contains exactly 12,000 observations',
|
||||
Object.values(injected).reduce((sum, count) => sum + count, 0) === OBSERVATION_COUNT,
|
||||
JSON.stringify(injected),
|
||||
);
|
||||
|
||||
// Warm-up before steady-state p95: the first solves after field
|
||||
// injection run on cold-tier code paths and skew p95 on a fresh server.
|
||||
// (perf wave 2 added a 2.5 s sleep for this; main's settle+conclusive
|
||||
// -samples gates supersede it — same fix, deterministic instead of timed.)
|
||||
await waitForSettledSyntheticField(page);
|
||||
await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const revision = Number(document.querySelector('#world-overlay-canvas')?.dataset?.solveRevision || 0);
|
||||
window.__LABEL_QA.solve = [];
|
||||
window.__LABEL_QA.frames = [];
|
||||
window.__LABEL_QA.lastRevision = revision;
|
||||
window.__LABEL_QA.lastFrameNumber = viewer.scene.frameState?.frameNumber ?? -1;
|
||||
window.__LABEL_QA.startedAt = performance.now();
|
||||
});
|
||||
const syntheticSampling = await waitForConclusiveSamples(page, {
|
||||
minimumElapsedMs: SYNTHETIC_SAMPLE_MS,
|
||||
minimumSolves: MIN_SYNTHETIC_SOLVES,
|
||||
minimumSolveSpanMs: MIN_SYNTHETIC_SPAN_MS,
|
||||
});
|
||||
|
||||
await page.screenshot({
|
||||
path: path.join(SHOT_DIR, 'deterministic-12000.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
|
||||
const syntheticSamples = await page.evaluate(() => {
|
||||
const completed = window.__LABEL_QA;
|
||||
const counts = window.__LABEL_QA_NORMAL_COUNTS;
|
||||
for (const layerId of Object.keys(window.__LABEL_QA_FIELD)) {
|
||||
window.__LABEL_QA_FIELD[layerId] = window.__LABEL_QA_FIELD[layerId].slice(0, counts[layerId]);
|
||||
}
|
||||
window.__LABEL_QA = {
|
||||
solve: [],
|
||||
frames: [],
|
||||
lastRevision: -1,
|
||||
lastFrameNumber: -1,
|
||||
startedAt: performance.now(),
|
||||
};
|
||||
return completed;
|
||||
});
|
||||
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.styleManager.getDetectionDiagnostics()?.observationCount === 5200,
|
||||
{ timeout: SAMPLE_TIMEOUT_MS, polling: 100 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const revision = Number(document.querySelector('#world-overlay-canvas')?.dataset?.solveRevision || 0);
|
||||
window.__LABEL_QA.solve = [];
|
||||
window.__LABEL_QA.frames = [];
|
||||
window.__LABEL_QA.lastRevision = revision;
|
||||
window.__LABEL_QA.lastFrameNumber = viewer.scene.frameState?.frameNumber ?? -1;
|
||||
window.__LABEL_QA.startedAt = performance.now();
|
||||
});
|
||||
const normalSampling = await waitForConclusiveSamples(page, {
|
||||
minimumElapsedMs: NORMAL_SAMPLE_MS,
|
||||
minimumNormalFrames: MIN_NORMAL_FRAMES,
|
||||
});
|
||||
const normalSamples = await page.evaluate(() => {
|
||||
clearInterval(window.__LABEL_QA_ORBIT);
|
||||
return window.__LABEL_QA;
|
||||
});
|
||||
|
||||
await page.screenshot({
|
||||
path: path.join(SHOT_DIR, 'normal-global-5200.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
|
||||
const solveSamples = syntheticSamples.solve;
|
||||
const steadySolveSamples = solveSamples.filter((sample) => (
|
||||
sample.observationCount === OBSERVATION_COUNT
|
||||
&& sample.labeledKeys.length > 0
|
||||
&& sample.selectedCount === sample.labeledKeys.length
|
||||
&& sample.cameraHeight >= GLOBAL_CAMERA_MIN_HEIGHT_M
|
||||
&& sample.cameraHeight <= GLOBAL_CAMERA_MAX_HEIGHT_M
|
||||
));
|
||||
const normalFieldFrames = normalSamples.frames.filter(
|
||||
(sample) => sample.observationCount === 5200,
|
||||
);
|
||||
const normalFrames = normalFieldFrames.filter((sample) => !sample.didSolve);
|
||||
const solveP95 = percentile(solveSamples.map((sample) => sample.solveMs), 0.95);
|
||||
const renderP95 = percentile(normalFrames.map((sample) => sample.frameTotalMs), 0.95);
|
||||
// SwiftShader can render the complete Cesium scene below 8 FPS, making
|
||||
// every sampled overlay frame eligible for the 125 ms solve tick. Paint is
|
||||
// timed separately from solve, so it remains the deterministic frame-lane
|
||||
// measurement even when there are no non-solve frames in this backend.
|
||||
const paintP95 = percentile(
|
||||
(normalFrames.length ? normalFrames : normalFieldFrames).map((sample) => sample.paintMs),
|
||||
0.95,
|
||||
);
|
||||
const throttles = normalSamples.frames.map((sample) => sample.throttleSkipCount).filter(Number.isFinite);
|
||||
const throttleDelta = throttles.length ? Math.max(...throttles) - Math.min(...throttles) : 0;
|
||||
const normalPlacementOverflow = normalFieldFrames.filter(
|
||||
(sample) => sample.placementBuildCount > sample.selectedCount + sample.fadingCount
|
||||
+ (sample.didSolve ? sample.cohortCount : 0),
|
||||
);
|
||||
const solvePlacementOverflow = solveSamples.filter((sample) => {
|
||||
const cohort = Object.values(sample.cohortByLayer).reduce((sum, count) => sum + count, 0);
|
||||
return sample.placementBuildCount > cohort + sample.selectedCount + sample.fadingCount;
|
||||
});
|
||||
const cohortOverflow = solveSamples.filter((sample) => Object.entries(sample.cohortByLayer)
|
||||
.some(([layerId, count]) => {
|
||||
const quota = sample.entitlementByLayer[layerId] || 0;
|
||||
return count > Math.min(256, Math.max(64, 4 * quota));
|
||||
}));
|
||||
const membership = analyzeMembership(solveSamples);
|
||||
|
||||
record(
|
||||
'sampling is conclusive across the pathological and normal fields',
|
||||
Boolean(syntheticSampling && normalSampling)
|
||||
&& solveSamples.length >= MIN_SYNTHETIC_SOLVES
|
||||
&& syntheticSampling.solveSpanMs >= MIN_SYNTHETIC_SPAN_MS
|
||||
&& normalFieldFrames.length >= MIN_NORMAL_FRAMES,
|
||||
`synthetic solves=${solveSamples.length}, span=${syntheticSampling?.solveSpanMs?.toFixed(0) || 0}ms; normal frames=${normalFieldFrames.length}`,
|
||||
);
|
||||
|
||||
record(
|
||||
'synthetic churn samples remain non-empty at the 12,000-contact global view',
|
||||
steadySolveSamples.length === solveSamples.length,
|
||||
`steady=${steadySolveSamples.length}/${solveSamples.length}; population=${membership.minimumPopulation}-${membership.maximumPopulation}`,
|
||||
);
|
||||
|
||||
record(
|
||||
'solve cohort remains bounded per layer',
|
||||
cohortOverflow.length === 0,
|
||||
`${solveSamples.length} solves checked`,
|
||||
);
|
||||
record(
|
||||
'placement construction is bounded in both lanes',
|
||||
normalPlacementOverflow.length === 0 && solvePlacementOverflow.length === 0,
|
||||
`normal overflow=${normalPlacementOverflow.length}, solve overflow=${solvePlacementOverflow.length}`,
|
||||
);
|
||||
record(
|
||||
'solve p95 is below approximately 4 ms',
|
||||
solveP95 < 4.5,
|
||||
`${solveP95.toFixed(2)} ms`,
|
||||
);
|
||||
record(
|
||||
'normal-field overlay paint p95 is at or below 10 ms',
|
||||
paintP95 <= 10,
|
||||
`paint=${paintP95.toFixed(2)} ms${Number.isFinite(renderP95) ? `, non-solve total=${renderP95.toFixed(2)} ms` : ', every sampled frame included a solve'}`,
|
||||
);
|
||||
record(
|
||||
'normal scene does not trigger adaptive throttling',
|
||||
throttleDelta === 0,
|
||||
`skip delta=${throttleDelta}`,
|
||||
);
|
||||
record(
|
||||
'label churn remains below 8% per second',
|
||||
membership.churnPctPerSec < 8,
|
||||
`${membership.churnPctPerSec.toFixed(2)}%/s; replacements=${membership.replacements}, exposure=${membership.exposure.toFixed(1)} label-s, transitions=${membership.transitions}, population=${membership.minimumPopulation}-${membership.maximumPopulation}`,
|
||||
);
|
||||
record(
|
||||
'no show-hide-show cycle occurs within two seconds',
|
||||
membership.showHideShow === 0,
|
||||
`cycles=${membership.showHideShow}`,
|
||||
);
|
||||
record(
|
||||
'console remains clean',
|
||||
consoleErrors.length === 0,
|
||||
consoleErrors.slice(0, 3).join(' | ') || '0 errors',
|
||||
);
|
||||
record(
|
||||
'no HTTP 5xx responses occur',
|
||||
failedResponses.length === 0,
|
||||
failedResponses.slice(0, 3).join(' | ') || '0 responses',
|
||||
);
|
||||
|
||||
console.log(`\n Screenshot: ${path.join(SHOT_DIR, 'deterministic-12000.png')}`);
|
||||
console.log(` Screenshot: ${path.join(SHOT_DIR, 'normal-global-5200.png')}`);
|
||||
console.log(` Synthetic solves: ${solveSamples.length}; normal frames: ${normalFieldFrames.length} (${normalFrames.length} non-solve)`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const failures = results.filter((result) => !result.ok);
|
||||
console.log(`\n${results.length - failures.length}/${results.length} checks passed.`);
|
||||
if (failures.length) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,653 @@
|
||||
#!/usr/bin/env node
|
||||
/** Focused browser proof for the responsive, accessible Map Source tray. */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const shotsDir = path.join(repoRoot, 'qa-shots', 'map-source-tray');
|
||||
const appUrl = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const headful = process.argv.includes('--headful');
|
||||
// The no-ion-token contract is a real shipped state that a normal keyed
|
||||
// `dev-fresh.sh` run can never reach, so it went untested on every machine that
|
||||
// has a token. `--keyless` (or QA_MAP_SOURCE_TRAY_KEYLESS=1) clears the
|
||||
// controller's token in-page before the key-required assertions, so one server
|
||||
// can prove both branches. It forces the keyless EXPECTATIONS as well: a seam
|
||||
// that fails to take effect is a failure, not a quiet fall-through to the keyed
|
||||
// branch.
|
||||
const forceKeyless = process.argv.includes('--keyless')
|
||||
|| process.env.QA_MAP_SOURCE_TRAY_KEYLESS === '1';
|
||||
const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH
|
||||
|| (() => { try { return puppeteer.executablePath(); } catch { return null; } })();
|
||||
|
||||
if (!executablePath || !fs.existsSync(executablePath)) {
|
||||
throw new Error('Puppeteer Chrome for Testing is unavailable');
|
||||
}
|
||||
fs.mkdirSync(shotsDir, { recursive: true });
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: headful ? false : 'new',
|
||||
executablePath,
|
||||
args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const failures = [];
|
||||
const consoleErrors = [];
|
||||
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error' && !/Failed to load resource.*404/i.test(message.text())) {
|
||||
const source = message.location()?.url;
|
||||
consoleErrors.push(source ? `${message.text()} [${source}]` : message.text());
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => consoleErrors.push(error.message));
|
||||
|
||||
const check = (name, passed, detail = '') => {
|
||||
console.log(` [${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
if (!passed) failures.push(name);
|
||||
};
|
||||
|
||||
const trayMetrics = () => page.evaluate(() => {
|
||||
const panel = document.getElementById('control-panel');
|
||||
const popover = document.getElementById('control-panel-popover');
|
||||
const row = document.getElementById('map-stack-chips');
|
||||
const popoverRect = popover.getBoundingClientRect();
|
||||
const chips = [...row.children].map((chip) => {
|
||||
const rect = chip.getBoundingClientRect();
|
||||
return {
|
||||
id: chip.dataset.stackId,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
pressed: chip.getAttribute('aria-pressed'),
|
||||
ariaDisabled: chip.getAttribute('aria-disabled'),
|
||||
ariaLabel: chip.getAttribute('aria-label'),
|
||||
};
|
||||
});
|
||||
return {
|
||||
viewport: { width: innerWidth, height: innerHeight },
|
||||
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
|
||||
pinned: panel.classList.contains('dock-pinned'),
|
||||
popover: {
|
||||
left: popoverRect.left,
|
||||
right: popoverRect.right,
|
||||
top: popoverRect.top,
|
||||
bottom: popoverRect.bottom,
|
||||
width: popoverRect.width,
|
||||
},
|
||||
columns: getComputedStyle(row).gridTemplateColumns,
|
||||
rows: new Set(chips.map((chip) => chip.top)).size,
|
||||
chips,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 });
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/openai/hud-summary') {
|
||||
request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ summary: 'Map Source tray QA' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
request.continue();
|
||||
});
|
||||
await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 60_000 });
|
||||
await page.waitForFunction(
|
||||
() => document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
|
||||
const presentation = await page.evaluate(() => ({
|
||||
ids: [...document.querySelectorAll('.map-stack-chip')].map((chip) => chip.dataset.stackId),
|
||||
retiredPanel: Boolean(document.getElementById('stack-panel')),
|
||||
toggleTag: document.getElementById('control-panel-toggle')?.tagName,
|
||||
controls: document.getElementById('control-panel-toggle')?.getAttribute('aria-controls'),
|
||||
}));
|
||||
check(
|
||||
'exact four-source presentation; the retired left Map Stack panel is gone',
|
||||
JSON.stringify(presentation.ids) === JSON.stringify([
|
||||
'photoreal', 'bing-aerial', 'bing-labels', 'osm',
|
||||
]) && !presentation.retiredPanel,
|
||||
JSON.stringify(presentation),
|
||||
);
|
||||
check(
|
||||
'compact wing is a semantic disclosure',
|
||||
presentation.toggleTag === 'BUTTON' && presentation.controls === 'control-panel-popover',
|
||||
JSON.stringify(presentation),
|
||||
);
|
||||
|
||||
await page.focus('#control-panel-toggle');
|
||||
await page.keyboard.press('Enter');
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const keyboardOpen = await page.evaluate(() => ({
|
||||
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
|
||||
activeStack: document.activeElement?.dataset?.stackId || null,
|
||||
}));
|
||||
check(
|
||||
'Enter opens the tray and hands focus to a Map Source tile',
|
||||
keyboardOpen.expanded === 'true' && keyboardOpen.activeStack === 'photoreal',
|
||||
JSON.stringify(keyboardOpen),
|
||||
);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
const keyboardClose = await page.evaluate(() => ({
|
||||
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
|
||||
activeId: document.activeElement?.id || null,
|
||||
}));
|
||||
check(
|
||||
'Escape closes the tray and restores disclosure focus',
|
||||
keyboardClose.expanded === 'false' && keyboardClose.activeId === 'control-panel-toggle',
|
||||
JSON.stringify(keyboardClose),
|
||||
);
|
||||
|
||||
await page.keyboard.press('Space');
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const spaceOpen = await page.evaluate(() => ({
|
||||
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
|
||||
activeStack: document.activeElement?.dataset?.stackId || null,
|
||||
}));
|
||||
check(
|
||||
'Space opens the tray through the same keyboard path',
|
||||
spaceOpen.expanded === 'true' && spaceOpen.activeStack === 'photoreal',
|
||||
JSON.stringify(spaceOpen),
|
||||
);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await page.keyboard.down('Enter');
|
||||
await new Promise((resolve) => setTimeout(resolve, 320));
|
||||
await page.keyboard.up('Enter');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.keyboard.press('Enter');
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const longHoldRecovery = await page.evaluate(() => ({
|
||||
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
|
||||
activeStack: document.activeElement?.dataset?.stackId || null,
|
||||
}));
|
||||
check(
|
||||
'long Enter hold cannot strand the disclosure keyboard path',
|
||||
longHoldRecovery.expanded === 'true' && longHoldRecovery.activeStack === 'photoreal',
|
||||
JSON.stringify(longHoldRecovery),
|
||||
);
|
||||
|
||||
if (forceKeyless) {
|
||||
await page.evaluate(() => {
|
||||
const styleManager = window.__godsEyeView.styleManager;
|
||||
window.__qaIonTokenBackup = styleManager.mapStackController.cesiumToken;
|
||||
styleManager.mapStackController.cesiumToken = '';
|
||||
styleManager._initMapStackControl();
|
||||
});
|
||||
}
|
||||
await page.focus('[data-stack-id="bing-aerial"]');
|
||||
const ionAvailable = await page.$eval(
|
||||
'[data-stack-id="bing-aerial"]',
|
||||
(chip) => chip.getAttribute('aria-disabled') !== 'true',
|
||||
);
|
||||
await page.click('[data-stack-id="bing-aerial"]');
|
||||
if (ionAvailable) {
|
||||
// Cesium creates the imagery provider asynchronously. Wait for controller
|
||||
// truth instead of assuming a keyed switch can settle in one animation.
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.styleManager.mapStackController.getActiveId() === 'bing-aerial'
|
||||
|| Boolean(window.__godsEyeView.styleManager.mapStackController.getState()?.lastError),
|
||||
{ timeout: 20_000 },
|
||||
).catch(() => {});
|
||||
}
|
||||
const ionSource = await page.evaluate(() => {
|
||||
const chip = document.querySelector('[data-stack-id="bing-aerial"]');
|
||||
return {
|
||||
focused: document.activeElement === chip,
|
||||
ariaDisabled: chip.getAttribute('aria-disabled'),
|
||||
ariaLabel: chip.getAttribute('aria-label'),
|
||||
active: [...document.querySelectorAll('.map-stack-chip')]
|
||||
.filter((candidate) => candidate.getAttribute('aria-pressed') === 'true')
|
||||
.map((candidate) => candidate.dataset.stackId),
|
||||
};
|
||||
});
|
||||
if (forceKeyless || ionSource.ariaDisabled === 'true') {
|
||||
check(
|
||||
'key-required sources stay focusable, explained, and inert when no ion token is configured',
|
||||
ionSource.ariaDisabled === 'true'
|
||||
&& ionSource.focused
|
||||
&& /token required/i.test(ionSource.ariaLabel)
|
||||
&& JSON.stringify(ionSource.active) === JSON.stringify(['photoreal']),
|
||||
JSON.stringify(ionSource),
|
||||
);
|
||||
} else {
|
||||
check(
|
||||
'key-required sources switch normally when the ion token is configured',
|
||||
ionSource.focused
|
||||
&& ionSource.ariaDisabled === 'false'
|
||||
&& JSON.stringify(ionSource.active) === JSON.stringify(['bing-aerial']),
|
||||
JSON.stringify(ionSource),
|
||||
);
|
||||
}
|
||||
if (forceKeyless) {
|
||||
// Hand the real token back so every later assertion runs against the same
|
||||
// configuration in both invocations.
|
||||
await page.evaluate(() => {
|
||||
const styleManager = window.__godsEyeView.styleManager;
|
||||
styleManager.mapStackController.cesiumToken = window.__qaIonTokenBackup || '';
|
||||
delete window.__qaIonTokenBackup;
|
||||
styleManager._initMapStackControl();
|
||||
});
|
||||
}
|
||||
|
||||
const switching = await page.evaluate(async () => {
|
||||
const styleManager = window.__godsEyeView.styleManager;
|
||||
const controller = styleManager.mapStackController;
|
||||
const originalSetStack = controller.setStack.bind(controller);
|
||||
const before = controller.getActiveId();
|
||||
let release;
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
controller.setStack = async (stackId) => {
|
||||
await gate;
|
||||
return originalSetStack(stackId);
|
||||
};
|
||||
const switchPromise = styleManager._setMapStack('osm', { syncShare: false });
|
||||
const during = {
|
||||
status: document.getElementById('map-stack-status').textContent,
|
||||
active: [...document.querySelectorAll('.map-stack-chip')]
|
||||
.filter((chip) => chip.getAttribute('aria-pressed') === 'true')
|
||||
.map((chip) => chip.dataset.stackId),
|
||||
};
|
||||
release();
|
||||
await switchPromise;
|
||||
const after = {
|
||||
status: document.getElementById('map-stack-status').textContent,
|
||||
active: [...document.querySelectorAll('.map-stack-chip')]
|
||||
.filter((chip) => chip.getAttribute('aria-pressed') === 'true')
|
||||
.map((chip) => chip.dataset.stackId),
|
||||
};
|
||||
controller.setStack = originalSetStack;
|
||||
await styleManager._setMapStack(before, { syncShare: false });
|
||||
return { before, during, after };
|
||||
});
|
||||
check(
|
||||
'switching feedback is truthful and active state moves only after commit',
|
||||
switching.during.status === '...'
|
||||
&& JSON.stringify(switching.during.active) === JSON.stringify([switching.before])
|
||||
&& JSON.stringify(switching.after.active) === JSON.stringify(['osm']),
|
||||
JSON.stringify(switching),
|
||||
);
|
||||
|
||||
const acquiringLifecycle = await page.evaluate(async () => {
|
||||
const styleManager = window.__godsEyeView.styleManager;
|
||||
const status = document.getElementById('global-loading-status');
|
||||
const snapshot = () => ({
|
||||
hidden: status.hidden,
|
||||
state: status.dataset.state || null,
|
||||
label: document.getElementById('global-loading-label').textContent.trim(),
|
||||
detail: document.getElementById('global-loading-detail').textContent.trim(),
|
||||
});
|
||||
styleManager._handleShareTrackingRestoreStatus({
|
||||
classification: 'pending',
|
||||
layerId: 'flights',
|
||||
targetId: 'qa-flight',
|
||||
label: 'flight',
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
const pending = snapshot();
|
||||
styleManager._handleShareTrackingRestoreStatus({
|
||||
classification: 'followed',
|
||||
layerId: 'flights',
|
||||
targetId: 'qa-flight',
|
||||
label: 'flight',
|
||||
});
|
||||
const followed = snapshot();
|
||||
styleManager._handleShareTrackingRestoreStatus({
|
||||
classification: 'pending',
|
||||
layerId: 'military',
|
||||
targetId: 'qa-military',
|
||||
label: 'military flight',
|
||||
});
|
||||
styleManager._handleShareTrackingRestoreStatus({
|
||||
classification: 'source-unavailable',
|
||||
layerId: 'flights',
|
||||
targetId: 'qa-flight',
|
||||
label: 'flight',
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
const staleTerminal = snapshot();
|
||||
styleManager._handleShareTrackingRestoreStatus({
|
||||
classification: 'cancelled',
|
||||
layerId: 'military',
|
||||
targetId: 'qa-military',
|
||||
label: 'military flight',
|
||||
});
|
||||
const cancelled = snapshot();
|
||||
return { pending, followed, staleTerminal, cancelled };
|
||||
});
|
||||
check(
|
||||
'ACQUIRING DOM notice persists, ignores stale terminals, and clears on ownership completion',
|
||||
acquiringLifecycle.pending.hidden === false
|
||||
&& acquiringLifecycle.pending.state === 'acquiring'
|
||||
&& acquiringLifecycle.pending.label === 'ACQUIRING'
|
||||
&& acquiringLifecycle.pending.detail === 'SHARED FLIGHT'
|
||||
&& acquiringLifecycle.followed.hidden === true
|
||||
&& acquiringLifecycle.staleTerminal.hidden === false
|
||||
&& acquiringLifecycle.staleTerminal.state === 'acquiring'
|
||||
&& acquiringLifecycle.staleTerminal.detail === 'SHARED MILITARY FLIGHT'
|
||||
&& acquiringLifecycle.cancelled.hidden === true,
|
||||
JSON.stringify(acquiringLifecycle),
|
||||
);
|
||||
|
||||
const acquiringFailureArbitration = await page.evaluate(async () => {
|
||||
const styleManager = window.__godsEyeView.styleManager;
|
||||
const dataManager = styleManager._dataManager;
|
||||
const status = document.getElementById('global-loading-status');
|
||||
const originalGetAll = dataManager.getAll;
|
||||
const snapshot = () => ({
|
||||
hidden: status.hidden,
|
||||
state: status.dataset.state || null,
|
||||
label: document.getElementById('global-loading-label').textContent.trim(),
|
||||
detail: document.getElementById('global-loading-detail').textContent.trim(),
|
||||
});
|
||||
const waitForQueuedNotice = async (label, timeoutMs = 1000) => {
|
||||
const deadline = performance.now() + timeoutMs;
|
||||
while (styleManager._globalStatusNotice?.label !== label
|
||||
&& performance.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 16));
|
||||
}
|
||||
return styleManager._globalStatusNotice?.label === label;
|
||||
};
|
||||
const baseNow = performance.now();
|
||||
try {
|
||||
styleManager._loadingFeedbackState = {
|
||||
phase: 'idle', visible: false, startedAt: 0, showAt: 0, hideAt: 0,
|
||||
activeIds: [], batchOutcome: null, terminal: null, operation: null,
|
||||
};
|
||||
styleManager._handleShareTrackingRestoreStatus({
|
||||
classification: 'pending',
|
||||
layerId: 'flights',
|
||||
targetId: 'qa-failure-flight',
|
||||
label: 'flight',
|
||||
});
|
||||
dataManager.getAll = () => [{
|
||||
id: 'qa-unrelated-layer',
|
||||
name: 'QA unrelated layer',
|
||||
lifecycleState: 'enabling',
|
||||
enabled: false,
|
||||
stats: {},
|
||||
}];
|
||||
styleManager._updateGlobalLoadingFeedback(baseNow);
|
||||
styleManager._updateGlobalLoadingFeedback(baseNow + 200);
|
||||
dataManager.getAll = () => [];
|
||||
styleManager._loadingFeedbackEvent = {
|
||||
type: 'visibility-failed',
|
||||
layerId: 'qa-unrelated-layer',
|
||||
error: new Error('QA offline'),
|
||||
};
|
||||
styleManager._updateGlobalLoadingFeedback(baseNow + 300);
|
||||
const failureStart = snapshot();
|
||||
styleManager._handleShareTrackingRestoreStatus({
|
||||
classification: 'source-unavailable',
|
||||
layerId: 'flights',
|
||||
targetId: 'qa-failure-flight',
|
||||
label: 'flight',
|
||||
});
|
||||
const queuedNoticeReady = await waitForQueuedNotice(
|
||||
'Shared flight could not be restored — feed unavailable',
|
||||
);
|
||||
const shareFailureQueued = snapshot();
|
||||
styleManager._updateGlobalLoadingFeedback(baseNow + 5299);
|
||||
const failureEnd = snapshot();
|
||||
styleManager._updateGlobalLoadingFeedback(baseNow + 5300);
|
||||
const shareFailureStart = snapshot();
|
||||
styleManager._updateGlobalLoadingFeedback(baseNow + 10299);
|
||||
const shareFailureEnd = snapshot();
|
||||
styleManager._updateGlobalLoadingFeedback(baseNow + 10300);
|
||||
const settled = snapshot();
|
||||
return {
|
||||
failureStart,
|
||||
queuedNoticeReady,
|
||||
shareFailureQueued,
|
||||
failureEnd,
|
||||
shareFailureStart,
|
||||
shareFailureEnd,
|
||||
settled,
|
||||
};
|
||||
} finally {
|
||||
dataManager.getAll = originalGetAll;
|
||||
styleManager._handleShareTrackingRestoreStatus({
|
||||
classification: 'cancelled',
|
||||
layerId: 'flights',
|
||||
targetId: 'qa-failure-flight',
|
||||
label: 'flight',
|
||||
});
|
||||
}
|
||||
});
|
||||
check(
|
||||
'manager failure then queued share failure each receives its full visible dwell',
|
||||
acquiringFailureArbitration.failureStart.hidden === false
|
||||
&& acquiringFailureArbitration.failureStart.state === 'error'
|
||||
&& acquiringFailureArbitration.failureStart.label === 'LOAD FAILED'
|
||||
&& acquiringFailureArbitration.queuedNoticeReady === true
|
||||
&& acquiringFailureArbitration.shareFailureQueued.state === 'error'
|
||||
&& acquiringFailureArbitration.shareFailureQueued.label === 'LOAD FAILED'
|
||||
&& acquiringFailureArbitration.failureEnd.state === 'error'
|
||||
&& acquiringFailureArbitration.failureEnd.label === 'LOAD FAILED'
|
||||
&& acquiringFailureArbitration.shareFailureStart.state === 'error'
|
||||
&& acquiringFailureArbitration.shareFailureStart.label === 'Shared flight could not be restored — feed unavailable'
|
||||
&& acquiringFailureArbitration.shareFailureEnd.label === 'Shared flight could not be restored — feed unavailable'
|
||||
&& acquiringFailureArbitration.settled.hidden === true,
|
||||
JSON.stringify(acquiringFailureArbitration),
|
||||
);
|
||||
|
||||
// Unpinned mouse-away dismissal AFTER a tile click. Chromium focuses a
|
||||
// <button> on mouse press, so a close-guard reading plain
|
||||
// `document.activeElement` left the tray permanently open once Map Source
|
||||
// moved into it — switch a basemap and the popover never went away again
|
||||
// (field report). The pin samples the exact mechanism: focus IS parked
|
||||
// inside the panel and is NOT `:focus-visible`, and the tray closes anyway.
|
||||
const setControlPanelPinned = (wanted) => page.evaluate((want) => {
|
||||
const panel = document.getElementById('control-panel');
|
||||
if (panel.classList.contains('dock-pinned') !== want) {
|
||||
document.querySelector('.dock-pin-btn[data-pin-target="control-panel"]').click();
|
||||
}
|
||||
return panel.classList.contains('dock-pinned');
|
||||
}, wanted);
|
||||
const readTrayState = () => page.evaluate(() => {
|
||||
const panel = document.getElementById('control-panel');
|
||||
const active = document.activeElement;
|
||||
let focusVisible = null;
|
||||
try { focusVisible = active?.matches?.(':focus-visible') ?? null; } catch { focusVisible = null; }
|
||||
return {
|
||||
collapsed: panel.classList.contains('collapsed'),
|
||||
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
|
||||
focusInside: panel.contains(active),
|
||||
focusVisible,
|
||||
};
|
||||
});
|
||||
const clickTileThenLeave = async (stackId) => {
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager
|
||||
.setPanelCollapsed('control-panel', false, { explicit: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 240));
|
||||
await page.click(`[data-stack-id="${stackId}"]`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
const afterClick = await readTrayState();
|
||||
await page.mouse.move(20, 20); // leave the dock entirely → pointerleave
|
||||
// Past the 420 ms unpinned close delay with room for a slow frame.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return { afterClick, afterLeave: await readTrayState() };
|
||||
};
|
||||
|
||||
// The OTHER half of the same rule, asserted positively: a KEYBOARD user who
|
||||
// tabbed to a tile and pressed Enter must keep the tray, because closing it
|
||||
// out from under them would strand the caret in a hidden surface. Same
|
||||
// mouse-away that dismisses after a click, opposite outcome — so a fix that
|
||||
// simply deleted the focus guard would fail here.
|
||||
await setControlPanelPinned(false);
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager
|
||||
.setPanelCollapsed('control-panel', true, { explicit: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await page.focus('#control-panel-toggle');
|
||||
await page.keyboard.press('Enter'); // opens and hands focus to the active tile
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
await page.keyboard.press('Tab'); // tab ONTO a tile, keyboard modality
|
||||
await page.keyboard.press('Enter'); // activate it from the keyboard
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const keyboardAfterActivate = await page.evaluate(() => ({
|
||||
focusedStack: document.activeElement?.dataset?.stackId || null,
|
||||
isChip: !!document.activeElement?.classList?.contains('map-stack-chip'),
|
||||
}));
|
||||
// Enter the tray with the pointer and leave again, so a real pointerleave
|
||||
// schedules the close this pin expects to be declined.
|
||||
const chipPoint = await page.$eval('#map-stack-chips .map-stack-chip', (chip) => {
|
||||
const rect = chip.getBoundingClientRect();
|
||||
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||
});
|
||||
await page.mouse.move(chipPoint.x, chipPoint.y);
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
await page.mouse.move(20, 20);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
const keyboardHold = await readTrayState();
|
||||
check(
|
||||
'keyboard activation of a tile HOLDS the tray open through the same mouse-away',
|
||||
keyboardAfterActivate.isChip === true
|
||||
&& keyboardHold.collapsed === false
|
||||
&& keyboardHold.expanded === 'true'
|
||||
&& keyboardHold.focusInside === true
|
||||
&& keyboardHold.focusVisible === true,
|
||||
JSON.stringify({ keyboardAfterActivate, keyboardHold }),
|
||||
);
|
||||
|
||||
await setControlPanelPinned(false);
|
||||
const dismissAfterTileClick = await clickTileThenLeave('osm');
|
||||
const pinnedForHold = await setControlPanelPinned(true);
|
||||
const pinnedHold = await clickTileThenLeave('photoreal');
|
||||
await setControlPanelPinned(false);
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager
|
||||
._setMapStack('photoreal', { syncShare: false }));
|
||||
// Hand the tray back OPEN and unpinned — the responsive block below starts by
|
||||
// clicking the pin control, which is only hittable while the tray is showing.
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager
|
||||
.setPanelCollapsed('control-panel', false, { explicit: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 240));
|
||||
check(
|
||||
'a tile click does not exempt the unpinned tray from mouse-away auto-dismiss',
|
||||
dismissAfterTileClick.afterClick.collapsed === false
|
||||
&& dismissAfterTileClick.afterClick.focusInside === true
|
||||
&& dismissAfterTileClick.afterClick.focusVisible === false
|
||||
&& dismissAfterTileClick.afterLeave.collapsed === true
|
||||
&& dismissAfterTileClick.afterLeave.expanded === 'false'
|
||||
&& pinnedForHold === true
|
||||
&& pinnedHold.afterLeave.collapsed === false,
|
||||
JSON.stringify({ dismissAfterTileClick, pinnedForHold, pinnedHold }),
|
||||
);
|
||||
|
||||
await page.click('.dock-pin-btn[data-pin-target="control-panel"]');
|
||||
const desktop = await trayMetrics();
|
||||
check(
|
||||
'desktop tray is one row and fully inside the viewport',
|
||||
desktop.rows === 1
|
||||
&& desktop.popover.left >= 0
|
||||
&& desktop.popover.right <= desktop.viewport.width,
|
||||
JSON.stringify(desktop),
|
||||
);
|
||||
|
||||
await page.setViewport({ width: 620, height: 900, deviceScaleFactor: 1 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 180));
|
||||
const at620 = await trayMetrics();
|
||||
check(
|
||||
'620 px tray uses two rows without clipping',
|
||||
at620.expanded === 'true'
|
||||
&& at620.pinned
|
||||
&& at620.rows === 2
|
||||
&& at620.popover.left >= 0
|
||||
&& at620.popover.right <= at620.viewport.width,
|
||||
JSON.stringify(at620),
|
||||
);
|
||||
|
||||
await page.setViewport({ width: 480, height: 900, deviceScaleFactor: 1 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 180));
|
||||
const at480 = await trayMetrics();
|
||||
const allRectsInside = at480.chips.every((chip) => (
|
||||
chip.left >= 0 && chip.right <= at480.viewport.width
|
||||
&& chip.top >= 0 && chip.bottom <= at480.viewport.height
|
||||
));
|
||||
check(
|
||||
'480 px live resize keeps the open tray and every tile in bounds',
|
||||
at480.expanded === 'true'
|
||||
&& at480.pinned
|
||||
&& at480.rows === 2
|
||||
&& at480.popover.left >= 0
|
||||
&& at480.popover.right <= at480.viewport.width
|
||||
&& allRectsInside,
|
||||
JSON.stringify(at480),
|
||||
);
|
||||
await page.screenshot({ path: path.join(shotsDir, '480-open.png') });
|
||||
|
||||
await page.click('.dock-pin-btn[data-pin-target="control-panel"]');
|
||||
await page.click('#control-panel-toggle');
|
||||
const cdp = await page.createCDPSession();
|
||||
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
|
||||
const toggleRect = await page.$eval('#control-panel-toggle', (toggle) => {
|
||||
const rect = toggle.getBoundingClientRect();
|
||||
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||
});
|
||||
await page.touchscreen.tap(toggleRect.x, toggleRect.y);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const touchOpen = await page.$eval('#control-panel-toggle', (toggle) => toggle.getAttribute('aria-expanded'));
|
||||
check('coarse-pointer tap opens the compact wing', touchOpen === 'true', `aria-expanded=${touchOpen}`);
|
||||
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: false });
|
||||
|
||||
// Retired and unknown stack ids take the SAME path. `bing-road` was the fifth
|
||||
// source behind the retired left Map Stack panel; deleting it from
|
||||
// `MAP_STACKS` (no build carrying it ever shipped publicly, so no link is
|
||||
// owed anything) means an old `map=bing-road` link is now simply an
|
||||
// unrecognized id, and `setStack()`'s `getStack(id) || getStack('photoreal')`
|
||||
// fallback lands it on Google 3D with that tile lit — never on a hidden fifth
|
||||
// source whose status reads ROAD while no tile is pressed.
|
||||
await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 });
|
||||
for (const legacyId of ['bing-road', 'garbage']) {
|
||||
await page.goto(`${appUrl}#v=2&lat=30.27&lon=-97.74&map=${legacyId}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 60_000,
|
||||
});
|
||||
await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 60_000 });
|
||||
await page.waitForFunction(
|
||||
() => document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.styleManager.mapStackController.getState()?.status !== 'switching',
|
||||
{ timeout: 20_000 },
|
||||
).catch(() => {});
|
||||
const restored = await page.evaluate(() => ({
|
||||
activeId: window.__godsEyeView.styleManager.mapStackController.getActiveId(),
|
||||
lastError: window.__godsEyeView.styleManager.mapStackController.getState()?.lastError || null,
|
||||
status: document.getElementById('map-stack-status').textContent.trim(),
|
||||
pressed: [...document.querySelectorAll('.map-stack-chip')]
|
||||
.filter((chip) => chip.getAttribute('aria-pressed') === 'true')
|
||||
.map((chip) => chip.dataset.stackId),
|
||||
}));
|
||||
check(
|
||||
`a map=${legacyId} link restores to photoreal with the photoreal tile lit`,
|
||||
restored.activeId === 'photoreal'
|
||||
&& restored.lastError === null
|
||||
&& JSON.stringify(restored.pressed) === JSON.stringify(['photoreal']),
|
||||
JSON.stringify(restored),
|
||||
);
|
||||
await page.screenshot({ path: path.join(shotsDir, `legacy-${legacyId}.png`) });
|
||||
}
|
||||
|
||||
check('no new page or console errors', consoleErrors.length === 0, consoleErrors.join(' | '));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`\nMap Source tray QA failed: ${failures.join(', ')}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log('\nMap Source tray QA passed.');
|
||||
}
|
||||
@@ -0,0 +1,943 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Phase 0 world-overlay baseline harness.
|
||||
*
|
||||
* The harness intentionally measures the current implementation without
|
||||
* importing or changing application modules. Each scene gets a fresh page,
|
||||
* enables its layers through DataLayerManager.toggle(), waits for layer data,
|
||||
* then samples five seconds of scripted camera motion and five seconds at rest.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/qa-overlay-baseline.mjs
|
||||
* node scripts/qa-overlay-baseline.mjs --scene datacenters
|
||||
* node scripts/qa-overlay-baseline.mjs --scene cctv-street,detection-50
|
||||
* node scripts/qa-overlay-baseline.mjs --json overlay-baseline.json
|
||||
* node scripts/qa-overlay-baseline.mjs --screenshots-dir overlay-shots
|
||||
* node scripts/qa-overlay-baseline.mjs --hardware-gpu --headful
|
||||
* node scripts/qa-overlay-baseline.mjs --dist-dir gev-dist
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const CHROME_EXECUTABLE = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const DEFAULT_URL = 'http://localhost:4176';
|
||||
const VIEWPORT = Object.freeze({ width: 1440, height: 900 });
|
||||
const SAMPLE_MS = 5_000;
|
||||
const BETWEEN_PHASE_SETTLE_MS = 1_000;
|
||||
const DEFAULT_SETTLE_MS = 3_000;
|
||||
const LAYER_WAIT_MS = 60_000;
|
||||
const KNOWN_OVERLAY_CANVASES = new Set([
|
||||
'world-overlay-canvas',
|
||||
'tracked-readout',
|
||||
'firms-labels',
|
||||
'vessel-labels',
|
||||
'cctv-cards',
|
||||
]);
|
||||
|
||||
const SCENES = Object.freeze([
|
||||
{ id: 'datacenters', layers: ['local-datacenters'], camera: [-98, 38, 6_000_000, 0, -Math.PI / 2] },
|
||||
{ id: 'dams', layers: ['local-dams'], camera: [-98, 38, 6_000_000, 0, -Math.PI / 2] },
|
||||
{ id: 'datacenters+dams', layers: ['local-datacenters', 'local-dams'], camera: [-98, 38, 6_000_000, 0, -Math.PI / 2] },
|
||||
{ id: 'submarine-cables', layers: ['telegeography-submarine-cables'], camera: [-20, 12, 11_000_000, 0, -Math.PI / 2] },
|
||||
{ id: 'cctv-street', layers: ['cctv'], cctvHeightM: 1_500 },
|
||||
{ id: 'cctv-city', layers: ['cctv'], cctvHeightM: 6_000 },
|
||||
{ id: 'cctv-high', layers: ['cctv'], cctvHeightM: 12_000 },
|
||||
{ id: 'firms', layers: ['local-firms'], camera: [-110, 45, 5_000_000, 0, -Math.PI / 2] },
|
||||
{ id: 'vessels', layers: ['ais-live-vessels'], camera: [4.05, 51.93, 18_000, 0.3, -1.25] },
|
||||
{ id: 'detection-25', layers: ['flights', 'satellites'], detectionDensity: 25, camera: [-98, 38, 2_500_000, 0, -Math.PI / 2] },
|
||||
{ id: 'detection-50', layers: ['flights', 'satellites'], detectionDensity: 50, camera: [-98, 38, 2_500_000, 0, -Math.PI / 2] },
|
||||
{ id: 'detection-100', layers: ['flights', 'satellites'], detectionDensity: 100, camera: [-98, 38, 2_500_000, 0, -Math.PI / 2] },
|
||||
{ id: 'tracked-civil-aircraft', layers: ['flights'], trackedFlight: true, camera: [-98, 38, 2_500_000, 0, -Math.PI / 2] },
|
||||
{ id: 'missions-selected', layers: ['rocket-launches'], missionSelected: true, camera: [0, 15, 18_000_000, 0, -Math.PI / 2] },
|
||||
{ id: 'cockpit-mode', layers: ['flights'], trackedFlight: true, cockpit: true, camera: [-98, 38, 2_500_000, 0, -Math.PI / 2] },
|
||||
]);
|
||||
|
||||
const SCENE_ALIASES = Object.freeze({
|
||||
'datacenters-dams': 'datacenters+dams',
|
||||
cables: 'submarine-cables',
|
||||
'tracked-civil': 'tracked-civil-aircraft',
|
||||
missions: 'missions-selected',
|
||||
cockpit: 'cockpit-mode',
|
||||
});
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
|
||||
function getOpt(name, fallback = null) {
|
||||
const index = argv.indexOf(name);
|
||||
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
||||
}
|
||||
|
||||
function hasFlag(name) {
|
||||
return argv.includes(name);
|
||||
}
|
||||
|
||||
function selectedScenes() {
|
||||
const raw = getOpt('--scene');
|
||||
if (!raw) return [...SCENES];
|
||||
const requested = raw.split(',').map((value) => value.trim()).filter(Boolean);
|
||||
const selected = [];
|
||||
for (const requestedId of requested) {
|
||||
const id = SCENE_ALIASES[requestedId] || requestedId;
|
||||
const scene = SCENES.find((candidate) => candidate.id === id);
|
||||
if (!scene) {
|
||||
throw new Error(`Unknown scene '${requestedId}'. Available: ${SCENES.map((candidate) => candidate.id).join(', ')}`);
|
||||
}
|
||||
selected.push(scene);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function contentType(filePath) {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
return ({
|
||||
'.css': 'text/css',
|
||||
'.gif': 'image/gif',
|
||||
'.html': 'text/html',
|
||||
'.ico': 'image/x-icon',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'text/javascript',
|
||||
'.json': 'application/json',
|
||||
'.ktx2': 'image/ktx2',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.wasm': 'application/wasm',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
})[extension] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
async function installStaticDist(page, distDir) {
|
||||
if (!distDir) return;
|
||||
const root = path.resolve(distDir);
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', async (request) => {
|
||||
const url = new URL(request.url());
|
||||
try {
|
||||
if (url.origin === new URL(APP_URL).origin) {
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
await request.respond({
|
||||
status: 503,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'offline_dist_capture', message: 'Dev-server proxies unavailable' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const relativePath = decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname).replace(/^\/+/, '');
|
||||
const filePath = path.resolve(root, relativePath);
|
||||
if (filePath !== root && !filePath.startsWith(`${root}${path.sep}`)) {
|
||||
await request.respond({ status: 403, body: 'Forbidden' });
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
||||
await request.respond({ status: 404, body: 'Not found' });
|
||||
return;
|
||||
}
|
||||
await request.respond({
|
||||
status: 200,
|
||||
contentType: contentType(filePath),
|
||||
body: fs.readFileSync(filePath),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.hostname === 'tile.googleapis.com' && url.pathname.includes('/3dtiles/')) {
|
||||
await request.respond({
|
||||
status: 403,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: { message: 'Offline dist capture: Google tiles unavailable' } }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await request.continue();
|
||||
} catch {
|
||||
try { await request.abort(); } catch { /* request already resolved */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function installDeterministicDevEndpoints(page) {
|
||||
if (DIST_DIR) return;
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', async (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.origin !== new URL(APP_URL).origin) {
|
||||
await request.continue();
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/openai/hud-summary') {
|
||||
await request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ summary: 'QA globe ready' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/ais-live') {
|
||||
await request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'connected', rows: [], lastMessageAt: null }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await request.continue();
|
||||
});
|
||||
}
|
||||
|
||||
function round(value, digits = 3) {
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function percentile(values, fraction) {
|
||||
const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);
|
||||
if (!sorted.length) return null;
|
||||
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * fraction) - 1));
|
||||
return sorted[index];
|
||||
}
|
||||
|
||||
function summarizeFrameIntervals(intervals) {
|
||||
const usable = intervals.filter((value) => Number.isFinite(value) && value > 0);
|
||||
if (!usable.length) {
|
||||
return { samples: 0, meanMs: null, p50Ms: null, p95Ms: null, p99Ms: null, maxMs: null };
|
||||
}
|
||||
const mean = usable.reduce((sum, value) => sum + value, 0) / usable.length;
|
||||
return {
|
||||
samples: usable.length,
|
||||
meanMs: round(mean),
|
||||
p50Ms: round(percentile(usable, 0.50)),
|
||||
p95Ms: round(percentile(usable, 0.95)),
|
||||
p99Ms: round(percentile(usable, 0.99)),
|
||||
maxMs: round(Math.max(...usable)),
|
||||
};
|
||||
}
|
||||
|
||||
function numericDelta(after, before, key) {
|
||||
return (after?.totals?.[key] || 0) - (before?.totals?.[key] || 0);
|
||||
}
|
||||
|
||||
function inventoryDelta(before, after) {
|
||||
return {
|
||||
entities: numericDelta(after, before, 'entities'),
|
||||
labelGraphics: numericDelta(after, before, 'labelGraphics'),
|
||||
nonemptyLabelText: numericDelta(after, before, 'nonemptyLabelText'),
|
||||
emptyLabelText: numericDelta(after, before, 'emptyLabelText'),
|
||||
shownLabelGraphics: numericDelta(after, before, 'shownLabelGraphics'),
|
||||
primitiveLabels: numericDelta(after, before, 'primitiveLabels'),
|
||||
primitiveBillboards: numericDelta(after, before, 'primitiveBillboards'),
|
||||
primitivePoints: numericDelta(after, before, 'primitivePoints'),
|
||||
};
|
||||
}
|
||||
|
||||
function printContext(context) {
|
||||
console.log('\nWorld Overlay Phase 0 Baseline');
|
||||
console.log(` URL : ${context.url}`);
|
||||
console.log(` Machine : ${context.machine.hostname} · ${context.machine.platform} ${context.machine.release} · ${context.machine.arch}`);
|
||||
console.log(` CPU / RAM : ${context.machine.cpuModel} · ${context.machine.logicalCpuCount} logical · ${context.machine.totalMemoryGiB} GiB`);
|
||||
console.log(` Browser : ${context.browser.version}`);
|
||||
console.log(` User agent : ${context.browser.userAgent}`);
|
||||
console.log(` Viewport : ${context.viewport.width}×${context.viewport.height} CSS px · DPR ${context.viewport.dpr}`);
|
||||
console.log(` WebGL : ${context.browser.webglVendor} · ${context.browser.webglRenderer}`);
|
||||
console.log(` Capture : ${context.captureMode}`);
|
||||
console.log(context.gpuMode === 'hardware'
|
||||
? ' Timing note: hardware-GPU run; retain the renderer string with every comparison.\n'
|
||||
: ' Timing note: SwiftShader absolute FPS is non-representative; compare motion/rest and before/after only.\n');
|
||||
}
|
||||
|
||||
function printScene(result) {
|
||||
const tag = result.status === 'ok' ? 'OK' : result.status === 'skipped' ? 'SKIP' : 'ERROR';
|
||||
console.log(`[${tag}] ${result.scene}`);
|
||||
if (result.reason) console.log(` reason : ${result.reason}`);
|
||||
for (const activation of result.layerActivations || []) {
|
||||
const delta = activation.delta;
|
||||
console.log(` toggle ${activation.layerId.padEnd(31)} entities ${String(delta.entities).padStart(6)} · LabelGraphics ${String(delta.labelGraphics).padStart(6)} · primitive labels ${String(delta.primitiveLabels).padStart(6)}`);
|
||||
}
|
||||
if (result.entityInventory) {
|
||||
const totals = result.entityInventory.totals;
|
||||
console.log(` scene total : entities ${totals.entities} · LabelGraphics ${totals.labelGraphics} (${totals.shownLabelGraphics} show=true) · primitive labels ${totals.primitiveLabels}`);
|
||||
}
|
||||
for (const phaseName of ['motion', 'rest']) {
|
||||
const phase = result.samples?.[phaseName];
|
||||
if (!phase) continue;
|
||||
const frame = phase.frameTime;
|
||||
console.log(` ${phaseName.padEnd(6)} frame: n=${frame.samples} mean=${frame.meanMs} ms p50=${frame.p50Ms} ms p95=${frame.p95Ms} ms p99=${frame.p99Ms} ms`);
|
||||
for (const canvas of phase.canvases || []) {
|
||||
console.log(` ${canvas.id.padEnd(18)} frames≈${String(canvas.clearRectCalls).padStart(4)} fillText=${String(canvas.fillTextCalls).padStart(5)} drawImage=${String(canvas.drawImageCalls).padStart(5)} 2D-sync=${String(canvas.totalSyncMs).padStart(8)} ms`);
|
||||
}
|
||||
}
|
||||
const detection = result.samples?.rest?.detectionDiagnostics;
|
||||
if (detection) {
|
||||
console.log(` detection : observations=${detection.observationCount ?? 'n/a'} candidates=${detection.candidateCount ?? 'n/a'} visible=${detection.visibleCount ?? 'n/a'} selected=${detection.selectedCount ?? 'n/a'} solve=${round(detection.solveMs)} ms paint=${round(detection.paintMs)} ms`);
|
||||
}
|
||||
const exposed = result.samples?.rest?.exposedCounts;
|
||||
if (exposed && Object.values(exposed).some((value) => value != null)) {
|
||||
console.log(` exposed : CCTV cards=${exposed.cctvOwnedEntries ?? 'n/a'}/${exposed.cctvEntryLimit ?? 'n/a'} · FIRMS objects=${exposed.firmsSourceObjects ?? 'n/a'} · vessel objects=${exposed.vesselSourceObjects ?? 'n/a'}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
async function installPageInstrumentation(page) {
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
const trackedMethods = [
|
||||
'arc', 'arcTo', 'beginPath', 'clearRect', 'closePath', 'drawImage', 'fill',
|
||||
'fillRect', 'fillText', 'lineTo', 'measureText', 'moveTo', 'quadraticCurveTo',
|
||||
'rect', 'resetTransform', 'restore', 'rotate', 'roundRect', 'save', 'scale',
|
||||
'setTransform', 'stroke', 'strokeRect', 'strokeText', 'translate',
|
||||
];
|
||||
const metrics = new Map();
|
||||
const metricFor = (canvas) => {
|
||||
const id = canvas?.id || '(anonymous-canvas)';
|
||||
let metric = metrics.get(id);
|
||||
if (!metric) {
|
||||
metric = { id, calls: 0, totalSyncMs: 0, maxSyncMs: 0, methods: {} };
|
||||
metrics.set(id, metric);
|
||||
}
|
||||
return metric;
|
||||
};
|
||||
const proto = globalThis.CanvasRenderingContext2D?.prototype;
|
||||
if (proto) {
|
||||
for (const method of trackedMethods) {
|
||||
const original = proto[method];
|
||||
if (typeof original !== 'function' || original.__gevBaselineWrapped) continue;
|
||||
const wrapped = function(...args) {
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
return Reflect.apply(original, this, args);
|
||||
} finally {
|
||||
const elapsed = performance.now() - startedAt;
|
||||
const metric = metricFor(this.canvas);
|
||||
metric.calls++;
|
||||
metric.totalSyncMs += elapsed;
|
||||
metric.maxSyncMs = Math.max(metric.maxSyncMs, elapsed);
|
||||
const methodMetric = metric.methods[method] || (metric.methods[method] = { calls: 0, totalSyncMs: 0 });
|
||||
methodMetric.calls++;
|
||||
methodMetric.totalSyncMs += elapsed;
|
||||
}
|
||||
};
|
||||
Object.defineProperty(wrapped, '__gevBaselineWrapped', { value: true });
|
||||
try { proto[method] = wrapped; } catch { /* A non-writable method stays uninstrumented. */ }
|
||||
}
|
||||
}
|
||||
globalThis.__overlayBaselineInstrumentation = {
|
||||
reset() { metrics.clear(); },
|
||||
snapshot() {
|
||||
return Array.from(metrics.values()).map((metric) => ({
|
||||
id: metric.id,
|
||||
calls: metric.calls,
|
||||
totalSyncMs: metric.totalSyncMs,
|
||||
maxSyncMs: metric.maxSyncMs,
|
||||
methods: JSON.parse(JSON.stringify(metric.methods)),
|
||||
}));
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForApp(page) {
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
await page.waitForFunction(
|
||||
() => Boolean(window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
try { viewer.camera.cancelFlight(); } catch { /* no flight */ }
|
||||
try { viewer.scene.tweens?.removeAll?.(); } catch { /* no tween collection */ }
|
||||
});
|
||||
await sleep(1_250);
|
||||
}
|
||||
|
||||
async function setCamera(page, camera) {
|
||||
if (!camera) return;
|
||||
await page.evaluate(([lon, lat, height, heading, pitch]) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const ellipsoid = viewer.scene.globe.ellipsoid;
|
||||
viewer.camera.cancelFlight?.();
|
||||
viewer.scene.tweens?.removeAll?.();
|
||||
viewer.camera.setView({
|
||||
destination: ellipsoid.cartographicToCartesian({
|
||||
longitude: lon * Math.PI / 180,
|
||||
latitude: lat * Math.PI / 180,
|
||||
height,
|
||||
}),
|
||||
orientation: { heading, pitch, roll: 0 },
|
||||
});
|
||||
viewer.scene.requestRender?.();
|
||||
}, camera);
|
||||
}
|
||||
|
||||
async function readLayerState(page, layerId) {
|
||||
return page.evaluate((id) => {
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
const entry = manager.layers.get(id);
|
||||
if (!entry) return null;
|
||||
let stats = {};
|
||||
try { stats = entry.initialized ? entry.module.getStats?.() || {} : {}; } catch (error) { stats = { error: error.message }; }
|
||||
return { enabled: entry.enabled, initialized: entry.initialized, stats };
|
||||
}, layerId);
|
||||
}
|
||||
|
||||
async function waitForLayer(page, layerId) {
|
||||
const dataBearing = new Set([
|
||||
'local-datacenters', 'local-dams', 'telegeography-submarine-cables', 'cctv',
|
||||
'local-firms', 'ais-live-vessels', 'flights', 'satellites', 'rocket-launches',
|
||||
]);
|
||||
if (!dataBearing.has(layerId)) return readLayerState(page, layerId);
|
||||
try {
|
||||
await page.waitForFunction((id) => {
|
||||
const entry = window.__godsEyeView?.dataManager?.layers?.get(id);
|
||||
if (!entry?.enabled || !entry.initialized) return false;
|
||||
let stats;
|
||||
try { stats = entry.module.getStats?.() || {}; } catch { return false; }
|
||||
if (stats.loading === true) return false;
|
||||
if (stats.error) return true;
|
||||
return Number(stats.count || 0) > 0;
|
||||
}, { timeout: LAYER_WAIT_MS, polling: 250 }, layerId);
|
||||
} catch {
|
||||
// The caller records the final state and decides whether live-data absence
|
||||
// makes the specialized scene skippable.
|
||||
}
|
||||
return readLayerState(page, layerId);
|
||||
}
|
||||
|
||||
async function readEntityInventory(page) {
|
||||
return page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const time = viewer.clock.currentTime;
|
||||
const collections = [];
|
||||
const totals = {
|
||||
entities: 0,
|
||||
labelGraphics: 0,
|
||||
shownLabelGraphics: 0,
|
||||
nonemptyLabelText: 0,
|
||||
emptyLabelText: 0,
|
||||
primitiveLabels: 0,
|
||||
primitiveBillboards: 0,
|
||||
primitivePoints: 0,
|
||||
scenePrimitives: 0,
|
||||
};
|
||||
|
||||
const propertyValue = (property, fallback) => {
|
||||
if (property == null) return fallback;
|
||||
try {
|
||||
return typeof property.getValue === 'function' ? property.getValue(time) : property;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
const labelGroup = (entityId) => {
|
||||
const id = String(entityId || '');
|
||||
if (id.startsWith('rocket-launch:')) return 'mission-anchor';
|
||||
if (id.startsWith('rocket-satellite:')) return 'mission-live-or-estimated';
|
||||
if (id.startsWith('rocket-orbit-label:')) return 'mission-orbit';
|
||||
if (id.startsWith('rocket-reentry-label:')) return 'mission-reentry';
|
||||
if (id.startsWith('cctv-')) return 'cctv';
|
||||
return id.includes(':') ? id.split(':', 1)[0] : '(other)';
|
||||
};
|
||||
const addCollection = (scope, name, entityCollection, collectionShown = true) => {
|
||||
const entities = Array.from(entityCollection?.values || []);
|
||||
let labelGraphics = 0;
|
||||
let shownLabelGraphics = 0;
|
||||
let nonemptyLabelText = 0;
|
||||
let emptyLabelText = 0;
|
||||
const labelGroups = {};
|
||||
for (const entity of entities) {
|
||||
if (!entity?.label) continue;
|
||||
labelGraphics++;
|
||||
const text = String(propertyValue(entity.label.text, '') || '');
|
||||
if (text) nonemptyLabelText++;
|
||||
else emptyLabelText++;
|
||||
const entityShown = entity.show !== false && collectionShown;
|
||||
const labelShown = Boolean(propertyValue(entity.label.show, true));
|
||||
if (entityShown && labelShown) shownLabelGraphics++;
|
||||
const group = labelGroup(entity.id);
|
||||
labelGroups[group] = (labelGroups[group] || 0) + 1;
|
||||
}
|
||||
const row = {
|
||||
scope,
|
||||
name,
|
||||
entities: entities.length,
|
||||
labelGraphics,
|
||||
shownLabelGraphics,
|
||||
nonemptyLabelText,
|
||||
emptyLabelText,
|
||||
labelGroups,
|
||||
};
|
||||
collections.push(row);
|
||||
totals.entities += row.entities;
|
||||
totals.labelGraphics += row.labelGraphics;
|
||||
totals.shownLabelGraphics += row.shownLabelGraphics;
|
||||
totals.nonemptyLabelText += row.nonemptyLabelText;
|
||||
totals.emptyLabelText += row.emptyLabelText;
|
||||
};
|
||||
|
||||
addCollection('viewer', 'viewer.entities', viewer.entities, true);
|
||||
for (let index = 0; index < viewer.dataSources.length; index++) {
|
||||
const dataSource = viewer.dataSources.get(index);
|
||||
addCollection('dataSource', dataSource.name || `dataSource:${index}`, dataSource.entities, dataSource.show !== false);
|
||||
}
|
||||
|
||||
const primitiveTypes = {};
|
||||
const seen = new Set();
|
||||
const visitPrimitiveCollection = (collection, depth = 0) => {
|
||||
if (!collection || depth > 5 || seen.has(collection)) return;
|
||||
seen.add(collection);
|
||||
const length = Number(collection.length || 0);
|
||||
for (let index = 0; index < length; index++) {
|
||||
let primitive;
|
||||
try { primitive = collection.get(index); } catch { primitive = null; }
|
||||
if (!primitive || seen.has(primitive)) continue;
|
||||
const type = primitive.constructor?.name || '(anonymous)';
|
||||
primitiveTypes[type] = (primitiveTypes[type] || 0) + 1;
|
||||
totals.scenePrimitives++;
|
||||
if (Array.isArray(primitive._labels)) totals.primitiveLabels += primitive._labels.length;
|
||||
if (Array.isArray(primitive._billboards)) totals.primitiveBillboards += primitive._billboards.length;
|
||||
if (Array.isArray(primitive._pointPrimitives)) totals.primitivePoints += primitive._pointPrimitives.length;
|
||||
if (typeof primitive.length === 'number' && typeof primitive.get === 'function') {
|
||||
visitPrimitiveCollection(primitive, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
visitPrimitiveCollection(viewer.scene.primitives);
|
||||
|
||||
return { totals, collections, primitiveTypes };
|
||||
});
|
||||
}
|
||||
|
||||
async function activateLayers(page, layerIds) {
|
||||
const activations = [];
|
||||
for (const layerId of layerIds) {
|
||||
const before = await readEntityInventory(page);
|
||||
const toggleResult = await page.evaluate(async (id) => {
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
if (!manager.layers.has(id)) return { error: `unregistered layer: ${id}` };
|
||||
if (!manager.isEnabled(id)) await manager.toggle(id);
|
||||
return { enabled: manager.isEnabled(id) };
|
||||
}, layerId);
|
||||
const state = await waitForLayer(page, layerId);
|
||||
const after = await readEntityInventory(page);
|
||||
activations.push({
|
||||
layerId,
|
||||
toggleResult,
|
||||
state,
|
||||
delta: inventoryDelta(before, after),
|
||||
collectionsAdded: after.collections.filter((row) => !before.collections.some((prior) => prior.scope === row.scope && prior.name === row.name)),
|
||||
});
|
||||
}
|
||||
return activations;
|
||||
}
|
||||
|
||||
async function prepareCctv(page, heightM) {
|
||||
const camera = await page.evaluate((height) => {
|
||||
const layer = window.__godsEyeView.dataManager.layers.get('cctv')?.module;
|
||||
const state = layer?.getUIState?.();
|
||||
const record = state?.cameras?.find((candidate) => Number.isFinite(candidate.lon) && Number.isFinite(candidate.lat));
|
||||
if (!record) return null;
|
||||
return [record.lon, record.lat, height, 0, -1.35];
|
||||
}, heightM);
|
||||
if (!camera) return 'CCTV catalog did not expose a geolocated camera';
|
||||
await setCamera(page, camera);
|
||||
await sleep(7_000);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function prepareFirms(page) {
|
||||
const result = await page.evaluate(() => {
|
||||
const layer = window.__godsEyeView.dataManager.layers.get('local-firms')?.module;
|
||||
const stats = layer?.getStats?.() || {};
|
||||
const fire = layer?.getStrongestFire?.();
|
||||
return { stats, fire };
|
||||
});
|
||||
if (!result.fire) return `FIRMS unavailable (${result.stats.error || 'no detections'})`;
|
||||
await setCamera(page, [result.fire.longitude, result.fire.latitude, 60_000, 0, -1.45]);
|
||||
await sleep(4_000);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function prepareVessels(page) {
|
||||
const state = await readLayerState(page, 'ais-live-vessels');
|
||||
if (!(state?.stats?.count > 0)) return `AIS unavailable (${state?.stats?.error || 'no live vessels'})`;
|
||||
await sleep(2_500);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function prepareDetection(page, densityPct) {
|
||||
const sourceStates = await Promise.all(['flights', 'satellites'].map((id) => readLayerState(page, id)));
|
||||
if (!sourceStates.some((state) => (state?.stats?.count || 0) > 0)) {
|
||||
return 'Detection sources exposed no observations';
|
||||
}
|
||||
const control = await page.evaluate((density) => window.__godsEyeView.styleManager.setDetection({
|
||||
enabled: true,
|
||||
densityPct: density,
|
||||
}), densityPct);
|
||||
if (!control?.ok) return `Detection control failed (${control?.error || 'unknown error'})`;
|
||||
await sleep(2_000);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function prepareTrackedFlight(page, cockpit) {
|
||||
if (cockpit) {
|
||||
const contacts = await page.evaluate(() => window.__godsEyeView.styleManager.setContextMode(
|
||||
'contacts',
|
||||
{ origin: 'user' },
|
||||
));
|
||||
if (!contacts?.ok) {
|
||||
return `Contacts activation failed (${contacts?.error || 'unknown error'})`;
|
||||
}
|
||||
}
|
||||
const tracked = await page.evaluate(() => {
|
||||
const entry = window.__godsEyeView.dataManager.layers.get('flights');
|
||||
const layer = entry?.module;
|
||||
const candidates = layer?.getAllPositions?.(500) || [];
|
||||
const airborne = candidates.find((candidate) => Number(candidate.altitudeM) > 1_000) || candidates[0];
|
||||
if (!airborne) return { ok: false, reason: 'no rendered civil aircraft' };
|
||||
const ok = layer.trackById?.(airborne.id);
|
||||
return { ok: Boolean(ok), id: airborne.id, label: airborne.label, reason: ok ? null : 'trackById rejected candidate' };
|
||||
});
|
||||
if (!tracked.ok) return `Civil tracking unavailable (${tracked.reason})`;
|
||||
try {
|
||||
await page.waitForFunction(() => Boolean(window.__godsEyeView.viewer.trackedEntity?.position), { timeout: 10_000 });
|
||||
} catch {
|
||||
return 'Civil tracking did not create a tracked entity';
|
||||
}
|
||||
await sleep(2_000);
|
||||
if (!cockpit) return null;
|
||||
const entryAvailable = await page.evaluate(() => {
|
||||
const button = document.getElementById('cockpit-entry');
|
||||
if (!button || button.hidden || button.disabled) return false;
|
||||
button.click();
|
||||
return true;
|
||||
});
|
||||
if (!entryAvailable) return 'Cockpit entry control was unavailable for the tracked aircraft';
|
||||
try {
|
||||
await page.waitForFunction(() => document.body.classList.contains('cockpit-mode'), { timeout: 5_000 });
|
||||
} catch {
|
||||
return 'Cockpit entry control did not enter cockpit mode';
|
||||
}
|
||||
await sleep(2_000);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function prepareMission(page) {
|
||||
const state = await readLayerState(page, 'rocket-launches');
|
||||
if (!(state?.stats?.count > 0)) return `Mission feed unavailable (${state?.stats?.error || 'no missions'})`;
|
||||
const selected = await page.evaluate(() => {
|
||||
const button = document.querySelector('[data-mission-roster-index="0"]');
|
||||
if (!button) return false;
|
||||
button.click();
|
||||
return true;
|
||||
});
|
||||
if (!selected) return 'Mission roster did not expose a selectable mission';
|
||||
try {
|
||||
await page.waitForFunction(() => String(window.__godsEyeView.viewer.selectedEntity?.id || '').startsWith('rocket-launch:'), { timeout: 10_000 });
|
||||
} catch {
|
||||
return 'Mission selection did not reach the Cesium selected entity';
|
||||
}
|
||||
await sleep(2_000);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function prepareScene(page, scene) {
|
||||
if (scene.camera) await setCamera(page, scene.camera);
|
||||
const layerActivations = await activateLayers(page, scene.layers);
|
||||
let skipReason = null;
|
||||
if (scene.cctvHeightM) skipReason = await prepareCctv(page, scene.cctvHeightM);
|
||||
if (!skipReason && scene.id === 'firms') skipReason = await prepareFirms(page);
|
||||
if (!skipReason && scene.id === 'vessels') skipReason = await prepareVessels(page);
|
||||
if (!skipReason && scene.detectionDensity) skipReason = await prepareDetection(page, scene.detectionDensity);
|
||||
if (!skipReason && scene.trackedFlight) skipReason = await prepareTrackedFlight(page, scene.cockpit);
|
||||
if (!skipReason && scene.missionSelected) skipReason = await prepareMission(page);
|
||||
await sleep(DEFAULT_SETTLE_MS);
|
||||
return { layerActivations, skipReason };
|
||||
}
|
||||
|
||||
async function readCanvasSummary(page) {
|
||||
return page.evaluate(() => Array.from(document.querySelectorAll('canvas')).map((canvas) => {
|
||||
const style = getComputedStyle(canvas);
|
||||
return {
|
||||
id: canvas.id || '(anonymous-canvas)',
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
cssWidth: canvas.clientWidth,
|
||||
cssHeight: canvas.clientHeight,
|
||||
display: style.display,
|
||||
opacity: style.opacity,
|
||||
zIndex: style.zIndex,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
async function samplePhase(page, moving) {
|
||||
const raw = await page.evaluate(async ({ durationMs, movingCamera }) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const instrumentation = window.__overlayBaselineInstrumentation;
|
||||
instrumentation?.reset?.();
|
||||
const intervals = [];
|
||||
const startedAt = performance.now();
|
||||
let lastFrameAt = null;
|
||||
await new Promise((resolve) => {
|
||||
const step = (now) => {
|
||||
if (lastFrameAt != null) intervals.push(now - lastFrameAt);
|
||||
lastFrameAt = now;
|
||||
const elapsed = now - startedAt;
|
||||
if (movingCamera) {
|
||||
viewer.camera.rotateRight(0.00035);
|
||||
viewer.camera.rotateUp(0.00008 * Math.sin(elapsed / 600));
|
||||
}
|
||||
viewer.scene.requestRender?.();
|
||||
if (elapsed >= durationMs) resolve();
|
||||
else requestAnimationFrame(step);
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
});
|
||||
const canvasMetrics = instrumentation?.snapshot?.() || [];
|
||||
const detectionDiagnostics = window.__godsEyeView.styleManager?.getDetectionDiagnostics?.() || null;
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
const cctvEntry = manager.layers.get('cctv');
|
||||
const cctvState = cctvEntry?.initialized ? cctvEntry.module.getUIState?.() : null;
|
||||
const firmsEntry = manager.layers.get('local-firms');
|
||||
const vesselsEntry = manager.layers.get('ais-live-vessels');
|
||||
return {
|
||||
intervals,
|
||||
canvasMetrics,
|
||||
detectionDiagnostics,
|
||||
exposedCounts: {
|
||||
cctvOwnedEntries: cctvState?.ambientCards?.count ?? null,
|
||||
cctvEntryLimit: cctvState?.ambientCards?.limit ?? null,
|
||||
firmsSourceObjects: firmsEntry?.initialized ? firmsEntry.module.getStats?.()?.count ?? null : null,
|
||||
vesselSourceObjects: vesselsEntry?.initialized ? vesselsEntry.module.getStats?.()?.count ?? null : null,
|
||||
},
|
||||
elapsedMs: performance.now() - startedAt,
|
||||
};
|
||||
}, { durationMs: SAMPLE_MS, movingCamera: moving });
|
||||
|
||||
const canvases = raw.canvasMetrics
|
||||
.filter((metric) => KNOWN_OVERLAY_CANVASES.has(metric.id))
|
||||
.map((metric) => ({
|
||||
id: metric.id,
|
||||
calls: metric.calls,
|
||||
totalSyncMs: round(metric.totalSyncMs),
|
||||
maxSyncMs: round(metric.maxSyncMs),
|
||||
clearRectCalls: metric.methods.clearRect?.calls || 0,
|
||||
fillTextCalls: metric.methods.fillText?.calls || 0,
|
||||
drawImageCalls: metric.methods.drawImage?.calls || 0,
|
||||
measureTextCalls: metric.methods.measureText?.calls || 0,
|
||||
strokeCalls: metric.methods.stroke?.calls || 0,
|
||||
fillCalls: metric.methods.fill?.calls || 0,
|
||||
methods: Object.fromEntries(Object.entries(metric.methods).map(([name, value]) => [name, {
|
||||
calls: value.calls,
|
||||
totalSyncMs: round(value.totalSyncMs),
|
||||
}])),
|
||||
}))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
return {
|
||||
requestedDurationMs: SAMPLE_MS,
|
||||
actualDurationMs: round(raw.elapsedMs),
|
||||
frameTime: summarizeFrameIntervals(raw.intervals),
|
||||
canvases,
|
||||
detectionDiagnostics: raw.detectionDiagnostics,
|
||||
exposedCounts: raw.exposedCounts,
|
||||
};
|
||||
}
|
||||
|
||||
async function captureShot(page, shotsDir, sceneId, suffix) {
|
||||
if (!shotsDir) return null;
|
||||
fs.mkdirSync(shotsDir, { recursive: true });
|
||||
const safeScene = sceneId.replace(/[^a-z0-9-]+/gi, '-');
|
||||
const outPath = path.join(shotsDir, `${safeScene}-${suffix}.png`);
|
||||
await page.screenshot({ path: outPath });
|
||||
return outPath;
|
||||
}
|
||||
|
||||
async function runScene(browser, scene, shotsDir) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ ...VIEWPORT, deviceScaleFactor: DPR });
|
||||
await installStaticDist(page, DIST_DIR);
|
||||
await installDeterministicDevEndpoints(page);
|
||||
const consoleMessages = [];
|
||||
page.on('console', (message) => {
|
||||
if (['error', 'warning', 'warn'].includes(message.type())) {
|
||||
consoleMessages.push({ type: message.type(), text: message.text() });
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => consoleMessages.push({ type: 'pageerror', text: error.message }));
|
||||
await installPageInstrumentation(page);
|
||||
|
||||
const result = {
|
||||
scene: scene.id,
|
||||
status: 'ok',
|
||||
reason: null,
|
||||
layerActivations: [],
|
||||
entityInventory: null,
|
||||
canvasInventory: [],
|
||||
samples: null,
|
||||
screenshots: [],
|
||||
consoleMessages,
|
||||
};
|
||||
try {
|
||||
await waitForApp(page);
|
||||
const prepared = await prepareScene(page, scene);
|
||||
result.layerActivations = prepared.layerActivations;
|
||||
result.entityInventory = await readEntityInventory(page);
|
||||
result.canvasInventory = await readCanvasSummary(page);
|
||||
if (prepared.skipReason) {
|
||||
result.status = 'skipped';
|
||||
result.reason = prepared.skipReason;
|
||||
return result;
|
||||
}
|
||||
const beforeShot = await captureShot(page, shotsDir, scene.id, 'before-motion');
|
||||
if (beforeShot) result.screenshots.push(beforeShot);
|
||||
const motion = await samplePhase(page, true);
|
||||
const afterMotionShot = await captureShot(page, shotsDir, scene.id, 'after-motion');
|
||||
if (afterMotionShot) result.screenshots.push(afterMotionShot);
|
||||
await sleep(BETWEEN_PHASE_SETTLE_MS);
|
||||
const rest = await samplePhase(page, false);
|
||||
result.samples = { motion, rest };
|
||||
result.entityInventoryAfter = await readEntityInventory(page);
|
||||
return result;
|
||||
} catch (error) {
|
||||
result.status = 'error';
|
||||
result.reason = error?.stack || error?.message || String(error);
|
||||
return result;
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
const APP_URL = getOpt('--url', DEFAULT_URL);
|
||||
const DPR = Number(getOpt('--dpr', '1'));
|
||||
const JSON_PATH = getOpt('--json');
|
||||
const SCREENSHOTS_DIR = getOpt('--screenshots-dir');
|
||||
const DIST_DIR = getOpt('--dist-dir');
|
||||
const HEADFUL = hasFlag('--headful');
|
||||
const HARDWARE_GPU = hasFlag('--hardware-gpu');
|
||||
|
||||
function softwareRenderer(renderer) {
|
||||
return /swiftshader|software|llvmpipe/i.test(String(renderer || ''));
|
||||
}
|
||||
|
||||
function unavailableRenderer(renderer) {
|
||||
return !String(renderer || '').trim() || String(renderer).toLowerCase() === 'unavailable';
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(CHROME_EXECUTABLE)) {
|
||||
throw new Error(`Required Chrome executable not found: ${CHROME_EXECUTABLE}`);
|
||||
}
|
||||
if (!Number.isFinite(DPR) || DPR <= 0) throw new Error(`Invalid --dpr value: ${DPR}`);
|
||||
const scenes = selectedScenes();
|
||||
if (DIST_DIR && (!fs.existsSync(DIST_DIR) || !fs.statSync(DIST_DIR).isDirectory())) {
|
||||
throw new Error(`--dist-dir is not a directory: ${DIST_DIR}`);
|
||||
}
|
||||
if (!DIST_DIR) {
|
||||
const response = await fetch(APP_URL).catch((error) => ({ ok: false, statusText: error.message }));
|
||||
if (!response.ok) throw new Error(`Dev server unavailable at ${APP_URL}: ${response.status || response.statusText}`);
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: CHROME_EXECUTABLE,
|
||||
headless: HEADFUL ? false : 'new',
|
||||
args: [
|
||||
...(HARDWARE_GPU ? [] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']),
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
`--window-size=${VIEWPORT.width},${VIEWPORT.height}`,
|
||||
],
|
||||
});
|
||||
|
||||
const run = { generatedAt: new Date().toISOString(), context: null, scenes: [] };
|
||||
try {
|
||||
const contextPage = await browser.newPage();
|
||||
await contextPage.setViewport({ ...VIEWPORT, deviceScaleFactor: DPR });
|
||||
await installStaticDist(contextPage, DIST_DIR);
|
||||
await contextPage.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
const browserContext = await contextPage.evaluate(() => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
||||
const debug = gl?.getExtension('WEBGL_debug_renderer_info');
|
||||
return {
|
||||
userAgent: navigator.userAgent,
|
||||
dpr: window.devicePixelRatio,
|
||||
webglVendor: debug ? gl.getParameter(debug.UNMASKED_VENDOR_WEBGL) : gl?.getParameter(gl.VENDOR) || 'unavailable',
|
||||
webglRenderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : gl?.getParameter(gl.RENDERER) || 'unavailable',
|
||||
};
|
||||
});
|
||||
await contextPage.close();
|
||||
const cpus = os.cpus();
|
||||
if (HARDWARE_GPU && (
|
||||
unavailableRenderer(browserContext.webglRenderer)
|
||||
|| softwareRenderer(browserContext.webglRenderer)
|
||||
)) {
|
||||
throw new Error(`Hardware GPU requested but Chrome reported ${browserContext.webglRenderer}`);
|
||||
}
|
||||
run.context = {
|
||||
url: APP_URL,
|
||||
machine: {
|
||||
hostname: os.hostname(),
|
||||
platform: os.platform(),
|
||||
release: os.release(),
|
||||
arch: os.arch(),
|
||||
cpuModel: cpus[0]?.model || 'unknown',
|
||||
logicalCpuCount: cpus.length,
|
||||
totalMemoryGiB: round(os.totalmem() / (1024 ** 3), 1),
|
||||
},
|
||||
browser: {
|
||||
version: await browser.version(),
|
||||
userAgent: browserContext.userAgent,
|
||||
webglVendor: browserContext.webglVendor,
|
||||
webglRenderer: browserContext.webglRenderer,
|
||||
},
|
||||
viewport: { ...VIEWPORT, dpr: browserContext.dpr },
|
||||
gpuMode: HARDWARE_GPU ? 'hardware' : 'swiftshader',
|
||||
chromeExecutable: CHROME_EXECUTABLE,
|
||||
chromeArgs: HARDWARE_GPU
|
||||
? ['--no-sandbox']
|
||||
: ['--enable-unsafe-swiftshader', '--use-gl=swiftshader', '--no-sandbox'],
|
||||
sampleMs: SAMPLE_MS,
|
||||
captureMode: DIST_DIR ? `static-dist:${path.resolve(DIST_DIR)}` : 'dev-server',
|
||||
};
|
||||
printContext(run.context);
|
||||
|
||||
for (const scene of scenes) {
|
||||
const result = await runScene(browser, scene, SCREENSHOTS_DIR);
|
||||
run.scenes.push(result);
|
||||
printScene(result);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
run.summary = {
|
||||
ok: run.scenes.filter((scene) => scene.status === 'ok').length,
|
||||
skipped: run.scenes.filter((scene) => scene.status === 'skipped').length,
|
||||
errors: run.scenes.filter((scene) => scene.status === 'error').length,
|
||||
};
|
||||
console.log(`Summary: ${run.summary.ok} measured · ${run.summary.skipped} skipped · ${run.summary.errors} errors`);
|
||||
if (JSON_PATH) {
|
||||
fs.mkdirSync(path.dirname(path.resolve(JSON_PATH)), { recursive: true });
|
||||
fs.writeFileSync(JSON_PATH, `${JSON.stringify(run, null, 2)}\n`);
|
||||
console.log(`JSON: ${path.resolve(JSON_PATH)}`);
|
||||
}
|
||||
if (run.summary.errors > 0) process.exitCode = 1;
|
||||
if (HARDWARE_GPU && run.summary.ok === 0) {
|
||||
console.error('Hardware GPU run produced no measured scenes.');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error?.stack || error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,413 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-perf — render-governor regression gate (perf wave 2).
|
||||
*
|
||||
* Asserts the governor's observable contract with RELATIVE frame-count
|
||||
* assertions (SwiftShader-safe; no wall-clock GPU numbers):
|
||||
*
|
||||
* 1. Idle + zero layers + parked camera → the scene stops rendering
|
||||
* (near-zero postRender fires over a settle-then-observe window).
|
||||
* 2. A discrete mutation while idle (style slider write routed through
|
||||
* governorRequestRender) → at least one render, then settles again.
|
||||
* 3. Camera movement while idle → renders happen (Cesium-native path).
|
||||
* 4. Flights enabled → continuous mode (postRender cadence ≈ rAF cadence,
|
||||
* and ≥5× the idle count over the same window).
|
||||
* 5. Flights disabled again → back to idle (near-zero fires).
|
||||
* 6. Governor diagnostics agree with the mode at every step.
|
||||
*
|
||||
* ── WHAT THE IDLE WINDOWS MEASURE, AND WHY THEY WAIT ────────────────────────
|
||||
*
|
||||
* The idle checks (1 and 5) measure the STEADY-STATE floor: what a parked scene
|
||||
* costs once it has settled. They do not measure how long settling takes, so the
|
||||
* counted window has to begin AFTER settling — and "after" is a stronger
|
||||
* condition than "nothing is happening right now".
|
||||
*
|
||||
* That distinction became load-bearing when detection stopped holding the render
|
||||
* loop open (2026-08-22) and became a default. With a paint lane active, the
|
||||
* world-overlay host honours its occluder observers with frames: chrome that
|
||||
* changes SIZE resizes an occluder, `markLayoutDirty` fires, and that becomes a
|
||||
* `requestRender`. While detection forced continuous mode this was invisible.
|
||||
* Event-driven, it shows up as renders.
|
||||
*
|
||||
* The specific contaminant, pinned with stack traces on this tree, is triggered
|
||||
* by THIS HARNESS'S OWN SETUP. Disabling every layer publishes a `visibility`
|
||||
* change; the HUD subscribes to that and marks its semantic summary dirty
|
||||
* (`src/hud.js`), and the summary refreshes on a 15-SECOND interval
|
||||
* (`HUD_SUMMARY_INTERVAL_MS`). So up to 15 s after the teardown a tick fetches a
|
||||
* new summary and TYPES it in with a typewriter animation; the growing text
|
||||
* reflows `.hud-corner.hud-top-left`, an occluder, and the host turns that
|
||||
* reflow into a handful of frames. Two instrumented runs (150 s and 180 s
|
||||
* watches) agree to the second: the teardown lands at 15 s, the burst at 31 s —
|
||||
* seven frames in that one second — which is 16 s after the teardown and 4 s
|
||||
* INTO the window the old settle had already started counting. Neither run then
|
||||
* saw a single further render, across 146 s and 176 s respectively. It is a
|
||||
* one-shot: the tick that fetches also clears the summary's dirty flag and
|
||||
* commits its context signature, so every later tick returns early.
|
||||
*
|
||||
* Waiting for "an empty second" cannot survive that, because the scene really IS
|
||||
* empty for the ~15 s between the teardown and the tick that notices it. So the
|
||||
* settle below requires a quiet RUN LONGER THAN ONE FULL SUMMARY TICK, and that
|
||||
* run resets on any activity. Quiet then means something stronger and checkable:
|
||||
* a complete refresh period has elapsed in which the app looked at its new state
|
||||
* and found nothing to do. The length is derived from the app's own cadence
|
||||
* rather than tuned against a flake rate — if that interval changes, this moves
|
||||
* with it.
|
||||
*
|
||||
* The threshold inside the window (≤4 fires / 5 s) is untouched. The teeth live
|
||||
* in the WAIT, not the threshold: a scene rendering every frame never produces an
|
||||
* empty second at all, so the bounded wait expires and FAILS. Quiet that never
|
||||
* arrives IS the failure, which is why the timeout is an assertion rather than a
|
||||
* fall-through — and it is what keeps this honest if the churn ever stops being a
|
||||
* one-shot. It would: when `/api/openai/hud-summary` is unreachable the summary
|
||||
* stays dirty and re-types every 15 s, so that machine gets a loud repeatable
|
||||
* failure here rather than a coin flip.
|
||||
*
|
||||
* The underlying fix belongs to the host, not to this harness: an active lane
|
||||
* with nothing to place is not paint work and should not be honoured with a
|
||||
* frame. That is worldOverlay surgery — see the post-launch ledger entry
|
||||
* "world-overlay honours occluder churn as paint work" in
|
||||
* the performance contract in `docs/CURRENT-STATE.md`.
|
||||
*
|
||||
* Usage: node scripts/qa-perf.mjs [--url http://localhost:4173]
|
||||
* Requires a running dev server. Headless; flags disable occlusion
|
||||
* throttling so rAF cadence is trustworthy (hidden-pane gotcha).
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv;
|
||||
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4173';
|
||||
|
||||
const results = [];
|
||||
function check(name, pass, detail) {
|
||||
results.push({ name, pass });
|
||||
const tag = pass ? 'PASS' : 'FAIL';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${JSON.stringify(detail)}` : ''}`);
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
protocolTimeout: 300_000,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--window-size=1440,900',
|
||||
// Never let background/occlusion throttling freeze rAF or timers — the
|
||||
// measurements below depend on an honest frame clock.
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-background-timer-throttling',
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 860 });
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 });
|
||||
// Boot flyTo + tile warm + all deferred init.
|
||||
await new Promise((r) => setTimeout(r, 15_000));
|
||||
|
||||
// Park deterministically and disable every layer.
|
||||
await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
const v = gev.viewer;
|
||||
v.camera.cancelFlight();
|
||||
const ell = v.scene.globe.ellipsoid;
|
||||
v.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
longitude: -97.74 * Math.PI / 180, latitude: 30.27 * Math.PI / 180, height: 60_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
for (const [id, entry] of gev.dataManager.layers) {
|
||||
if (entry.enabled) { try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* gate reports via counts */ } }
|
||||
}
|
||||
});
|
||||
// Let tiles finish + fades settle + the settling frames drain.
|
||||
await new Promise((r) => setTimeout(r, 12_000));
|
||||
|
||||
/** Count scene postRender fires and rAF ticks over windowMs. */
|
||||
const countFrames = (windowMs) => page.evaluate((ms) => new Promise((resolve) => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
let renders = 0; let rafs = 0;
|
||||
const remove = scene.postRender.addEventListener(() => { renders += 1; });
|
||||
const t0 = performance.now();
|
||||
const tick = () => {
|
||||
rafs += 1;
|
||||
if (performance.now() - t0 < ms) requestAnimationFrame(tick);
|
||||
else { remove(); resolve({ renders, rafs }); }
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}), windowMs);
|
||||
|
||||
const diag = () => page.evaluate(() => window.__godsEyeView.getRenderGovernorDiagnostics?.()
|
||||
|| window.__gevRenderGovernor?.getDiagnostics?.() || null);
|
||||
|
||||
/**
|
||||
* The HUD's semantic summary refreshes on this cadence (`src/hud.js`,
|
||||
* `HUD_SUMMARY_INTERVAL_MS`). A layer-visibility change marks it dirty, and
|
||||
* the tick that picks that up types the new text in — reflowing an occluder
|
||||
* and, with a paint lane active, costing frames. Mirrored rather than
|
||||
* imported because this harness runs against the built app, not the module.
|
||||
*/
|
||||
const HUD_SUMMARY_INTERVAL_MS = 15_000;
|
||||
/** One full refresh period, plus a second of margin, in 1 s windows. */
|
||||
const QUIET_RUN_WINDOWS = Math.ceil(HUD_SUMMARY_INTERVAL_MS / 1_000) + 1;
|
||||
|
||||
/**
|
||||
* Wait until the scene is genuinely settled, and report whether it ever was.
|
||||
* This is the START of the idle windows below — see the header.
|
||||
*
|
||||
* Requires a CONSECUTIVE RUN of empty one-second windows at least one full
|
||||
* summary refresh period long, and resets that run on any activity. One empty
|
||||
* window — or three — proves nothing here: the scene is genuinely idle for the
|
||||
* whole gap between a teardown and the tick that notices it, so a short
|
||||
* confirmation happily reports "quiet" moments before the typewriter fires.
|
||||
* A run longer than the period cannot straddle that gap: it either contains
|
||||
* the burst, and restarts, or it postdates it.
|
||||
*
|
||||
* `quiet: false` is a FAILURE, not a fall-through: a scene rendering every
|
||||
* frame never produces an empty window at all, so this is what catches a real
|
||||
* hot loop — and equally a contaminant that has stopped being a one-shot. The
|
||||
* bound stays comfortably longer than settling takes and comfortably shorter
|
||||
* than forever: worst case is one interrupted run, the burst itself, then a
|
||||
* clean run (~55 s), against a 120 s ceiling.
|
||||
*/
|
||||
const settleUntilQuiet = async (maxMs = 120_000, requiredConsecutive = QUIET_RUN_WINDOWS) => {
|
||||
const deadline = Date.now() + maxMs;
|
||||
let windows = 0;
|
||||
let consecutive = 0;
|
||||
let busiest = 0;
|
||||
let restarts = 0;
|
||||
while (Date.now() < deadline) {
|
||||
windows += 1;
|
||||
const { renders } = await countFrames(1_000);
|
||||
busiest = Math.max(busiest, renders);
|
||||
if (renders === 0) consecutive += 1;
|
||||
else { if (consecutive > 0) restarts += 1; consecutive = 0; }
|
||||
if (consecutive >= requiredConsecutive) {
|
||||
return { quiet: true, windows, busiest, restarts, ranFor: consecutive, needRun: requiredConsecutive };
|
||||
}
|
||||
}
|
||||
// A failure here reads as "never held N empty seconds in a row, best run was
|
||||
// M, restarted R times, busiest window was B" — enough to tell a hot loop
|
||||
// from a contaminant that has stopped being a one-shot.
|
||||
return { quiet: false, windows, busiest, restarts, ranFor: consecutive, needRun: requiredConsecutive };
|
||||
};
|
||||
|
||||
// ── 1. idle: near-zero renders ────────────────────────────────────────
|
||||
const idleSettle = await settleUntilQuiet();
|
||||
const idle = await countFrames(5_000);
|
||||
const d1 = await diag();
|
||||
// Quiet that never arrives is the hot-loop failure — assert it, do not skip it.
|
||||
check('the parked scene holds a settled quiet run before the idle window is counted', idleSettle.quiet, idleSettle);
|
||||
check('governor reports idle mode with zero layers', d1?.mode === 'idle', d1);
|
||||
check('idle parked scene stops rendering (≤4 fires / 5s)', idle.renders <= 4, idle);
|
||||
|
||||
// ── 1b. detection at its FIRST-RUN DEFAULT must not hold the parked scene ─
|
||||
//
|
||||
// Detection became ON by default on 2026-08-22. It used to take an
|
||||
// unconditional continuous-render hold whenever it was on — nobody felt that
|
||||
// while it defaulted OFF, but as a DEFAULT it would pin every idle first-run
|
||||
// tab at 60 fps forever, defeating this governor outright. The hold is gone
|
||||
// (`src/data/detectionRenderDemand.js`): detection repaints on change and asks
|
||||
// for single frames only while a bounded animation is running.
|
||||
//
|
||||
// This is the gate for that. The scene above is parked with zero layers, so
|
||||
// detection-on and detection-off must yield the SAME near-zero render count.
|
||||
const detectionDefault = await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.getDetectionState?.() || null,
|
||||
);
|
||||
check(
|
||||
'precondition: detection is ON at its first-run default (Dense @ 75)',
|
||||
detectionDefault?.detectionMode === 'DENSE' && detectionDefault?.densityPct === 75,
|
||||
detectionDefault,
|
||||
);
|
||||
check(
|
||||
'detection ON takes NO continuous-render hold',
|
||||
d1?.mode === 'idle' && !d1.holds.includes('detection'),
|
||||
d1,
|
||||
);
|
||||
// The control: the same window with detection explicitly OFF.
|
||||
await page.evaluate(() => { window.__godsEyeView.styleManager._setDetectionMode('OFF'); });
|
||||
await new Promise((r) => setTimeout(r, 1_500)); // let any fade chain terminate
|
||||
const idleDetectOff = await countFrames(5_000);
|
||||
check('idle baseline with detection OFF (≤4 fires / 5s)', idleDetectOff.renders <= 4, idleDetectOff);
|
||||
// Back to the default. A regression here is the entire point of this gate: the
|
||||
// old hold produced a full 60 fps window instead of near-zero.
|
||||
await page.evaluate(() => { window.__godsEyeView.styleManager._setDetectionMode('DENSE'); });
|
||||
await new Promise((r) => setTimeout(r, 1_500));
|
||||
const idleDetectOn = await countFrames(5_000);
|
||||
const dDetect = await diag();
|
||||
check('idle parked scene with detection ON (≤4 fires / 5s)', idleDetectOn.renders <= 4, idleDetectOn);
|
||||
check(
|
||||
'detection ON costs no more idle frames than detection OFF',
|
||||
idleDetectOn.renders <= idleDetectOff.renders + 2,
|
||||
{ on: idleDetectOn.renders, off: idleDetectOff.renders },
|
||||
);
|
||||
check(
|
||||
'governor still idle with detection ON',
|
||||
dDetect?.mode === 'idle' && !dDetect.holds.includes('detection'),
|
||||
dDetect,
|
||||
);
|
||||
// …and the overlay must still be LIVE, not merely quiet: with detection on, a
|
||||
// camera nudge has to repaint promptly, or "idle" would only mean "stale".
|
||||
const detectMove = await Promise.all([
|
||||
countFrames(2_500),
|
||||
page.evaluate(() => new Promise((resolve) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
let steps = 0;
|
||||
const id = setInterval(() => {
|
||||
v.camera.moveForward(50);
|
||||
steps += 1;
|
||||
if (steps >= 20) { clearInterval(id); resolve(true); }
|
||||
}, 60);
|
||||
})),
|
||||
]).then(([frames]) => frames);
|
||||
check(
|
||||
'detection ON still repaints promptly on camera motion (≥10 / 2.5s)',
|
||||
detectMove.renders >= 10,
|
||||
detectMove,
|
||||
);
|
||||
// A render count alone proves the SCENE rendered, not that the detection
|
||||
// painter ran — a painter disabled outright would score a perfect idle and
|
||||
// sail through every check above. Count the painter's own frames across the
|
||||
// same kind of motion, so the teeth reach the thing this change touched.
|
||||
const detectPainted = await page.evaluate(() => new Promise((resolve) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const before = gev.styleManager.getDetectionDiagnostics?.()?.frameCount ?? null;
|
||||
let paints = 0;
|
||||
// The diagnostics object is rebuilt on every detection paint, so a fresh
|
||||
// identity is one painted frame. Sampling it per scene frame is enough to
|
||||
// tell "painting" from "silent" without reaching into module internals.
|
||||
let last = gev.styleManager.getDetectionDiagnostics?.();
|
||||
const remove = gev.viewer.scene.postRender.addEventListener(() => {
|
||||
const now = gev.styleManager.getDetectionDiagnostics?.();
|
||||
if (now && now !== last) { paints += 1; last = now; }
|
||||
});
|
||||
let steps = 0;
|
||||
const id = setInterval(() => {
|
||||
gev.viewer.camera.moveForward(50);
|
||||
steps += 1;
|
||||
if (steps >= 20) {
|
||||
clearInterval(id);
|
||||
setTimeout(() => {
|
||||
remove();
|
||||
resolve({ paints, before, after: gev.styleManager.getDetectionDiagnostics?.()?.frameCount ?? null });
|
||||
}, 400);
|
||||
}
|
||||
}, 60);
|
||||
}));
|
||||
check(
|
||||
'detection is still PAINTING, not merely quiet (≥5 painted frames on motion)',
|
||||
detectPainted.paints >= 5,
|
||||
detectPainted,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 3_000)); // settle back to parked
|
||||
|
||||
// ── 2. a REAL slider mutation renders (full UI → uniform → request path) ─
|
||||
const afterMutation = await Promise.all([
|
||||
countFrames(2_500),
|
||||
page.evaluate(() => new Promise((resolve) => setTimeout(() => {
|
||||
// Drive the actual sharpen slider: input event → handler →
|
||||
// _applySharpenIntensity → governorRequestRender. Proves the wiring,
|
||||
// not just the facade.
|
||||
const slider = document.getElementById('sharpen-intensity-slider');
|
||||
if (!slider) { resolve({ ok: false }); return; }
|
||||
slider.value = String(Math.min(100, Number(slider.value) + 7));
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
resolve({ ok: true });
|
||||
}, 500))),
|
||||
]).then(([frames]) => frames);
|
||||
check('real slider mutation while idle renders ≥1 and ≤10 frames', afterMutation.renders >= 1 && afterMutation.renders <= 10, afterMutation);
|
||||
|
||||
// ── 2b. animated style cycle: style-anim holds, then releases ─────────
|
||||
await page.evaluate(() => { window.__godsEyeView.styleManager.setStyle('retro'); });
|
||||
await new Promise((r) => setTimeout(r, 900)); // crossfade + first ticks
|
||||
const dAnim = await diag();
|
||||
check('animated style takes the style-anim hold (continuous)', dAnim?.mode === 'continuous' && dAnim.holds.includes('style-anim'), dAnim);
|
||||
const animFrames = await countFrames(2_000);
|
||||
check('animated style keeps frames flowing (≥50% rAF)', animFrames.renders >= animFrames.rafs * 0.5, animFrames);
|
||||
// Returning to normal keeps detection ON by design (it is the default, and
|
||||
// detection persists across style switches). Since 2026-08-22 detection holds
|
||||
// nothing, so this asserts the strictly harder thing: the scene returns to
|
||||
// idle with detection still ON — where before it could only go idle by also
|
||||
// turning detection off.
|
||||
await page.evaluate(() => { window.__godsEyeView.styleManager.setStyle('normal'); });
|
||||
await new Promise((r) => setTimeout(r, 1_500)); // fade out + loop self-stop
|
||||
const dAnimOff = await diag();
|
||||
const detectionStillOn = await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.getDetectionState?.()?.detectionMode || null,
|
||||
);
|
||||
check(
|
||||
'style-anim hold releases and the scene goes idle with detection still ON',
|
||||
dAnimOff?.mode === 'idle'
|
||||
&& !dAnimOff.holds.includes('style-anim')
|
||||
&& !dAnimOff.holds.includes('detection')
|
||||
&& detectionStillOn !== 'OFF',
|
||||
{ diag: dAnimOff, detection: detectionStillOn },
|
||||
);
|
||||
|
||||
// ── 2c. satellites holder enters and leaves diagnostics ───────────────
|
||||
await page.evaluate(async () => {
|
||||
await window.__godsEyeView.dataManager.setEnabled('satellites', true, { origin: 'user' });
|
||||
});
|
||||
const dSat = await diag();
|
||||
check('satellites enable registers its holder', dSat?.holds.includes('satellites'), dSat);
|
||||
await page.evaluate(async () => {
|
||||
await window.__godsEyeView.dataManager.setEnabled('satellites', false, { origin: 'user' });
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 2_000));
|
||||
const dSatOff = await diag();
|
||||
check('satellites disable releases its holder', !dSatOff?.holds.includes('satellites'), dSatOff);
|
||||
|
||||
// ── 3. camera movement renders (Cesium-native path) ───────────────────
|
||||
const duringMove = await Promise.all([
|
||||
countFrames(2_500),
|
||||
page.evaluate(() => new Promise((resolve) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
let steps = 0;
|
||||
const id = setInterval(() => {
|
||||
v.camera.moveForward(50);
|
||||
steps += 1;
|
||||
if (steps >= 20) { clearInterval(id); resolve(true); }
|
||||
}, 60);
|
||||
})),
|
||||
]).then(([frames]) => frames);
|
||||
check('camera movement while idle produces renders (≥10 / 2.5s)', duringMove.renders >= 10, duringMove);
|
||||
await new Promise((r) => setTimeout(r, 3_000)); // settle
|
||||
|
||||
// ── 4. flights enabled → continuous ───────────────────────────────────
|
||||
await page.evaluate(async () => {
|
||||
await window.__godsEyeView.dataManager.setEnabled('flights', true, { origin: 'user' });
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 5_000));
|
||||
const active = await countFrames(5_000);
|
||||
const d4 = await diag();
|
||||
check('governor reports continuous mode with flights on', d4?.mode === 'continuous', d4);
|
||||
check('flights-on cadence ≈ rAF cadence (≥70%)', active.renders >= active.rafs * 0.7, active);
|
||||
check('flights-on renders ≥5× idle renders', active.renders >= Math.max(1, idle.renders) * 5, { active: active.renders, idle: idle.renders });
|
||||
|
||||
// ── 5. flights disabled → idle again ──────────────────────────────────
|
||||
await page.evaluate(async () => {
|
||||
await window.__godsEyeView.dataManager.setEnabled('flights', false, { origin: 'user' });
|
||||
});
|
||||
// Deselect flows, fades, and the chrome churn the overlay host re-evaluates
|
||||
// its occluders against all have to drain first — and this teardown, like the
|
||||
// one in the setup above, marks the HUD summary dirty, so the same deferred
|
||||
// typewriter is still ahead of us. Same rule: hold a quiet run longer than one
|
||||
// refresh period before counting anything.
|
||||
const teardownSettle = await settleUntilQuiet();
|
||||
const idleAgain = await countFrames(5_000);
|
||||
const d5 = await diag();
|
||||
check('the scene holds a settled quiet run again before the second idle window', teardownSettle.quiet, teardownSettle);
|
||||
check('governor returns to idle after disable', d5?.mode === 'idle', d5);
|
||||
check('scene stops rendering again (≤4 fires / 5s)', idleAgain.renders <= 4, idleAgain);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const passed = results.filter((r) => r.pass).length;
|
||||
console.log(`\nqa-perf: ${passed}/${results.length} passed`);
|
||||
console.log(`RESULT: ${passed} passed, ${results.length - passed} failed, 0 skipped`);
|
||||
process.exit(passed === results.length ? 0 : 1);
|
||||
@@ -0,0 +1,578 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-sprites-b5.mjs — throwaway QA harness for skylight Tasks 8+9
|
||||
* (type-aware billboard silhouettes + per-class 2D/3D scale; Batch 5).
|
||||
*
|
||||
* Injects synthetic STRAIGHT-FLYING aircraft covering ALL 8 classifyAircraft
|
||||
* classes — the flights layer via OpenSky extended categories (state[17]),
|
||||
* the military layer via adsb.lol type codes — using the same fetch-shim +
|
||||
* history-priming pattern as scripts/qa-heading-b3.mjs (positions analytic at
|
||||
* serve time so every fix is self-consistent through the render delay).
|
||||
*
|
||||
* Machine assertions:
|
||||
* B1 ingest : both layers ingest the synthetic planes
|
||||
* B2 billboards : every fleet billboard carries EXACTLY the expected
|
||||
* per-class glyph (image data-URI === aircraftIcon(klass),
|
||||
* imported from src) and per-class scale (flights:
|
||||
* CLASS_SCALE_2D; military: 0.7 × CLASS_SCALE_2D)
|
||||
* B3 models : with 3D on, fleet Cesium.Model.scale === the layer's
|
||||
* MODEL_SCALE (both 1; legacy scale is baked into the GLB)
|
||||
* × CLASS_SCALE_3D[klass] per icao; tracked too
|
||||
* B4 clean : no console errors
|
||||
*
|
||||
* Visual output (qa-shots/b5/, untracked): top-down 2D spreads of both rows,
|
||||
* per-row close-ups, a tracked-cyan tint shot, and 3D cluster shots at two
|
||||
* zooms per layer showing per-class model scale differences. The PNGs are the
|
||||
* acceptance test — read them.
|
||||
*
|
||||
* Run: node scripts/qa-sprites-b5.mjs --url http://localhost:4300
|
||||
* Exits non-zero if any assertion fails. Never commits anything.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { classifyAircraft, CLASS_SCALE_2D, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
||||
// Hangar fleet (2026-08-16): real per-class GLBs render at scale 1; military
|
||||
// heavies (airliner/quadjet/glider) render airplane.glb at 1 x class; only
|
||||
// fastjet/unknown keep the per-layer jet/airplane MODEL_SCALE formula.
|
||||
const flightsWantScale = (klass) => (CLASS_MODEL_REAL[klass] ? 1 : FL_MODEL_SCALE * CLASS_SCALE_3D[klass]);
|
||||
const MIL_PLANE_CLASSES = new Set(['airliner', 'quadjet', 'glider']);
|
||||
const militaryWantScale = (klass) => (CLASS_MODEL_REAL[klass] ? 1
|
||||
: MIL_PLANE_CLASSES.has(klass) ? MIL_PLANE_MODEL_SCALE * CLASS_SCALE_3D[klass]
|
||||
: MIL_MODEL_SCALE * CLASS_SCALE_3D[klass]);
|
||||
import { aircraftIcon } from '../src/data/aircraftIcons.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Args (same shape as qa-heading-b3.mjs)
|
||||
// ---------------------------------------------------------------------------
|
||||
const argv = process.argv.slice(2);
|
||||
const getFlag = (name) => argv.includes(name);
|
||||
const getOpt = (name, dflt) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const SHOT_DIR = path.resolve('qa-shots/b5');
|
||||
|
||||
const FL_MODEL_SCALE = 1; // flights.js (airplane.glb is transform-applied and meter-scale)
|
||||
const MIL_PLANE_MODEL_SCALE = 1; // military heavy classes share the same baked airplane.glb
|
||||
const MIL_MODEL_SCALE = 1; // militaryFlights.js (jet.glb is already real-world scale, native radius ~29.8 m)
|
||||
const MIL_BILLBOARD_SCALE = 0.7; // militaryFlights BILLBOARD_SCALE
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
|
||||
try { if (fs.existsSync(candidate)) return candidate; } catch { /* skip */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Synthetic straight-flying aircraft.
|
||||
// Rows (2D spread): flights row at lat 30.32 (6 category-driven classes),
|
||||
// military row at lat 30.28 (all 8 classes via type codes). Clusters (3D
|
||||
// scale comparison): tight 130–150 m spacing so the follow-camera frames
|
||||
// several models at once below the minimumPixelSize clamp distance.
|
||||
// courseDeg 0 = flying north (glyph nose-up in a north-up top-down view);
|
||||
// two planes fly rotated courses to confirm sprites still follow the path.
|
||||
// ---------------------------------------------------------------------------
|
||||
const ROW_LON0 = -97.82;
|
||||
const ROW_STEP_DEG = 0.012; // ~1.15 km — full military row (8 planes) fits the unobstructed frame center
|
||||
const CLUSTER_LON0 = -97.7431;
|
||||
const mDegLon = (m, lat) => m / (111320 * Math.cos((lat * Math.PI) / 180));
|
||||
|
||||
const SPRITES = {
|
||||
timeOffsetSec: 0,
|
||||
// OpenSky extended categories: 2 light, 4 large airliner, 6 heavy/widebody,
|
||||
// 7 high-perf fastjet, 8 rotorcraft, 9 glider. (quadjet/turboprop need type
|
||||
// codes, which OpenSky lacks — the military row covers those.)
|
||||
flights: [
|
||||
{ icao: 'caa001', callsign: 'FL-LGT', lon0: ROW_LON0 + 0 * ROW_STEP_DEG, lat0: 30.32, courseDeg: 0, speedMps: 80, altM: 2500, category: 2 },
|
||||
{ icao: 'caa002', callsign: 'FL-AIR', lon0: ROW_LON0 + 1 * ROW_STEP_DEG, lat0: 30.32, courseDeg: 0, speedMps: 80, altM: 2600, category: 4 },
|
||||
{ icao: 'caa003', callsign: 'FL-WID', lon0: ROW_LON0 + 2 * ROW_STEP_DEG, lat0: 30.32, courseDeg: 0, speedMps: 80, altM: 2700, category: 6 },
|
||||
{ icao: 'caa004', callsign: 'FL-FJ', lon0: ROW_LON0 + 3 * ROW_STEP_DEG, lat0: 30.32, courseDeg: 90, speedMps: 80, altM: 2800, category: 7 },
|
||||
{ icao: 'caa005', callsign: 'FL-HEL', lon0: ROW_LON0 + 4 * ROW_STEP_DEG, lat0: 30.32, courseDeg: 0, speedMps: 40, altM: 1200, category: 8 },
|
||||
{ icao: 'caa006', callsign: 'FL-GLD', lon0: ROW_LON0 + 5 * ROW_STEP_DEG, lat0: 30.32, courseDeg: 0, speedMps: 40, altM: 2000, category: 9 },
|
||||
// 3D cluster (150 m spacing): widebody vs airliner (tracked) vs fastjet
|
||||
{ icao: 'cbb001', callsign: '3D-WID', lon0: CLUSTER_LON0 - mDegLon(150, 30.10), lat0: 30.10, courseDeg: 0, speedMps: 60, altM: 3000, category: 6 },
|
||||
{ icao: 'cbb002', callsign: '3D-AIR', lon0: CLUSTER_LON0, lat0: 30.10, courseDeg: 0, speedMps: 60, altM: 3000, category: 4 },
|
||||
{ icao: 'cbb003', callsign: '3D-FJ', lon0: CLUSTER_LON0 + mDegLon(150, 30.10), lat0: 30.10, courseDeg: 0, speedMps: 60, altM: 3000, category: 7 },
|
||||
],
|
||||
military: [
|
||||
{ hex: 'dda001', flight: 'ML-LGT', t: 'C172', lon0: ROW_LON0 + 0 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 60, altFt: 9000 },
|
||||
{ hex: 'dda002', flight: 'ML-GLD', t: 'DISC', lon0: ROW_LON0 + 1 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 40, altFt: 8000 },
|
||||
{ hex: 'dda003', flight: 'ML-TPR', t: 'C130', lon0: ROW_LON0 + 2 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 10000 },
|
||||
{ hex: 'dda004', flight: 'ML-AIR', t: 'A320', lon0: ROW_LON0 + 3 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 11000 },
|
||||
{ hex: 'dda005', flight: 'ML-WID', t: 'C17', lon0: ROW_LON0 + 4 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 12000 },
|
||||
{ hex: 'dda006', flight: 'ML-QUAD', t: 'B744', lon0: ROW_LON0 + 5 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 13000 },
|
||||
{ hex: 'dda007', flight: 'ML-HEL', t: 'H60', lon0: ROW_LON0 + 6 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 40, altFt: 3000 },
|
||||
// course 0 like the rest — keeps the row a clean size-comparison line (2D
|
||||
// rotation is demoed by caa004 and gated by qa-heading-b3 anyway).
|
||||
{ hex: 'dda008', flight: 'ML-FJ', t: 'F16', lon0: ROW_LON0 + 7 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 15000 },
|
||||
// 3D cluster: C17 vs F16 (tracked) vs B744 vs C130. jet.glb is a
|
||||
// real-world-scale asset (native bounding radius ~29.8) rendered at
|
||||
// MODEL_SCALE 1, so models are ~22–43 m radius — same 150 m spacing and
|
||||
// 450–900 m shot ranges as the flights cluster.
|
||||
{ hex: 'ddb001', flight: '3DMWID', t: 'C17', lon0: CLUSTER_LON0 - mDegLon(150, 30.05), lat0: 30.05, courseDeg: 0, speedMps: 60, altFt: 12000 },
|
||||
{ hex: 'ddb002', flight: '3DMFJ', t: 'F16', lon0: CLUSTER_LON0, lat0: 30.05, courseDeg: 0, speedMps: 60, altFt: 12000 },
|
||||
{ hex: 'ddb003', flight: '3DMQUA', t: 'B744', lon0: CLUSTER_LON0 + mDegLon(150, 30.05), lat0: 30.05, courseDeg: 0, speedMps: 60, altFt: 12000 },
|
||||
{ hex: 'ddb004', flight: '3DMTPR', t: 'C130', lon0: CLUSTER_LON0 - mDegLon(300, 30.05), lat0: 30.05, courseDeg: 0, speedMps: 60, altFt: 12000 },
|
||||
],
|
||||
};
|
||||
|
||||
// Node-side expected classification (same inputs the layers see).
|
||||
const expectedFlights = new Map(SPRITES.flights.map((p) => [p.icao, classifyAircraft({ category: p.category })]));
|
||||
const expectedMilitary = new Map(SPRITES.military.map((p) => [p.hex, classifyAircraft({ typeCode: p.t })]));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
async function main() {
|
||||
console.log(`\nType-aware sprite QA (Batch 5 / skylight Tasks 8+9)`);
|
||||
console.log(` App URL : ${APP_URL}\n`);
|
||||
console.log(` Expected classes — flights: ${[...expectedFlights.entries()].map(([k, v]) => `${k}:${v}`).join(' ')}`);
|
||||
console.log(` Expected classes — military: ${[...expectedMilitary.entries()].map(([k, v]) => `${k}:${v}`).join(' ')}\n`);
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL, { method: 'GET' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
||||
|
||||
const chromeExecutable = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
],
|
||||
});
|
||||
|
||||
const consoleErrors = [];
|
||||
const httpErrors = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
const text = msg.text();
|
||||
if (!/Failed to load resource.*404/i.test(text)) consoleErrors.push(text);
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (err) => consoleErrors.push(`pageerror: ${err.message}`));
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 500) httpErrors.push(`${response.status()} ${response.url()}`);
|
||||
});
|
||||
|
||||
// ---- Straight-flight fetch shim (installed before any app code runs) ----
|
||||
await page.evaluateOnNewDocument((spec) => {
|
||||
window.__SPR = spec;
|
||||
window.__SPR.epochMs = Date.now();
|
||||
window.__SPR_HITS = { opensky: 0, mil: 0 };
|
||||
|
||||
// Straight line from (lon0, lat0) along courseDeg at speedMps.
|
||||
window.__SPR.stateAt = (p, tSec) => {
|
||||
const cRad = (p.courseDeg * Math.PI) / 180;
|
||||
const dist = p.speedMps * tSec;
|
||||
const east = dist * Math.sin(cRad);
|
||||
const north = dist * Math.cos(cRad);
|
||||
const lat = p.lat0 + north / 111320;
|
||||
const lon = p.lon0 + east / (111320 * Math.cos((p.lat0 * Math.PI) / 180));
|
||||
return { lon, lat, course: p.courseDeg, speedMps: p.speedMps };
|
||||
};
|
||||
|
||||
const realFetch = window.fetch.bind(window);
|
||||
const jsonResponse = (obj) => new Response(JSON.stringify(obj), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
window.fetch = (input, init) => {
|
||||
const url = typeof input === 'string' ? input : (input && input.url) || '';
|
||||
const S = window.__SPR;
|
||||
if (url.includes('/api/openai/hud-summary')) {
|
||||
return Promise.resolve(jsonResponse({ summary: 'Aircraft scale QA' }));
|
||||
}
|
||||
const nowSec = Date.now() / 1000 + (S.timeOffsetSec || 0);
|
||||
const tRel = nowSec - S.epochMs / 1000;
|
||||
|
||||
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] }));
|
||||
if (url.includes('/api/adsblol/trace')) {
|
||||
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] }));
|
||||
}
|
||||
if (url.includes('/api/opensky')) {
|
||||
window.__SPR_HITS.opensky++;
|
||||
const states = S.flights.map((f) => {
|
||||
const s = S.stateAt(f, tRel);
|
||||
return [
|
||||
f.icao, f.callsign, 'Synthetica',
|
||||
Math.floor(nowSec), Math.floor(nowSec),
|
||||
s.lon, s.lat, f.altM,
|
||||
false, // on_ground
|
||||
s.speedMps, s.course,
|
||||
0, null, null, null, false, 0,
|
||||
f.category, // state[17] — extended emitter category
|
||||
];
|
||||
});
|
||||
return Promise.resolve(jsonResponse({ time: Math.floor(nowSec), states }));
|
||||
}
|
||||
if (url.includes('/api/adsblol/mil')) {
|
||||
window.__SPR_HITS.mil++;
|
||||
const ac = S.military.map((m) => {
|
||||
const s = S.stateAt(m, tRel);
|
||||
return {
|
||||
hex: m.hex, flight: m.flight,
|
||||
lon: s.lon, lat: s.lat, alt_baro: m.altFt,
|
||||
track: s.course, gs: s.speedMps * 1.9438,
|
||||
t: m.t, r: `SY-${m.hex.slice(-3)}`, ownOp: 'SYNTH AF',
|
||||
seen_pos: Math.max(0, -(S.timeOffsetSec || 0)),
|
||||
};
|
||||
});
|
||||
return Promise.resolve(jsonResponse({ msg: 'No error', now: Date.now(), ac }));
|
||||
}
|
||||
// Synthetic categories/types already exercise every classification
|
||||
// branch. ADSBDB enrichment is incidental to this visual-scale check,
|
||||
// so keep an unavailable public enrichment provider from generating an
|
||||
// unrelated console error.
|
||||
if (url.includes('/api/adsbdb/')) return Promise.resolve(jsonResponse({ found: false }));
|
||||
return realFetch(input, init);
|
||||
};
|
||||
}, SPRITES);
|
||||
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
|
||||
{ timeout: 60000, polling: 200 }
|
||||
);
|
||||
console.log(' App globals ready.');
|
||||
|
||||
// ---- In-page probes: billboard walk, model walk, tracked-model finder ---
|
||||
await page.evaluate(() => {
|
||||
// Every billboard (image + alignedAxis + id) in every collection.
|
||||
window.__collectBillboards = function () {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let p;
|
||||
try { p = coll.get(i); } catch { continue; }
|
||||
if (!p) continue;
|
||||
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
|
||||
if (p.image !== undefined && p.alignedAxis !== undefined) {
|
||||
out.push({ id: p.id, image: p.image, scale: p.scale, show: p.show });
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(v.scene.primitives);
|
||||
return out;
|
||||
};
|
||||
// Every glTF model primitive (modelMatrix + ready flag), with id + scale.
|
||||
window.__collectModels = function () {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let p;
|
||||
try { p = coll.get(i); } catch { continue; }
|
||||
if (!p) continue;
|
||||
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
|
||||
if (p.modelMatrix && typeof p.ready !== 'undefined' && p.image === undefined) {
|
||||
let radius = null;
|
||||
try { radius = p.ready && p.boundingSphere ? p.boundingSphere.radius : null; } catch { /* not ready */ }
|
||||
out.push({ id: p.id, scale: p.scale, ready: p.ready, show: p.show, radius });
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(v.scene.primitives);
|
||||
return out;
|
||||
};
|
||||
// Displayed (render-delayed) latitude of a synthetic plane right now.
|
||||
window.__displayedState = function (layer, idx, renderDelaySec) {
|
||||
const S = window.__SPR;
|
||||
const p = S[layer][idx];
|
||||
const tRel = (Date.now() - S.epochMs) / 1000 - renderDelaySec;
|
||||
return S.stateAt(p, tRel);
|
||||
};
|
||||
// Canvas coordinates of every synthetic plane at its displayed position —
|
||||
// ground truth for identifying which glyph is which in a screenshot.
|
||||
window.__planeCanvasCoords = function (layer, renderDelaySec, altOf) {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const S = window.__SPR;
|
||||
const C3 = v.camera.position.constructor;
|
||||
return S[layer].map((p, i) => {
|
||||
const s = window.__displayedState(layer, i, renderDelaySec);
|
||||
const pos = C3.fromDegrees(s.lon, s.lat, altOf === 'ft' ? p.altFt * 0.3048 : p.altM);
|
||||
const c = v.scene.cartesianToCanvasCoordinates(pos);
|
||||
return `${p.icao || p.hex}=(${c ? Math.round(c.x) : '-'},${c ? Math.round(c.y) : '-'})`;
|
||||
}).join(' ');
|
||||
};
|
||||
});
|
||||
|
||||
// ---- Prime history through the render delay (same pattern as b3) -------
|
||||
console.log('Priming straight-flight history through the render delay (30 s / 15 s)...');
|
||||
const primed = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const v = window.__godsEyeView.viewer;
|
||||
window.__SPR.timeOffsetSec = -32;
|
||||
await dm.setEnabled('flights', true);
|
||||
await dm.setEnabled('military', true);
|
||||
const fl = dm.layers.get('flights').module;
|
||||
const mil = dm.layers.get('military').module;
|
||||
for (const off of [-24, -16, -8, 0]) {
|
||||
window.__SPR.timeOffsetSec = off;
|
||||
await fl.update(v);
|
||||
await mil.update(v);
|
||||
}
|
||||
window.__SPR.timeOffsetSec = 0;
|
||||
window.__SPR_DRIVER = setInterval(() => { fl.update(v); mil.update(v); }, 15000);
|
||||
return { fl: fl.getStats().count, mil: mil.getStats().count, hits: window.__SPR_HITS };
|
||||
});
|
||||
console.log(` flights count=${primed.fl} military count=${primed.mil} | shim hits opensky=${primed.hits.opensky} mil=${primed.hits.mil}`);
|
||||
record('B1 ingest: synthetic planes in both layers',
|
||||
primed.fl === SPRITES.flights.length && primed.mil === SPRITES.military.length,
|
||||
`flights=${primed.fl}/${SPRITES.flights.length} military=${primed.mil}/${SPRITES.military.length}`);
|
||||
if (!(primed.fl > 0 && primed.mil > 0)) { finish(); return; }
|
||||
|
||||
// ========================================================================
|
||||
// B2 — billboard glyph + scale per class (machine check)
|
||||
// ========================================================================
|
||||
console.log('\nB2 — per-class billboard glyph + scale');
|
||||
const bbs = await page.evaluate(() => window.__collectBillboards());
|
||||
const bbById = new Map(bbs.map((b) => [b.id, b]));
|
||||
let glyphBad = [];
|
||||
let scaleBad = [];
|
||||
for (const [id, klass] of expectedFlights) {
|
||||
const bb = bbById.get(id);
|
||||
const wantImg = aircraftIcon(klass);
|
||||
const wantScale = CLASS_SCALE_2D[klass] || 1;
|
||||
if (!bb || bb.image !== wantImg) glyphBad.push(`${id}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||
if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${id}: ${bb.scale} != ${wantScale}`);
|
||||
}
|
||||
for (const [id, klass] of expectedMilitary) {
|
||||
const bb = bbById.get(id);
|
||||
const wantImg = aircraftIcon(klass);
|
||||
const wantScale = MIL_BILLBOARD_SCALE * (CLASS_SCALE_2D[klass] || 1);
|
||||
if (!bb || bb.image !== wantImg) glyphBad.push(`${id}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||
if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${id}: ${bb.scale} != ${wantScale}`);
|
||||
}
|
||||
record('B2a: every synthetic billboard has its class glyph (data-URI match)',
|
||||
glyphBad.length === 0, glyphBad.length ? glyphBad.join(' ') : `${expectedFlights.size + expectedMilitary.size} billboards matched`);
|
||||
record('B2b: every synthetic billboard has its class scale',
|
||||
scaleBad.length === 0, scaleBad.length ? scaleBad.join(' ') : 'all scales exact');
|
||||
const distinctGlyphs = new Set([...expectedFlights.values(), ...expectedMilitary.values()].map((k) => aircraftIcon(k)));
|
||||
record('B2c: all 8 classes exercised with 8 distinct glyphs',
|
||||
distinctGlyphs.size === 8, `${distinctGlyphs.size} distinct data URIs`);
|
||||
|
||||
// ========================================================================
|
||||
// 2D screenshots
|
||||
// ========================================================================
|
||||
console.log('\n2D screenshots → qa-shots/b5/');
|
||||
const topDown = async (lat, lon, height) => {
|
||||
await page.evaluate(({ lat, lon, height }) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
v.camera.cancelFlight(); // the boot fly-to-Austin otherwise stomps setView
|
||||
const C3 = v.camera.position.constructor;
|
||||
v.camera.setView({
|
||||
destination: C3.fromDegrees(lon, lat, height),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
}, { lat, lon, height });
|
||||
await sleep(1800); // let fleet tick land positions + rotations at the new pose
|
||||
};
|
||||
// Zoom the follow-camera to an absolute range from the tracked entity.
|
||||
// In tracked mode camera.position lives in the ENTITY's reference frame, so
|
||||
// its magnitude IS the range — fixed zoomIn() amounts compound across phases
|
||||
// (the second tracked plane inherits the previous phase's close-in offset).
|
||||
const zoomToRange = async (want) => {
|
||||
await page.evaluate((want) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const p = v.camera.position;
|
||||
const d = Math.hypot(p.x, p.y, p.z);
|
||||
if (d > 1e6) return; // not in a tracked reference frame — leave it alone
|
||||
v.camera.zoomIn(d - want);
|
||||
}, want);
|
||||
await sleep(1500);
|
||||
};
|
||||
// Rows drift north while flying; recenter on the DISPLAYED (render-delayed) row.
|
||||
const rowLat = async (layer, idx, delay) =>
|
||||
(await page.evaluate(({ layer, idx, delay }) => window.__displayedState(layer, idx, delay), { layer, idx, delay })).lat;
|
||||
|
||||
const flRowLat = await rowLat('flights', 2, 30);
|
||||
const milRowLat = await rowLat('military', 4, 15);
|
||||
await topDown((flRowLat + milRowLat) / 2, ROW_LON0 + 3 * ROW_STEP_DEG, 16000);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-spread-bothrows.png') });
|
||||
console.log(' saved b5-2d-spread-bothrows.png (flights row top, military row bottom)');
|
||||
|
||||
// Row widths: military spans 7 steps (~8.1 km) → frame ~13.9 km at 12 km up;
|
||||
// flights spans 5 steps (~5.8 km, +caa004's eastward drift) → ~10.4 km at 9 km up.
|
||||
await topDown(await rowLat('military', 4, 15), ROW_LON0 + 3.5 * ROW_STEP_DEG, 17000);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-military-row.png') });
|
||||
console.log(' saved b5-2d-military-row.png (8 classes, amber)');
|
||||
console.log(` glyph positions: ${await page.evaluate(() => window.__planeCanvasCoords('military', 15, 'ft'))}`);
|
||||
|
||||
await topDown(await rowLat('flights', 2, 30), ROW_LON0 + 2.5 * ROW_STEP_DEG, 12000);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-flights-row.png') });
|
||||
console.log(' saved b5-2d-flights-row.png (6 category classes, white)');
|
||||
console.log(` glyph positions: ${await page.evaluate(() => window.__planeCanvasCoords('flights', 30, 'm'))}`);
|
||||
|
||||
// Tracked tint: commercial tracked plane goes cyan; neighbors stay white.
|
||||
await page.evaluate((icao) => {
|
||||
window.__godsEyeView.dataManager.layers.get('flights').module.trackById(icao);
|
||||
}, 'caa002');
|
||||
await sleep(3000);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-tracked-cyan.png') });
|
||||
console.log(' saved b5-2d-tracked-cyan.png (tracked airliner cyan, follow camera)');
|
||||
|
||||
// ========================================================================
|
||||
// B3 + 3D screenshots — flights cluster
|
||||
// ========================================================================
|
||||
console.log('\n3D — flights cluster (airplane.glb, per-class scale)');
|
||||
await page.evaluate(() => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
dm.layers.get('flights').module.setParams({ models3d: true });
|
||||
dm.layers.get('flights').module.trackById('cbb002');
|
||||
});
|
||||
const flModelsUp = await page.waitForFunction(() => {
|
||||
const models = window.__collectModels().filter((m) => m.ready && m.show);
|
||||
const ids = new Set(models.map((m) => m.id));
|
||||
return ids.has('cbb001') && ids.has('cbb002') && ids.has('cbb003');
|
||||
}, { timeout: 40000, polling: 400 }).then(() => true).catch(() => false);
|
||||
record('3D flights: tracked model + 2 fleet cluster models rendered', flModelsUp, flModelsUp ? 'all up' : 'timed out');
|
||||
|
||||
if (flModelsUp) {
|
||||
const flModels = await page.evaluate(() => window.__collectModels().filter((m) => m.ready));
|
||||
const modelScaleBad = [];
|
||||
for (const [id, want] of [
|
||||
['cbb001', flightsWantScale(expectedFlights.get('cbb001'))],
|
||||
['cbb003', flightsWantScale(expectedFlights.get('cbb003'))],
|
||||
]) {
|
||||
const m = flModels.find((x) => x.id === id);
|
||||
if (!m || Math.abs(m.scale - want) > 1e-9) modelScaleBad.push(`${id}: ${m ? m.scale : 'missing'} != ${want}`);
|
||||
}
|
||||
const trackedM = flModels.find((x) => x.id === 'cbb002');
|
||||
const trackedWant = flightsWantScale(expectedFlights.get('cbb002'));
|
||||
if (!trackedM || Math.abs(trackedM.scale - trackedWant) > 1e-9) {
|
||||
modelScaleBad.push(`tracked: ${trackedM ? trackedM.scale : 'missing'} != ${trackedWant}`);
|
||||
}
|
||||
record('B3a flights: model scales match the per-class registry (all GLBs meter-scale; shared @1×class)',
|
||||
modelScaleBad.length === 0,
|
||||
modelScaleBad.length ? modelScaleBad.join(' ') : 'all exact per registry');
|
||||
console.log(` world bounding radii (m): ${flModels.filter((m) => m.scale !== undefined).map((m) => `${m.id ?? 'tracked'}=${m.radius?.toFixed(1)}`).join(' ')}`);
|
||||
|
||||
await sleep(1500);
|
||||
await zoomToRange(900);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-flights-mid.png') });
|
||||
console.log(' saved b5-3d-flights-mid.png (900 m range — near the min-pixel clamp)');
|
||||
await zoomToRange(450);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-flights-close.png') });
|
||||
console.log(' saved b5-3d-flights-close.png (450 m range: widebody left, airliner center, fastjet right)');
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// B3 + 3D screenshots — military cluster
|
||||
// ========================================================================
|
||||
console.log('\n3D — military cluster (jet.glb, per-class scale)');
|
||||
await page.evaluate(() => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
dm.layers.get('military').module.setParams({ models3d: true });
|
||||
dm.layers.get('military').module.trackById('ddb002');
|
||||
});
|
||||
const milModelsUp = await page.waitForFunction(() => {
|
||||
const models = window.__collectModels().filter((m) => m.ready && m.show);
|
||||
const ids = new Set(models.map((m) => m.id));
|
||||
return ids.has('ddb001') && ids.has('ddb002') && ids.has('ddb003') && ids.has('ddb004');
|
||||
}, { timeout: 40000, polling: 400 }).then(() => true).catch(() => false);
|
||||
record('3D military: tracked model + 3 fleet cluster models rendered', milModelsUp, milModelsUp ? 'all up' : 'timed out');
|
||||
|
||||
if (milModelsUp) {
|
||||
const milModels = await page.evaluate(() => window.__collectModels().filter((m) => m.ready));
|
||||
const modelScaleBad = [];
|
||||
for (const [id, want] of [
|
||||
['ddb001', militaryWantScale(expectedMilitary.get('ddb001'))],
|
||||
['ddb003', militaryWantScale(expectedMilitary.get('ddb003'))],
|
||||
['ddb004', militaryWantScale(expectedMilitary.get('ddb004'))],
|
||||
]) {
|
||||
const m = milModels.find((x) => x.id === id);
|
||||
if (!m || Math.abs(m.scale - want) > 1e-9) modelScaleBad.push(`${id}: ${m ? m.scale : 'missing'} != ${want}`);
|
||||
}
|
||||
const trackedM = milModels.find((x) => x.id === 'ddb002');
|
||||
const trackedWant = militaryWantScale(expectedMilitary.get('ddb002'));
|
||||
if (!trackedM || Math.abs(trackedM.scale - trackedWant) > 1e-9) {
|
||||
modelScaleBad.push(`tracked: ${trackedM ? trackedM.scale : 'missing'} != ${trackedWant}`);
|
||||
}
|
||||
record('B3b military: model scales match the weight-class registry (real/heavy/jet assets @1×class)',
|
||||
modelScaleBad.length === 0,
|
||||
modelScaleBad.length ? modelScaleBad.join(' ') : 'all exact per registry');
|
||||
console.log(` world bounding radii (m): ${milModels.filter((m) => m.scale !== undefined).map((m) => `${m.id ?? 'tracked'}=${m.radius?.toFixed(1)}`).join(' ')}`);
|
||||
|
||||
await sleep(1500);
|
||||
// jet.glb at MODEL_SCALE 1 is real-world size — same shot ranges as the
|
||||
// flights cluster (close zoom no longer puts the camera inside a model).
|
||||
await zoomToRange(900);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-military-far.png') });
|
||||
console.log(' saved b5-3d-military-far.png (900 m range: C130/C17 left, F16 center, B744 right)');
|
||||
await zoomToRange(450);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-military-close.png') });
|
||||
console.log(' saved b5-3d-military-close.png (450 m range: C17 left, F16 center, B744 right)');
|
||||
}
|
||||
|
||||
record('B4: no console errors during QA run', consoleErrors.length === 0,
|
||||
consoleErrors.length
|
||||
? `${consoleErrors.length}: ${consoleErrors.slice(0, 3).join(' | ')}; HTTP: ${httpErrors.slice(0, 8).join(' | ') || 'none observed'}`
|
||||
: 'clean');
|
||||
|
||||
finish();
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
function finish() {
|
||||
const failed = results.filter((r) => r.ok === false);
|
||||
const passed = results.filter((r) => r.ok === true);
|
||||
console.log(`\n${'─'.repeat(60)}`);
|
||||
console.log(` RESULT: ${passed.length} passed, ${failed.length} failed`);
|
||||
console.log(`${'─'.repeat(60)}\n`);
|
||||
process.exitCode = failed.length > 0 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('\n\x1b[31mQA harness error:\x1b[0m', err && err.stack ? err.stack : err);
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -0,0 +1,477 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-traffic-baseline.mjs — Traffic Phase 0 causal-chain capture.
|
||||
*
|
||||
* Drives four client/cache conditions and prints the User Timing segments
|
||||
* emitted by `src/data/traffic.js` when `?trafficDebug=1` is present:
|
||||
*
|
||||
* 1. exact-viewport revisit (traffic module's full-road cache),
|
||||
* 2. adjacent 0.4–2 km pan (new exact bbox with heavy spatial overlap),
|
||||
* 3. cold city (no traffic-module entry for that city),
|
||||
* 4. degraded upstream (synthetic delay on `/api/overpass` only).
|
||||
*
|
||||
* The proxy's actual `X-Overpass-Cache` result is printed for every network
|
||||
* pass, so HIT/DISK/MISS/STALE/INFLIGHT is evidence rather than inference.
|
||||
* The degraded leg delays request release in Puppeteer; it exercises the same
|
||||
* fetch-start→response boundary without adding a failure switch to the
|
||||
* application or proxy. Pass `--degraded-delay-ms 0` to skip that leg.
|
||||
* TomTom status is intercepted as keyless so no live-flow key is required and
|
||||
* the capture isolates the OSM road-to-visible-dot chain.
|
||||
*
|
||||
* Measurement caveat: durations include instrumentation overhead (observer
|
||||
* effect), and timestamps are subject to browser clock-floor quantization.
|
||||
* Per-road clock observations also sit inside `road-parse-total` and
|
||||
* `waypoint-materialization`, so those segments slightly overstate production.
|
||||
*
|
||||
* Run with deterministic software GL (relative comparisons only):
|
||||
* node scripts/qa-traffic-baseline.mjs --url http://localhost:4173
|
||||
*
|
||||
* Run headful on the owner's real-GPU browser surface for reportable numbers:
|
||||
* node scripts/qa-traffic-baseline.mjs --url http://localhost:4173 --headful
|
||||
*
|
||||
* SwiftShader `scene.sampleHeight` durations are NOT representative of a real
|
||||
* GPU. The script prints the detected renderer and repeats this warning when
|
||||
* software rendering is detected. It does not write baseline numbers or edit
|
||||
* the latency plan.
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, dflt) => {
|
||||
const index = argv.indexOf(name);
|
||||
return index >= 0 && argv[index + 1] ? argv[index + 1] : dflt;
|
||||
};
|
||||
const getNumberOpt = (name, dflt) => {
|
||||
const value = Number(getOpt(name, dflt));
|
||||
return Number.isFinite(value) ? value : dflt;
|
||||
};
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const HEADFUL = argv.includes('--headful');
|
||||
const TIMEOUT_MS = Math.max(10_000, getNumberOpt('--timeout-ms', 120_000));
|
||||
const DEGRADED_DELAY_MS = Math.max(0, getNumberOpt('--degraded-delay-ms', 2500));
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
|
||||
try { if (fs.existsSync(candidate)) return candidate; } catch { /* ignore */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const VIEWS = {
|
||||
austin: {
|
||||
lon: -97.7431, lat: 30.2672, height: 1500, heading: 8, pitch: -78,
|
||||
},
|
||||
adjacent: {
|
||||
// 0.0105° longitude at Austin's latitude is ~1.0 km.
|
||||
lon: -97.7326, lat: 30.2672, height: 1500, heading: 8, pitch: -78,
|
||||
},
|
||||
coldCity: {
|
||||
lon: -122.4194, lat: 37.7749, height: 1500, heading: 18, pitch: -78,
|
||||
},
|
||||
degraded: {
|
||||
lon: -98.4936, lat: 29.4241, height: 1500, heading: 12, pitch: -78,
|
||||
},
|
||||
};
|
||||
|
||||
/** Add `trafficDebug=1` while preserving the caller's path/query/hash. */
|
||||
function debugUrl(rawUrl) {
|
||||
const url = new URL(rawUrl);
|
||||
url.searchParams.set('trafficDebug', '1');
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/** Position the camera and explicitly raise the boundaries the layer observes. */
|
||||
async function moveCamera(page, view) {
|
||||
return page.evaluate((v) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const module = gev.dataManager.layers.get('traffic').module;
|
||||
const beforeLastUpdate = module.getStats().lastUpdate;
|
||||
const boundaryTime = performance.now();
|
||||
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
|
||||
const radians = Math.PI / 180;
|
||||
try { gev.viewer.camera.cancelFlight(); } catch { /* no active flight */ }
|
||||
gev.viewer.camera.setView({
|
||||
destination: ellipsoid.cartographicToCartesian({
|
||||
longitude: v.lon * radians,
|
||||
latitude: v.lat * radians,
|
||||
height: v.height,
|
||||
}),
|
||||
orientation: {
|
||||
heading: (v.heading || 0) * radians,
|
||||
pitch: (v.pitch ?? -90) * radians,
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
// setView normally propagates through Cesium's render loop. Raising these
|
||||
// explicitly makes the capture boundary deterministic across GPU speeds.
|
||||
// Pairing still flows through camera.changed's schedule-time anchor; the
|
||||
// synthetic moveEnd is emitted only as the same diagnostic used naturally.
|
||||
gev.viewer.camera.changed.raiseEvent();
|
||||
gev.viewer.camera.moveEnd.raiseEvent();
|
||||
return { beforeLastUpdate, boundaryTime };
|
||||
}, view);
|
||||
}
|
||||
|
||||
/** Wait until the triggered load finishes and its final render reaches postRender. */
|
||||
async function waitForTrafficCapture(page, beforeLastUpdate, boundaryTime) {
|
||||
await page.waitForFunction(
|
||||
(before, boundary) => {
|
||||
const module = window.__godsEyeView?.dataManager?.layers?.get('traffic')?.module;
|
||||
if (!module) return false;
|
||||
const stats = module.getStats();
|
||||
if (stats.lastUpdate === before || stats.count <= 0 || stats.loading) return false;
|
||||
const entries = performance.getEntriesByType('measure')
|
||||
.filter((entry) => entry.startTime >= boundary);
|
||||
const renderCount = entries.filter((entry) => entry.name.startsWith('traffic:dot-construction:')).length;
|
||||
const postRenderCount = entries.filter((entry) => entry.name.startsWith('traffic:render-to-post-render:')).length;
|
||||
return renderCount > 0 && postRenderCount >= renderCount;
|
||||
},
|
||||
{ timeout: TIMEOUT_MS, polling: 100 },
|
||||
beforeLastUpdate,
|
||||
boundaryTime,
|
||||
);
|
||||
// Let a final full-pass postRender land after `_fetching` flips false.
|
||||
await sleep(150);
|
||||
}
|
||||
|
||||
/** Return serializable Traffic User Timing measures created after a boundary. */
|
||||
function readTrafficMeasures(page, boundaryTime) {
|
||||
return page.evaluate((boundary) => performance.getEntriesByType('measure')
|
||||
.filter((entry) => entry.name.startsWith('traffic:') && entry.startTime >= boundary)
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
startTime: entry.startTime,
|
||||
duration: entry.duration,
|
||||
detail: entry.detail,
|
||||
})), boundaryTime);
|
||||
}
|
||||
|
||||
/** Drive one measured state and return its raw User Timing measures. */
|
||||
async function captureState(page, name, view, setDelay) {
|
||||
setDelay(name === 'degraded-upstream' ? DEGRADED_DELAY_MS : 0);
|
||||
const { beforeLastUpdate, boundaryTime } = await moveCamera(page, view);
|
||||
await waitForTrafficCapture(page, beforeLastUpdate, boundaryTime);
|
||||
const measures = await readTrafficMeasures(page, boundaryTime);
|
||||
setDelay(0);
|
||||
return { name, measures };
|
||||
}
|
||||
|
||||
/** Format a duration without hiding zero-duration measurements. */
|
||||
function ms(value) {
|
||||
return Number.isFinite(value) ? value.toFixed(1) : '—';
|
||||
}
|
||||
|
||||
/** Find one segment for the same trace/pass as a render measure. */
|
||||
function matchingMeasure(measures, render, segment) {
|
||||
return measures.find((entry) => (
|
||||
entry.detail?.segment === segment
|
||||
&& entry.detail?.traceId === render.detail?.traceId
|
||||
&& entry.detail?.interactionId === render.detail?.interactionId
|
||||
&& entry.detail?.generation === render.detail?.generation
|
||||
&& entry.detail?.pass === render.detail?.pass
|
||||
));
|
||||
}
|
||||
|
||||
/** Fail loudly instead of filtering away a structurally mispaired trace. */
|
||||
function assertInteractionIntegrity(capture) {
|
||||
const interactionByTrace = new Map();
|
||||
for (const entry of capture.measures) {
|
||||
const traceId = entry.detail?.traceId;
|
||||
if (traceId == null) continue;
|
||||
const interactionId = entry.detail?.interactionId;
|
||||
if (interactionId == null) {
|
||||
throw new Error(`${capture.name}: trace ${traceId} has no scheduling interactionId`);
|
||||
}
|
||||
const prior = interactionByTrace.get(traceId);
|
||||
if (prior != null && prior !== interactionId) {
|
||||
throw new Error(
|
||||
`${capture.name}: trace ${traceId} crossed interactions ${prior} and ${interactionId}`,
|
||||
);
|
||||
}
|
||||
interactionByTrace.set(traceId, interactionId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert raw measures into one table row per major/full visible render. */
|
||||
function rowsForCapture(capture) {
|
||||
// Correlation violations are capture failures, never rows silently removed
|
||||
// by generation/settle predicates that are tautological after the guard.
|
||||
assertInteractionIntegrity(capture);
|
||||
const renders = capture.measures.filter((entry) => (
|
||||
entry.detail?.segment === 'dot-construction'
|
||||
&& entry.detail?.interactionId != null
|
||||
));
|
||||
return renders.map((render) => {
|
||||
const get = (segment) => matchingMeasure(capture.measures, render, segment);
|
||||
const fetchStart = get('last-camera-change-to-fetch-start');
|
||||
const response = get('fetch-to-response');
|
||||
const responseJson = get('response-json');
|
||||
const sample = get('sample-height-total');
|
||||
const materialize = get('waypoint-materialization');
|
||||
const flowRace = get('flow-render-race');
|
||||
const postRender = capture.measures.find((entry) => (
|
||||
entry.detail?.segment === 'render-to-post-render'
|
||||
&& entry.detail?.traceId === render.detail?.traceId
|
||||
&& entry.detail?.interactionId === render.detail?.interactionId
|
||||
&& entry.detail?.generation === render.detail?.generation
|
||||
&& entry.detail?.renderId === render.detail?.renderId
|
||||
));
|
||||
const visible = capture.measures.find((entry) => (
|
||||
entry.detail?.segment === 'last-camera-change-to-first-visible'
|
||||
&& entry.detail?.traceId === render.detail?.traceId
|
||||
&& entry.detail?.interactionId === render.detail?.interactionId
|
||||
&& entry.detail?.generation === render.detail?.generation
|
||||
&& entry.detail?.renderId === render.detail?.renderId
|
||||
));
|
||||
const heatLines = capture.measures.find((entry) => (
|
||||
entry.detail?.segment === 'rebuild-heat-lines'
|
||||
&& entry.detail?.traceId === render.detail?.traceId
|
||||
&& entry.detail?.interactionId === render.detail?.interactionId
|
||||
&& entry.detail?.generation === render.detail?.generation
|
||||
&& entry.detail?.renderId === render.detail?.renderId
|
||||
));
|
||||
const metadata = response?.detail || sample?.detail || render.detail || {};
|
||||
return {
|
||||
State: capture.name,
|
||||
Pass: render.detail?.pass || '—',
|
||||
Source: render.detail?.source || '—',
|
||||
'Proxy cache': metadata.proxyCache || '—',
|
||||
Upstream: metadata.proxyUpstream || '—',
|
||||
'Last change→fetch ms': ms(fetchStart?.duration),
|
||||
'Fetch→response ms': ms(response?.duration),
|
||||
'response.json ms': ms(responseJson?.duration),
|
||||
'Flow race ms': ms(flowRace?.duration),
|
||||
'sampleHeight ms': ms(sample?.duration),
|
||||
Calls: sample?.detail?.sampleHeightCalls ?? '—',
|
||||
'Mean/call ms': ms(sample?.detail?.sampleHeightMeanMs),
|
||||
Cells: sample?.detail?.distinctCells ?? '—',
|
||||
'Waypoints ms': ms(materialize?.duration),
|
||||
'Dots ms': ms(render.duration),
|
||||
'Heat lines ms': ms(heatLines?.duration),
|
||||
'Render→postRender ms': ms(postRender?.duration),
|
||||
'Last change→visible ms': ms(visible?.duration),
|
||||
Roads: render.detail?.roadCount ?? sample?.detail?.roadCount ?? '—',
|
||||
Dots: render.detail?.dotCount ?? '—',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Read the active WebGL renderer for the real-GPU/SwiftShader warning. */
|
||||
function readRenderer(page) {
|
||||
return page.evaluate(() => {
|
||||
const gl = window.__godsEyeView?.viewer?.scene?.context?._gl;
|
||||
if (!gl) return 'unknown';
|
||||
const extension = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
return extension
|
||||
? gl.getParameter(extension.UNMASKED_RENDERER_WEBGL)
|
||||
: gl.getParameter(gl.RENDERER);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const url = debugUrl(APP_URL);
|
||||
console.log('\nTraffic Phase 0 — causal-chain baseline capture');
|
||||
console.log(` App URL : ${url}`);
|
||||
console.log(` Browser mode : ${HEADFUL ? 'headful (real GPU expected)' : 'headless SwiftShader (relative-only)'}`);
|
||||
console.log(` Degraded delay : ${DEGRADED_DELAY_MS ? `${DEGRADED_DELAY_MS} ms/request` : 'skipped'}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(APP_URL);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
console.error(`\nDev server not reachable at ${APP_URL} (${error.message}).`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const executablePath = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1440,900',
|
||||
...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
],
|
||||
});
|
||||
|
||||
let activeProxyDelayMs = 0;
|
||||
const captures = [];
|
||||
const consoleErrors = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.evaluateOnNewDocument(() => localStorage.clear());
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text());
|
||||
});
|
||||
page.on('pageerror', (error) => consoleErrors.push(error.message));
|
||||
|
||||
// Force keyless traffic and optionally hold Overpass requests to model a
|
||||
// degraded proxy/upstream. No server key or proxy debug endpoint is needed.
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', async (request) => {
|
||||
if (request.url().includes('/api/tomtom/status')) {
|
||||
await request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ hasKey: false }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (activeProxyDelayMs > 0 && request.url().includes('/api/overpass')) {
|
||||
await sleep(activeProxyDelayMs);
|
||||
}
|
||||
try { await request.continue(); } catch { /* page closed or already handled */ }
|
||||
});
|
||||
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
|
||||
// Disable all persisted overlays, park above traffic's activation ceiling,
|
||||
// then enable traffic without accidentally starting an unmeasured load.
|
||||
await page.evaluate(async (highView) => {
|
||||
const gev = window.__godsEyeView;
|
||||
await gev.dataManager.restoreEnabledLayerIds([]);
|
||||
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
|
||||
const radians = Math.PI / 180;
|
||||
gev.viewer.camera.setView({
|
||||
destination: ellipsoid.cartographicToCartesian({
|
||||
longitude: highView.lon * radians,
|
||||
latitude: highView.lat * radians,
|
||||
height: 9000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
await gev.dataManager.setEnabled('traffic', true);
|
||||
gev.viewer.camera.changed.raiseEvent();
|
||||
gev.viewer.camera.moveEnd.raiseEvent();
|
||||
}, VIEWS.austin);
|
||||
await sleep(500);
|
||||
|
||||
const renderer = await readRenderer(page);
|
||||
const softwareRenderer = /swiftshader|software/i.test(renderer);
|
||||
console.log(` WebGL renderer : ${renderer}`);
|
||||
if (!HEADFUL || softwareRenderer) {
|
||||
console.log(' WARNING : sampleHeight timings are relative-only under SwiftShader/software GL.');
|
||||
}
|
||||
|
||||
// Prime Austin once. Its measurements are deliberately not reported.
|
||||
console.log('\nPriming the exact Austin viewport (not part of the table)...');
|
||||
const prime = await captureState(page, 'prime', VIEWS.austin, (msValue) => {
|
||||
activeProxyDelayMs = msValue;
|
||||
});
|
||||
if (!prime.measures.some((entry) => entry.detail?.segment === 'dot-construction')) {
|
||||
throw new Error('trafficDebug instrumentation emitted no dot-construction measure');
|
||||
}
|
||||
|
||||
// Clear the last-bounds gate without clearing the module's road cache, then
|
||||
// revisit the byte-identical camera pose for a true client-cache hit.
|
||||
await page.evaluate((view) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
|
||||
const radians = Math.PI / 180;
|
||||
gev.viewer.camera.setView({
|
||||
destination: ellipsoid.cartographicToCartesian({
|
||||
longitude: view.lon * radians,
|
||||
latitude: view.lat * radians,
|
||||
height: 9000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
gev.viewer.camera.changed.raiseEvent();
|
||||
gev.viewer.camera.moveEnd.raiseEvent();
|
||||
}, VIEWS.austin);
|
||||
await sleep(500);
|
||||
|
||||
captures.push(await captureState(page, 'exact-viewport revisit', VIEWS.austin, (msValue) => {
|
||||
activeProxyDelayMs = msValue;
|
||||
}));
|
||||
captures.push(await captureState(page, 'adjacent ~1.0 km pan', VIEWS.adjacent, (msValue) => {
|
||||
activeProxyDelayMs = msValue;
|
||||
}));
|
||||
captures.push(await captureState(page, 'cold city (client)', VIEWS.coldCity, (msValue) => {
|
||||
activeProxyDelayMs = msValue;
|
||||
}));
|
||||
if (DEGRADED_DELAY_MS > 0) {
|
||||
captures.push(await captureState(page, 'degraded-upstream', VIEWS.degraded, (msValue) => {
|
||||
activeProxyDelayMs = msValue;
|
||||
}));
|
||||
}
|
||||
|
||||
const missingStates = captures.filter((capture) => rowsForCapture(capture).length === 0);
|
||||
if (missingStates.length) {
|
||||
throw new Error(`no visible render captured for: ${missingStates.map((capture) => capture.name).join(', ')}`);
|
||||
}
|
||||
const exactRows = rowsForCapture(captures[0]);
|
||||
if (!exactRows.some((row) => row.Source === 'client-cache' && row['Fetch→response ms'] === '—')) {
|
||||
throw new Error('exact-viewport revisit did not use the traffic module cache');
|
||||
}
|
||||
for (const capture of captures.slice(1)) {
|
||||
if (!capture.measures.some((entry) => entry.detail?.segment === 'fetch-to-response')) {
|
||||
throw new Error(`${capture.name} did not issue an Overpass request`);
|
||||
}
|
||||
}
|
||||
if (DEGRADED_DELAY_MS > 0) {
|
||||
const degraded = captures.find((capture) => capture.name === 'degraded-upstream');
|
||||
const delayed = degraded?.measures.some((entry) => (
|
||||
entry.detail?.segment === 'fetch-to-response'
|
||||
&& entry.duration >= DEGRADED_DELAY_MS * 0.9
|
||||
));
|
||||
if (!delayed) throw new Error('degraded-upstream leg did not observe the synthetic delay');
|
||||
}
|
||||
|
||||
const rows = captures.flatMap(rowsForCapture);
|
||||
if (!rows.length) throw new Error('no measured traffic renders were captured');
|
||||
console.log('\nCausal segments (one row per visible major/full pass)');
|
||||
console.table(rows);
|
||||
console.log('\nNotes:');
|
||||
console.log(' - Render→postRender is the first visible-frame boundary used by Phase 0.');
|
||||
console.log(' - Proxy cache is read from X-Overpass-Cache; “—” means the client cache avoided fetch.');
|
||||
console.log(' - response.json includes both body transfer and JSON decoding; the production path does not split them.');
|
||||
console.log(' - Last change→fetch is approximately the 320 ms FETCH_DEBOUNCE for the first fetch by construction.');
|
||||
console.log(' - Full-pass Last change→fetch also includes the intentionally sequential major pass.');
|
||||
console.log(' - Cesium moveEnd is a diagnostic raised ~500 ms after stillness, typically after fetch starts; fetch never waits for it.');
|
||||
console.log(' - Synthetic events bypass camera percentageChanged/inertia but use the same camera.changed schedule-time anchor as natural captures.');
|
||||
console.log(' - The keyless capture zeroes Flow race and Heat lines work; live-keyed numbers will differ.');
|
||||
console.log(' - Measurement caveat: timings include instrumentation overhead (observer effect) and browser clock-floor quantization.');
|
||||
console.log(' - Per-road clock observations inside road-parse-total/waypoint-materialization slightly overstate production work.');
|
||||
const timingDiagnostics = await page.evaluate(() => (
|
||||
window.__godsEyeView.dataManager.layers.get('traffic').module.getStats().trafficTiming
|
||||
));
|
||||
console.log(` - Correlation drops: ${timingDiagnostics?.uncorrelatedTracesDropped ?? 'unavailable'} scheduling-anchor mismatch(es).`);
|
||||
if (!HEADFUL || softwareRenderer) {
|
||||
console.log(' - Do not copy these GPU-side numbers into the plan; rerun headful on a real-GPU surface.');
|
||||
}
|
||||
if (consoleErrors.length) {
|
||||
console.log(` - Browser console errors observed: ${consoleErrors.length}`);
|
||||
for (const error of consoleErrors.slice(0, 5)) console.log(` ${error}`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('\nTraffic baseline capture failed:', error);
|
||||
process.exit(3);
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-traffic-jamviz-ab.mjs — A/B screenshot capture for the jam-viz
|
||||
* congestion prototypes.
|
||||
*
|
||||
* For each view, renders the SAME camera framing under each jamViz mode
|
||||
* (none = shipped main behavior / density / heatline / both), forcing a
|
||||
* fresh camera-driven re-render between modes via an away-and-back teleport
|
||||
* (setParams applies on the next load, same semantics as uncoveredRoads).
|
||||
* Shots + per-shot layer stats land in --out (default qa-shots/jamviz).
|
||||
*
|
||||
* Views: Austin I-35 downtown corridor (owner's requested A/B target) at
|
||||
* city scale + low detail, plus Mumbai Western Express Hwy — captured
|
||||
* because Austin is off-peak at 22:00 CT while Mumbai is in live morning
|
||||
* rush, guaranteeing jam-bucket coverage in the drama shots.
|
||||
*
|
||||
* Run: node scripts/qa-traffic-jamviz-ab.mjs --url http://localhost:4412
|
||||
* Exits non-zero on harness failure (missing live mode, zero dots, etc.).
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, dflt) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4412');
|
||||
const OUT_DIR = path.resolve(REPO_ROOT, getOpt('--out', 'qa-shots/jamviz'));
|
||||
|
||||
const VIEWS = [
|
||||
{
|
||||
id: 'i35-city',
|
||||
label: 'Austin I-35 corridor — city scale',
|
||||
lon: -97.7365, lat: 30.235, height: 3000, heading: 8, pitch: -38,
|
||||
},
|
||||
{
|
||||
id: 'i35-detail',
|
||||
label: 'Austin I-35 at downtown — queue detail',
|
||||
lon: -97.735, lat: 30.262, height: 1100, heading: 10, pitch: -45,
|
||||
},
|
||||
{
|
||||
id: 'mumbai-wexp',
|
||||
label: 'Mumbai Western Express Hwy — live morning rush',
|
||||
lon: 72.851, lat: 19.115, height: 3200, heading: 0, pitch: -40,
|
||||
},
|
||||
];
|
||||
const MODES = ['none', 'density', 'heatline', 'both'];
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
const findChromeExecutable = () =>
|
||||
CHROME_EXECUTABLE_CANDIDATES.find((c) => { try { return fs.existsSync(c); } catch { return false; } }) || null;
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** Same pattern as qa-cctv-v2: wait for the 3D tileset to settle for crisp shots. */
|
||||
function waitForTilesLoaded(page, timeoutMs) {
|
||||
return page.waitForFunction(
|
||||
() => {
|
||||
const prims = window.__godsEyeView.viewer.scene.primitives;
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
const p = prims.get(i);
|
||||
if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') {
|
||||
return p.tilesLoaded === true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout: timeoutMs }
|
||||
).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
/** Teleport to a view (cancelling any camera flight first). */
|
||||
function setView(page, v) {
|
||||
return page.evaluate((view) => {
|
||||
const gev = window.__godsEyeView;
|
||||
try { gev.viewer.camera.cancelFlight(); } catch { /* no flight */ }
|
||||
const ell = gev.viewer.scene.globe.ellipsoid;
|
||||
const d2r = Math.PI / 180;
|
||||
gev.viewer.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
longitude: view.lon * d2r, latitude: view.lat * d2r, height: view.height,
|
||||
}),
|
||||
orientation: {
|
||||
heading: (view.heading || 0) * d2r,
|
||||
pitch: (view.pitch ?? -90) * d2r,
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
}, v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll layer stats until a render newer than `sinceLastUpdate` settles.
|
||||
* Returns the last sample with `renderSettled` reporting whether the
|
||||
* freshness predicate was actually met — on timeout the sample is stale
|
||||
* (possibly still-colored buckets from the previous view), and a caller that
|
||||
* cannot tell the difference will pass a capture that never rendered.
|
||||
*/
|
||||
function waitForFreshRender(page, sinceLastUpdate, timeoutS = 40) {
|
||||
return page.evaluate(async (since, tS) => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('traffic').module;
|
||||
let s = null;
|
||||
let renderSettled = false;
|
||||
for (let i = 0; i < tS; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
s = mod.getStats();
|
||||
if (s.lastUpdate && s.lastUpdate !== since && s.count > 0 && !s.loading) {
|
||||
renderSettled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { ...s, renderSettled };
|
||||
}, sinceLastUpdate, timeoutS);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\nJam-viz A/B capture');
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Out dir : ${OUT_DIR}\n`);
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`);
|
||||
process.exit(2);
|
||||
}
|
||||
const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
||||
if (!status?.hasKey) {
|
||||
console.error('Server has no TomTom key — the A/B needs live flow.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}),
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage', '--disable-web-security',
|
||||
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
||||
'--window-size=1600,900',
|
||||
],
|
||||
});
|
||||
|
||||
const manifest = [];
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1600, height: 900 });
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(() => window.__godsEyeView.dataManager.setEnabled('traffic', true));
|
||||
await sleep(1500);
|
||||
|
||||
let firstView = true;
|
||||
for (const view of VIEWS) {
|
||||
console.log(`\n── ${view.label} (${view.id})`);
|
||||
for (const mode of MODES) {
|
||||
// Apply the mode, then force a re-render AT THE EXACT SAME framing:
|
||||
// hop ~8.6 km east (defeats the overlap/center-shift gate — adjacent
|
||||
// views alone can sit inside it), WAIT for the away render to commit
|
||||
// (otherwise its late commit satisfies the freshness poll and the
|
||||
// shot captures the away render), then return — the comeback load
|
||||
// re-spawns with the new mode active.
|
||||
await page.evaluate((m) => {
|
||||
window.__godsEyeView.dataManager.layers.get('traffic').module.setParams({ jamViz: m });
|
||||
}, mode);
|
||||
const preHop = await page.evaluate(
|
||||
() => window.__godsEyeView.dataManager.layers.get('traffic').module.getStats().lastUpdate,
|
||||
);
|
||||
await setView(page, { ...view, lon: view.lon + 0.09 });
|
||||
const hop = await waitForFreshRender(page, preHop, 30);
|
||||
const since = await page.evaluate(
|
||||
() => window.__godsEyeView.dataManager.layers.get('traffic').module.getStats().lastUpdate,
|
||||
);
|
||||
await setView(page, view);
|
||||
const stats = await waitForFreshRender(page, since);
|
||||
await waitForTilesLoaded(page, firstView ? 30000 : 15000);
|
||||
await sleep(1400); // flow-recolor race + heat-line pulse mid-bright
|
||||
|
||||
const final = await page.evaluate(() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('traffic').module;
|
||||
return { stats: mod.getStats(), params: mod.getParams() };
|
||||
});
|
||||
const shot = `${view.id}--${mode}.png`;
|
||||
await page.screenshot({ path: path.join(OUT_DIR, shot) });
|
||||
|
||||
const b = final.stats.flowBuckets || {};
|
||||
// `mode` is the CONFIGURED source, not feed health: a keyed run whose
|
||||
// TomTom flow died still reports mode 'live' while every dot renders
|
||||
// simulated white. Health is `!error` + actually-colored dots.
|
||||
const colored = (b.free || 0) + (b.slow || 0) + (b.jam || 0);
|
||||
// A capture whose waits timed out shows the PREVIOUS view's dots.
|
||||
// That is a failed capture, not a footnote in the manifest.
|
||||
const renderSettled = Boolean(hop.renderSettled && stats.renderSettled)
|
||||
&& !final.stats.loading;
|
||||
const ok = renderSettled && final.stats.mode === 'live' && !final.stats.error
|
||||
&& final.stats.count > 0 && colored > 0
|
||||
&& final.params.jamViz === mode
|
||||
&& (mode !== 'none' || final.stats.heatLines === 0);
|
||||
if (!ok) exitCode = 1;
|
||||
console.log(` [${ok ? 'OK' : 'FAIL'}] ${shot} dots=${final.stats.count} `
|
||||
+ `free=${b.free} slow=${b.slow} jam=${b.jam} sim=${b.sim} `
|
||||
+ `heatLines=${final.stats.heatLines} cov=${final.stats.flowCoveragePct}% `
|
||||
+ `err=${final.stats.error || 'none'} settled=${renderSettled}`);
|
||||
manifest.push({
|
||||
view: view.id, label: view.label, mode, shot,
|
||||
capturedAt: new Date().toISOString(),
|
||||
count: final.stats.count, flowBuckets: b,
|
||||
heatLines: final.stats.heatLines,
|
||||
closedRoads: final.stats.closedRoads,
|
||||
coveragePct: final.stats.flowCoveragePct,
|
||||
error: final.stats.error || null,
|
||||
renderSettled,
|
||||
});
|
||||
firstView = false;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Harness error:', e);
|
||||
exitCode = 3;
|
||||
} finally {
|
||||
fs.writeFileSync(path.join(OUT_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`\nShots + manifest.json → ${OUT_DIR}`);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(3); });
|
||||
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-traffic-preset-ab.mjs — A/B screenshot capture for preset-aware
|
||||
* traffic dot styling (field finding 2026-07-23: NVG/FLIR/CRT
|
||||
* post-FX crush the green/amber/red congestion coding).
|
||||
*
|
||||
* For each view, settles the live traffic layer ONCE, then for each
|
||||
* post-FX style (normal / surveillance=NVG / thermal=FLIR / retro=CRT)
|
||||
* drives the REAL StyleManager.setStyle path and captures:
|
||||
* A — presetDots 'off' (shipped palette under that preset), and
|
||||
* B — presetDots 'on' (preset-aware luminance/size encoding),
|
||||
* at identical framing with the identical dot set — the kill switch
|
||||
* restyles in place, no refetch, so the pair is a true A/B.
|
||||
*
|
||||
* Views: Mumbai Western Express Hwy (live rush window for jam coverage)
|
||||
* + Austin I-35 downtown corridor (common target).
|
||||
*
|
||||
* Shots + per-shot layer stats land in --out
|
||||
* (default qa-shots/preset-traffic, gitignored).
|
||||
*
|
||||
* Run: node scripts/qa-traffic-preset-ab.mjs --url http://localhost:4390
|
||||
* Exits non-zero on harness failure (no live mode, zero dots, style or
|
||||
* param not adopted by the layer).
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, dflt) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4390');
|
||||
const OUT_DIR = path.resolve(REPO_ROOT, getOpt('--out', 'qa-shots/preset-traffic'));
|
||||
|
||||
// Framing note: at shallow oblique pitch the camera sits 5+ km from the
|
||||
// roads — free/slow dots (2 km depth punch) cull under the mesh and the
|
||||
// rest fade to ~2 px, so no styling is judgeable there. Both views keep the
|
||||
// camera close/steep enough that every bucket renders (same reason
|
||||
// qa-traffic's proof shot is near-nadir).
|
||||
const VIEWS = [
|
||||
{
|
||||
id: 'i35-close',
|
||||
label: 'Austin I-35 jam cluster — close queue read',
|
||||
lon: -97.7365, lat: 30.252, height: 1400, heading: 8, pitch: -65,
|
||||
},
|
||||
{
|
||||
id: 'i35-nadir',
|
||||
label: 'Austin I-35 corridor — near-nadir city scale',
|
||||
lon: -97.7365, lat: 30.25, height: 2400, heading: 8, pitch: -70,
|
||||
},
|
||||
];
|
||||
/**
|
||||
* StyleManager preset names with user-facing labels + expected profile.
|
||||
* `ironbow` flips the thermal palette uniform (0 = grayscale WHOT,
|
||||
* 1 = Ironbow "Predator" ramp) — round 2 requires the dots to read in both.
|
||||
*/
|
||||
const STYLES = [
|
||||
{ id: 'normal', name: 'normal', label: 'Normal', profile: 'normal' },
|
||||
{ id: 'surveillance', name: 'surveillance', label: 'NVG', profile: 'mono' },
|
||||
{ id: 'thermal', name: 'thermal', label: 'FLIR WHOT', profile: 'mono', ironbow: false },
|
||||
{ id: 'thermal-ironbow', name: 'thermal', label: 'FLIR Ironbow', profile: 'mono', ironbow: true },
|
||||
{ id: 'retro', name: 'retro', label: 'CRT', profile: 'crt' },
|
||||
];
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
const findChromeExecutable = () =>
|
||||
CHROME_EXECUTABLE_CANDIDATES.find((c) => { try { return fs.existsSync(c); } catch { return false; } }) || null;
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** Same pattern as qa-traffic-jamviz-ab: wait for the 3D tileset to settle. */
|
||||
function waitForTilesLoaded(page, timeoutMs) {
|
||||
return page.waitForFunction(
|
||||
() => {
|
||||
const prims = window.__godsEyeView.viewer.scene.primitives;
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
const p = prims.get(i);
|
||||
if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') {
|
||||
return p.tilesLoaded === true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout: timeoutMs }
|
||||
).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
/** Enable traffic + teleport, then poll the layer until settled. */
|
||||
function settleTraffic(page, view, { minCount = 100, timeoutS = 45 } = {}) {
|
||||
return page.evaluate(async (v, minC, tS) => {
|
||||
const gev = window.__godsEyeView;
|
||||
await gev.dataManager.setEnabled('traffic', true);
|
||||
const mod = gev.dataManager.layers.get('traffic').module;
|
||||
try { gev.viewer.camera.cancelFlight(); } catch { /* no flight */ }
|
||||
const ell = gev.viewer.scene.globe.ellipsoid;
|
||||
const d2r = Math.PI / 180;
|
||||
gev.viewer.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
longitude: v.lon * d2r, latitude: v.lat * d2r, height: v.height,
|
||||
}),
|
||||
orientation: {
|
||||
heading: (v.heading || 0) * d2r, pitch: (v.pitch ?? -90) * d2r, roll: 0,
|
||||
},
|
||||
});
|
||||
let s = null;
|
||||
// `renderSettled` separates a real settle from a timeout returning the
|
||||
// last (stale, possibly still-loading) sample — without it a capture of
|
||||
// the previous view's dots passes.
|
||||
let renderSettled = false;
|
||||
for (let i = 0; i < tS; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
s = mod.getStats();
|
||||
if (s.count >= minC && !s.loading) {
|
||||
renderSettled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { ...s, renderSettled };
|
||||
}, view, minCount, timeoutS);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\nPreset-aware traffic styling A/B capture');
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Out dir : ${OUT_DIR}\n`);
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`);
|
||||
process.exit(2);
|
||||
}
|
||||
const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
||||
if (!status?.hasKey) {
|
||||
console.error('Server has no TomTom key — the A/B needs live flow.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}),
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage', '--disable-web-security',
|
||||
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
||||
'--window-size=1600,900',
|
||||
],
|
||||
});
|
||||
|
||||
const manifest = [];
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1600, height: 900 });
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await sleep(1500);
|
||||
|
||||
let firstView = true;
|
||||
for (const view of VIEWS) {
|
||||
console.log(`\n── ${view.label} (${view.id})`);
|
||||
const settled = await settleTraffic(page, view);
|
||||
// `mode` only says a TomTom key is configured; `error` says whether the
|
||||
// flow feed is actually up. A degraded run renders simulated white dots
|
||||
// and must not pass as a live styling capture — and a wait that timed
|
||||
// out never rendered this view at all.
|
||||
if (!settled || !settled.renderSettled || settled.count === 0
|
||||
|| settled.mode !== 'live' || settled.error) {
|
||||
console.error(' [FAIL] traffic never settled live '
|
||||
+ `(count=${settled?.count} mode=${settled?.mode} `
|
||||
+ `settled=${Boolean(settled?.renderSettled)} err=${settled?.error || 'none'})`);
|
||||
exitCode = 1;
|
||||
continue;
|
||||
}
|
||||
await waitForTilesLoaded(page, firstView ? 30000 : 15000);
|
||||
firstView = false;
|
||||
|
||||
for (const style of STYLES) {
|
||||
// Real user path: StyleManager.setStyle drives the post-FX stage AND
|
||||
// the gev:style-change event the traffic layer listens to. The
|
||||
// thermal palette uniform selects WHOT vs Ironbow.
|
||||
await page.evaluate((s) => {
|
||||
const sm = window.__godsEyeView.styleManager;
|
||||
sm.setStyle(s.name, { applyPreset: true });
|
||||
if (sm.stages.thermal) sm.stages.thermal.uniforms.palette = s.ironbow ? 1.0 : 0.0;
|
||||
}, style);
|
||||
await sleep(1800); // 500 ms intensity lerp + settle
|
||||
|
||||
// Round 2 legs: dots alone (DETECT off) vs dots + bucket-colored
|
||||
// brackets (DENSE) — presetDots stays at its default 'on'.
|
||||
for (const detect of ['off', 'on']) {
|
||||
await page.evaluate((d) => {
|
||||
window.__godsEyeView.styleManager._setDetectionMode(d === 'on' ? 'DENSE' : 'OFF');
|
||||
}, detect);
|
||||
await sleep(1200); // label-arbiter solve + a few rendered frames
|
||||
|
||||
const final = await page.evaluate(() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('traffic').module;
|
||||
return { stats: mod.getStats(), params: mod.getParams() };
|
||||
});
|
||||
const shot = `${view.id}--${style.id}--detect-${detect}.png`;
|
||||
await page.screenshot({ path: path.join(OUT_DIR, shot) });
|
||||
|
||||
const b = final.stats.flowBuckets || {};
|
||||
// Bucket colors are the whole point of this capture, so a degraded
|
||||
// feed (mode 'live', flow down, every dot white) has to fail here.
|
||||
const colored = (b.free || 0) + (b.slow || 0) + (b.jam || 0);
|
||||
// A style swap can kick a re-render; capturing mid-load screenshots
|
||||
// the previous style's dots.
|
||||
const ok = !final.stats.loading
|
||||
&& final.stats.mode === 'live' && !final.stats.error
|
||||
&& final.stats.count > 0 && colored > 0
|
||||
&& final.params.presetDots === 'on'
|
||||
&& final.stats.stylePreset === style.name
|
||||
&& final.stats.styleProfile === style.profile;
|
||||
if (!ok) exitCode = 1;
|
||||
console.log(` [${ok ? 'OK' : 'FAIL'}] ${shot} dots=${final.stats.count} `
|
||||
+ `free=${b.free} slow=${b.slow} jam=${b.jam} sim=${b.sim} `
|
||||
+ `stylePreset=${final.stats.stylePreset} profile=${final.stats.styleProfile} `
|
||||
+ `err=${final.stats.error || 'none'} loading=${final.stats.loading}`);
|
||||
manifest.push({
|
||||
view: view.id, label: view.label,
|
||||
style: style.id, styleLabel: style.label, detect, shot,
|
||||
capturedAt: new Date().toISOString(),
|
||||
count: final.stats.count, flowBuckets: b,
|
||||
stylePreset: final.stats.stylePreset,
|
||||
styleProfile: final.stats.styleProfile,
|
||||
coveragePct: final.stats.flowCoveragePct,
|
||||
// A rejected capture must look rejected in the manifest too, not
|
||||
// just in the console verdict — record the gating fields.
|
||||
ok,
|
||||
loading: final.stats.loading,
|
||||
error: final.stats.error || null,
|
||||
renderSettled: final.renderSettled,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Leave the page in the shipped default state between views.
|
||||
await page.evaluate(() => {
|
||||
const sm = window.__godsEyeView.styleManager;
|
||||
sm.setStyle('normal', { applyPreset: true });
|
||||
sm._setDetectionMode('OFF');
|
||||
if (sm.stages.thermal) sm.stages.thermal.uniforms.palette = 0.0;
|
||||
});
|
||||
await sleep(1000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Harness error:', e);
|
||||
exitCode = 3;
|
||||
} finally {
|
||||
fs.writeFileSync(path.join(OUT_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`\nShots + manifest.json → ${OUT_DIR}`);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(3); });
|
||||
@@ -0,0 +1,316 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-traffic.mjs — headless proof for the TomTom live-flow traffic layer.
|
||||
*
|
||||
* Drives the REAL app in headless Chromium against a dev server that has a
|
||||
* TomTom key (default :4410) and asserts the live-mode contract, then uses
|
||||
* request interception to fabricate the keyless state deterministically
|
||||
* (no key removal needed, no upstream traffic).
|
||||
*
|
||||
* (i) LIVE mode — stats.mode 'live', dots rendered, colored buckets
|
||||
* non-empty (free+slow+jam > 0), coverage > 0, tiles fetched > 0.
|
||||
* (ii) C4 oblique bounds — at 2.5 km / -20° pitch the road-fetch box
|
||||
* center lands within 12 km of the camera (look-at point), never
|
||||
* the pre-fix horizon-biased midpoint (>25 km).
|
||||
* (iii) Budget honesty — /api/tomtom/status dailyCount grows by no more
|
||||
* than the tiles the page actually fetched this run.
|
||||
* (iv) Uncovered-roads param — 'hide' renders zero sim dots; 'sim'
|
||||
* restores them (Mumbai has partial coverage; Austin may be 100%,
|
||||
* so this asserts on whichever view has sim dots, else records
|
||||
* INCONCLUSIVE rather than a false failure).
|
||||
* (v) KEYLESS fallback — with /api/tomtom/status intercepted to
|
||||
* {hasKey:false}: mode 'sim', every dot white (buckets.sim ===
|
||||
* count), and ZERO /api/tomtom/flow requests issued.
|
||||
*
|
||||
* Visual proof saved to qa-shots/ (gitignored).
|
||||
*
|
||||
* Run: node scripts/qa-traffic.mjs --url http://localhost:4410
|
||||
* Exits non-zero on any FAIL. Does not commit anything.
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, dflt) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4410');
|
||||
const HEADFUL = argv.includes('--headful');
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
|
||||
try { if (fs.existsSync(candidate)) return candidate; } catch { /* ignore */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
const tag = ok === null ? '\x1b[33mINCONCLUSIVE\x1b[0m' : ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** Enable traffic + teleport, then poll the layer until settled. */
|
||||
async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) {
|
||||
return page.evaluate(async (v, minC, tS) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const dm = gev.dataManager;
|
||||
await dm.setEnabled('traffic', true);
|
||||
const mod = dm.layers.get('traffic').module;
|
||||
const ell = gev.viewer.scene.globe.ellipsoid;
|
||||
const d2r = Math.PI / 180;
|
||||
// The app's intro flyTo animation clobbers a setView issued mid-flight —
|
||||
// cancel any active tween before teleporting.
|
||||
try { gev.viewer.camera.cancelFlight(); } catch { /* no flight active */ }
|
||||
gev.viewer.camera.setView({
|
||||
destination: ell.cartographicToCartesian({ longitude: v.lon * d2r, latitude: v.lat * d2r, height: v.height }),
|
||||
orientation: { heading: (v.heading || 0) * d2r, pitch: (v.pitch ?? -90) * d2r, roll: 0 },
|
||||
});
|
||||
let s = null;
|
||||
for (let i = 0; i < tS; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
s = mod.getStats();
|
||||
if (s.count >= minC && !s.loading) break;
|
||||
}
|
||||
return s;
|
||||
}, view, minCount, timeoutS);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\nTomTom Live-Flow Traffic Proof (qa-traffic)');
|
||||
console.log(` App URL : ${APP_URL}\n`);
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const statusBefore = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
||||
if (!statusBefore?.hasKey) {
|
||||
console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}),
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage', '--disable-web-security',
|
||||
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
||||
'--window-size=1440,900',
|
||||
],
|
||||
});
|
||||
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
|
||||
// Track flow-tile requests + traffic console lines for (ii)/(iii)/(v).
|
||||
const flowRequests = [];
|
||||
page.on('request', (req) => {
|
||||
if (req.url().includes('/api/tomtom/flow/')) flowRequests.push(req.url());
|
||||
});
|
||||
const trafficLogs = [];
|
||||
page.on('console', (msg) => {
|
||||
const t = msg.text();
|
||||
if (t.includes('[Data:Traffic]')) trafficLogs.push(t);
|
||||
});
|
||||
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await sleep(1500);
|
||||
|
||||
// ── (i) LIVE mode: San Antonio — fast Overpass extract, partial TomTom
|
||||
// coverage (sim dots exist for (iv)). Mumbai proved too Overpass-cold for
|
||||
// a deterministic harness; congestion colors assert the same either way.
|
||||
console.log('\n(i) LIVE mode — San Antonio (partial coverage)...');
|
||||
let mumbai = await settleTraffic(page, { lon: -98.4936, lat: 29.4241, height: 2800, heading: 23, pitch: -72 }, { minCount: 300, timeoutS: 45 });
|
||||
if (!mumbai || mumbai.count === 0) {
|
||||
// One retry with a nudged center to defeat the overlap gate (a slow
|
||||
// first Overpass response can strand the initial load).
|
||||
mumbai = await settleTraffic(page, { lon: -98.487, lat: 29.43, height: 2800, heading: 23, pitch: -72 }, { minCount: 300, timeoutS: 45 });
|
||||
}
|
||||
{
|
||||
const b = mumbai.flowBuckets || {};
|
||||
const colored = (b.free || 0) + (b.slow || 0) + (b.jam || 0);
|
||||
record('LIVE: stats.mode === "live"', mumbai.mode === 'live', `mode=${mumbai.mode}`);
|
||||
record('LIVE: dots rendered', mumbai.count > 0, `count=${mumbai.count}`);
|
||||
record('LIVE: colored flow dots present (free+slow+jam > 0)', colored > 0,
|
||||
`free=${b.free} slow=${b.slow} jam=${b.jam} sim=${b.sim}`);
|
||||
record('LIVE: flow coverage > 0 and tiles fetched > 0',
|
||||
mumbai.flowCoveragePct > 0 && mumbai.tilesFetched > 0,
|
||||
`coverage=${mumbai.flowCoveragePct}% tiles=${mumbai.tilesFetched}`);
|
||||
if (mumbai.mode !== 'live' || !(mumbai.count > 0) || !(colored > 0)) exitCode = 1;
|
||||
await sleep(1200);
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-flow.png') });
|
||||
}
|
||||
|
||||
// ── (iv) uncovered-roads param — hide vs sim ─────────────────────────────
|
||||
console.log('\n(iv) uncoveredRoads param — hide vs sim...');
|
||||
if ((mumbai.flowBuckets?.sim || 0) > 0) {
|
||||
const hid = await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
const mod = gev.dataManager.layers.get('traffic').module;
|
||||
const before = mod.getStats().lastUpdate;
|
||||
mod.setParams({ uncoveredRoads: 'hide' });
|
||||
const ell = gev.viewer.scene.globe.ellipsoid;
|
||||
const d2r = Math.PI / 180;
|
||||
// Shift far enough to defeat the overlap gate and force a re-render.
|
||||
gev.viewer.camera.setView({
|
||||
destination: ell.cartographicToCartesian({ longitude: -98.51 * d2r, latitude: 29.435 * d2r, height: 2800 }),
|
||||
orientation: { heading: 0.4, pitch: -1.25, roll: 0 },
|
||||
});
|
||||
// Poll for a NEW render (lastUpdate changes) — the pre-shift stats
|
||||
// would otherwise satisfy a count>0 check instantly.
|
||||
let s = null;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
s = mod.getStats();
|
||||
if (s.lastUpdate !== before && s.count > 0 && !s.loading) break;
|
||||
}
|
||||
mod.setParams({ uncoveredRoads: 'sim' });
|
||||
return s;
|
||||
});
|
||||
record('PARAM: hide mode renders zero sim (white) dots',
|
||||
(hid.flowBuckets?.sim || 0) === 0 && hid.count > 0,
|
||||
`count=${hid.count} sim=${hid.flowBuckets?.sim}`);
|
||||
if ((hid.flowBuckets?.sim || 0) !== 0) exitCode = 1;
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-hide-mode.png') });
|
||||
} else {
|
||||
record('PARAM: hide mode renders zero sim (white) dots', null,
|
||||
'view had 100% coverage (no sim dots to hide) — inconclusive here, covered by unit tests');
|
||||
}
|
||||
|
||||
// ── (ii) C4 oblique bounds ───────────────────────────────────────────────
|
||||
console.log('\n(ii) C4 — oblique fetch bounds land at the look-at point...');
|
||||
trafficLogs.length = 0;
|
||||
await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 });
|
||||
{
|
||||
const fetchLine = trafficLogs.find((t) => t.includes('fetch') && t.includes('['));
|
||||
let ok = false; let detail = 'no fetch log captured';
|
||||
if (fetchLine) {
|
||||
const m = fetchLine.match(/\[(-?[\d.]+),(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)\]/);
|
||||
if (m) {
|
||||
const [s, w, n, e] = m.slice(1).map(Number);
|
||||
const cLat = (s + n) / 2; const cLon = (w + e) / 2;
|
||||
const dKm = Math.sqrt(((cLat - 30.245) * 111) ** 2 + ((cLon - (-97.72)) * 111 * Math.cos(30.27 * Math.PI / 180)) ** 2);
|
||||
ok = dKm > 1 && dKm <= 13; // look-at ~6.9 km ahead; clamp allows ≤12 (+margin)
|
||||
detail = `box center (${cLat.toFixed(4)},${cLon.toFixed(4)}) is ${dKm.toFixed(1)} km from camera (want 1–13 km; pre-fix bug: 25+ km)`;
|
||||
}
|
||||
}
|
||||
record('C4: oblique fetch box centers on the look-at point', ok, detail);
|
||||
if (!ok) exitCode = 1;
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-c4-oblique.png') });
|
||||
}
|
||||
|
||||
// ── (iii) budget honesty ─────────────────────────────────────────────────
|
||||
console.log('\n(iii) Budget — dailyCount grew by ≤ requests this run...');
|
||||
{
|
||||
const statusAfter = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json());
|
||||
const grew = statusAfter.dailyCount - statusBefore.dailyCount;
|
||||
const ok = grew >= 0 && grew <= flowRequests.length;
|
||||
record('BUDGET: /api/tomtom/status growth ≤ page tile requests', ok,
|
||||
`before=${statusBefore.dailyCount} after=${statusAfter.dailyCount} pageRequests=${flowRequests.length}`);
|
||||
if (!ok) exitCode = 1;
|
||||
}
|
||||
|
||||
// ── (v) KEYLESS fallback (intercepted — server key untouched) ────────────
|
||||
console.log('\n(v) KEYLESS — intercepted status, expect pure simulation...');
|
||||
await page.setRequestInterception(true);
|
||||
const keylessFlowReqs = [];
|
||||
page.on('request', (req) => {
|
||||
const url = req.url();
|
||||
if (url.includes('/api/tomtom/status')) {
|
||||
req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify({ hasKey: false }) });
|
||||
return;
|
||||
}
|
||||
if (url.includes('/api/tomtom/flow/')) {
|
||||
keylessFlowReqs.push(url);
|
||||
req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) });
|
||||
return;
|
||||
}
|
||||
try { req.continue(); } catch { /* already handled */ }
|
||||
});
|
||||
await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await sleep(1500);
|
||||
let simStats = await settleTraffic(page, { lon: -98.4936, lat: 29.4241, height: 3000 }, { minCount: 100, timeoutS: 45 });
|
||||
if (!simStats || simStats.count === 0) {
|
||||
// Public Overpass can throttle bursts across harness runs — one retry.
|
||||
simStats = await settleTraffic(page, { lon: -98.487, lat: 29.43, height: 3000 }, { minCount: 100, timeoutS: 45 });
|
||||
}
|
||||
{
|
||||
const b = simStats.flowBuckets || {};
|
||||
const allWhite = simStats.count > 0 && b.sim === simStats.count && !b.free && !b.slow && !b.jam;
|
||||
record('KEYLESS: stats.mode === "sim"', simStats.mode === 'sim', `mode=${simStats.mode}`);
|
||||
record('KEYLESS: every dot is white simulation', allWhite,
|
||||
`count=${simStats.count} sim=${b.sim} free=${b.free} slow=${b.slow} jam=${b.jam}`);
|
||||
record('KEYLESS: zero flow-tile requests issued', keylessFlowReqs.length === 0,
|
||||
`flowRequests=${keylessFlowReqs.length}`);
|
||||
// Keyless is a designed fallback, not a fault: it must read SIMULATED
|
||||
// without ever raising a layer error.
|
||||
const keylessHonest = !simStats.error
|
||||
&& String(simStats.loadingLabel || '').startsWith('SIMULATED');
|
||||
record('KEYLESS: no error, and the label reads SIMULATED', keylessHonest,
|
||||
`err=${simStats.error || 'none'} label="${simStats.loadingLabel}"`);
|
||||
if (simStats.mode !== 'sim' || !allWhite || keylessFlowReqs.length !== 0 || !keylessHonest) {
|
||||
exitCode = 1;
|
||||
}
|
||||
await sleep(1000);
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('\x1b[31mHarness error:\x1b[0m', e);
|
||||
exitCode = 3;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const pass = results.filter((r) => r.ok === true).length;
|
||||
const fail = results.filter((r) => r.ok === false).length;
|
||||
const inconclusive = results.filter((r) => r.ok === null).length;
|
||||
console.log('\n' + '─'.repeat(60));
|
||||
console.log(` RESULT: ${pass} passed, ${fail} failed, ${inconclusive} inconclusive`);
|
||||
console.log(` Shots : ${SHOTS_DIR}/traffic-*.png`);
|
||||
console.log('─'.repeat(60) + '\n');
|
||||
process.exit(exitCode || (fail > 0 ? 1 : 0));
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(3); });
|
||||