mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Compare commits
332
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9825b02aab | ||
|
|
a6c64713ee | ||
|
|
7abf74518f | ||
|
|
c502404e1d | ||
|
|
350a7f5abb | ||
|
|
0ee92eb864 | ||
|
|
909be62ae7 | ||
|
|
8bea1419f6 | ||
|
|
e1d3a588fc | ||
|
|
843c4a111d | ||
|
|
43b4e9348b | ||
|
|
727bb29cae | ||
|
|
0c0df50a40 | ||
|
|
db9bf64821 | ||
|
|
a2f5b8fca3 | ||
|
|
06ae95ceb3 | ||
|
|
bc1b56b63a | ||
|
|
b3efcfb7a2 | ||
|
|
1100cdcff5 | ||
|
|
f89aee1eac | ||
|
|
b8305de14b | ||
|
|
f87da8523e | ||
|
|
91685c94d9 | ||
|
|
a56f2f9352 | ||
|
|
43ab3ecaaf | ||
|
|
ebc717df8b | ||
|
|
726e201314 | ||
|
|
90d64e6916 | ||
|
|
cbe1d841ea | ||
|
|
b49f9dddf0 | ||
|
|
125fbc5a3b | ||
|
|
9cc108835e | ||
|
|
8d7f055572 | ||
|
|
227e3c2051 | ||
|
|
ae13a56f6d | ||
|
|
9f8381ff71 | ||
|
|
01ae045346 | ||
|
|
59a2e31aa8 | ||
|
|
b20579b06a | ||
|
|
a9282b30bb | ||
|
|
1a3f6f2486 | ||
|
|
c2d7a6dbb0 | ||
|
|
89339cdd96 | ||
|
|
5983c663bd | ||
|
|
ef174b0ff7 | ||
|
|
6ba67d01af | ||
|
|
d8ef366ab1 | ||
|
|
f0d38897f4 | ||
|
|
d8c63c69d8 | ||
|
|
eee4f48745 | ||
|
|
c392d6fb50 | ||
|
|
074f4af10e | ||
|
|
dcb8de9d2c | ||
|
|
4c28cc0212 | ||
|
|
00dfed7959 | ||
|
|
f7945d6844 | ||
|
|
96b5b0bb1f | ||
|
|
5b7171f485 | ||
|
|
f5c14ec30d | ||
|
|
dea10cac4f | ||
|
|
60a230b2ff | ||
|
|
057dbe24af | ||
|
|
f0d7def9c7 | ||
|
|
1773494116 | ||
|
|
3144169057 | ||
|
|
37ea4e1f93 | ||
|
|
31b1f27a2e | ||
|
|
f2117a98f2 | ||
|
|
40a81c58e6 | ||
|
|
df73c5b21a | ||
|
|
c96d4ecfb1 | ||
|
|
d5218c4722 | ||
|
|
4f8a9f2615 | ||
|
|
1c09c90e13 | ||
|
|
dae4011604 | ||
|
|
299a5943a0 | ||
|
|
f7cd6797ec | ||
|
|
1910a3b2d5 | ||
|
|
c45ec634ee | ||
|
|
d86047fe8f | ||
|
|
58c8a481ed | ||
|
|
63b2590b9f | ||
|
|
4a9b95e31b | ||
|
|
b977ef29e3 | ||
|
|
293ffb2bd3 | ||
|
|
b62f60482e | ||
|
|
b29e20632b | ||
|
|
736ee8d694 | ||
|
|
03cd379c0e | ||
|
|
57f7edc693 | ||
|
|
c34a2c38fc | ||
|
|
8e1a3bab3e | ||
|
|
a99b862580 | ||
|
|
2bac200c01 | ||
|
|
061ed1b682 | ||
|
|
d1fdb9eb45 | ||
|
|
741b6f67eb | ||
|
|
17eea266f1 | ||
|
|
111ba1626c | ||
|
|
05578c61b9 | ||
|
|
35ed499ae4 | ||
|
|
f0c2523ac4 | ||
|
|
b2cc00833d | ||
|
|
5564156c09 | ||
|
|
eed8acc3f7 | ||
|
|
f4879fc24b | ||
|
|
7f0572cf4c | ||
|
|
1a1eef0e5c | ||
|
|
a066a2d1ca | ||
|
|
9cc34e324f | ||
|
|
cf6d04b7db | ||
|
|
7fb11507d5 | ||
|
|
53c114965d | ||
|
|
65d41b971c | ||
|
|
4d2b061e3b | ||
|
|
64c5cd25e6 | ||
|
|
64759c552e | ||
|
|
12cd73bed9 | ||
|
|
baabff6acf | ||
|
|
2853938df7 | ||
|
|
dd040b4f87 | ||
|
|
0c190c3a15 | ||
|
|
62296a833c | ||
|
|
05b646daa3 | ||
|
|
14f7b32537 | ||
|
|
160e445677 | ||
|
|
a49671a391 | ||
|
|
133ca14119 | ||
|
|
24190a3e35 | ||
|
|
c4a97a653c | ||
|
|
b01f1ee7dc | ||
|
|
b98cbeebf2 | ||
|
|
75df3389f3 | ||
|
|
4e09559337 | ||
|
|
811bcaff85 | ||
|
|
605b9490f3 | ||
|
|
5cea3e8cbb | ||
|
|
2b4955c817 | ||
|
|
d7b3d45135 | ||
|
|
c1d829ef51 | ||
|
|
868d061856 | ||
|
|
cb350fa58d | ||
|
|
11992828fb | ||
|
|
87bbe14748 | ||
|
|
81bcae907f | ||
|
|
e08ddd787b | ||
|
|
1bda760763 | ||
|
|
b7431d7823 | ||
|
|
d7ab85f5c4 | ||
|
|
2ee09c5911 | ||
|
|
1930ca247a | ||
|
|
6fe5bda028 | ||
|
|
99bda8a35e | ||
|
|
c0062c4d3d | ||
|
|
a0e5f04c30 | ||
|
|
4bd7f97cc4 | ||
|
|
73ea608881 | ||
|
|
2087b55b47 | ||
|
|
8b179a7e01 | ||
|
|
96ce795f8c | ||
|
|
3a2a53aef9 | ||
|
|
0d15e80e28 | ||
|
|
3cbc17a926 | ||
|
|
5e658d8c9d | ||
|
|
f71c76c306 | ||
|
|
b8f4d097c4 | ||
|
|
1da65dc264 | ||
|
|
dc7a7c4d53 | ||
|
|
15f1f9f5b6 | ||
|
|
c76872dfaa | ||
|
|
8279af2e6d | ||
|
|
0df0fb8261 | ||
|
|
8328aab779 | ||
|
|
9ba5a3aba9 | ||
|
|
f59d134928 | ||
|
|
5a8bc33fbd | ||
|
|
b8560e5b26 | ||
|
|
694616d5c2 | ||
|
|
798d6636ab | ||
|
|
93c71ffc86 | ||
|
|
f72bf6a774 | ||
|
|
311e178d0c | ||
|
|
a72cfc4098 | ||
|
|
46b3aceafc | ||
|
|
9d587c1d37 | ||
|
|
d82a6f1550 | ||
|
|
1a694baf5a | ||
|
|
965a82144c | ||
|
|
f4f0088814 | ||
|
|
c58bc9864b | ||
|
|
5dd05a4a81 | ||
|
|
a9b3125a99 | ||
|
|
a3152ab719 | ||
|
|
c89185f352 | ||
|
|
e8870a4766 | ||
|
|
0600964249 | ||
|
|
766c786093 | ||
|
|
4baefeb8f9 | ||
|
|
1839d62160 | ||
|
|
6bdb995fe3 | ||
|
|
000ef99464 | ||
|
|
83fbe6949f | ||
|
|
c8a4f3ed98 | ||
|
|
69a78710cc | ||
|
|
620ada6df3 | ||
|
|
1fe8267550 | ||
|
|
6b27b21cc4 | ||
|
|
6eec8f6cb0 | ||
|
|
71b7177dc0 | ||
|
|
5ff8c5d7a6 | ||
|
|
ed9ea7e679 | ||
|
|
ea1e2f9c04 | ||
|
|
65e30546dd | ||
|
|
0b8cb855fa | ||
|
|
941b308c29 | ||
|
|
10d0e49ddb | ||
|
|
9dc446bd24 | ||
|
|
3a0a3b0551 | ||
|
|
7c0891deda | ||
|
|
e007350645 | ||
|
|
2da7b6d0d9 | ||
|
|
dcfb1219d5 | ||
|
|
ba1191b2e5 | ||
|
|
132a941e9a | ||
|
|
03a0ff3a01 | ||
|
|
b14bf7e013 | ||
|
|
20e9d4a0fa | ||
|
|
1dd3220812 | ||
|
|
443c581280 | ||
|
|
4dfcba8989 | ||
|
|
d81ebf1268 | ||
|
|
2ce1c55f69 | ||
|
|
7f13ef4a87 | ||
|
|
8e702f0c3b | ||
|
|
9f1a0611fa | ||
|
|
c493a8303b | ||
|
|
bf46eb6d97 | ||
|
|
907cb2092d | ||
|
|
ba039f7974 | ||
|
|
18b5475b8a | ||
|
|
bc281ab246 | ||
|
|
ec7dcc944e | ||
|
|
834d34296f | ||
|
|
d6fe0f3805 | ||
|
|
f0ce1b04c7 | ||
|
|
b0cbed081b | ||
|
|
e1be2eac19 | ||
|
|
7d5a484796 | ||
|
|
311722e6de | ||
|
|
d7193e3b9e | ||
|
|
a4e753d550 | ||
|
|
4e195dac6b | ||
|
|
68ff4e1c25 | ||
|
|
645f042450 | ||
|
|
f640890cf6 | ||
|
|
a89719aba9 | ||
|
|
39d522c9a9 | ||
|
|
d483e5897e | ||
|
|
ae52695b15 | ||
|
|
6436f580d9 | ||
|
|
0957d9712b | ||
|
|
93866c42fc | ||
|
|
e804351205 | ||
|
|
3c2e924363 | ||
|
|
8812117013 | ||
|
|
c327a25744 | ||
|
|
b0cb8ce881 | ||
|
|
86aa0bc9ba | ||
|
|
0109097440 | ||
|
|
4623c8b616 | ||
|
|
76851ee949 | ||
|
|
f7f4e9a45c | ||
|
|
7c7422e281 | ||
|
|
4aaf974600 | ||
|
|
ce985768bc | ||
|
|
da6141be4b | ||
|
|
6dad3894bd | ||
|
|
5188e8869d | ||
|
|
bb4e6bafa4 | ||
|
|
c33ee31efc | ||
|
|
f1a2df5cfe | ||
|
|
27e28373d0 | ||
|
|
26968f4903 | ||
|
|
d50eaa28af | ||
|
|
1cca89adf2 | ||
|
|
a8a2ad5582 | ||
|
|
abf16ae8af | ||
|
|
bb8c0b83c4 | ||
|
|
ac081a008d | ||
|
|
177446c0be | ||
|
|
179c846f3e | ||
|
|
80cc0b536e | ||
|
|
312ed416e9 | ||
|
|
b65dbab709 | ||
|
|
8270172086 | ||
|
|
64d3297301 | ||
|
|
4ded3fbf15 | ||
|
|
306e3e9467 | ||
|
|
72fec292d4 | ||
|
|
d112ecf296 | ||
|
|
c6e1b65c67 | ||
|
|
0b9deddd70 | ||
|
|
ec38e6ef16 | ||
|
|
16e63f4c50 | ||
|
|
a3e929870d | ||
|
|
840e34a881 | ||
|
|
a4ac0b9b0d | ||
|
|
90c8e7763a | ||
|
|
b6ac0e9c99 | ||
|
|
6dbd9c983e | ||
|
|
ab0acb0e09 | ||
|
|
12c9651a67 | ||
|
|
3e3aa0bd9f | ||
|
|
83c13f330d | ||
|
|
be56144d75 | ||
|
|
c2a0d20f3d | ||
|
|
aaa6074663 | ||
|
|
58a196374f | ||
|
|
a5c98c6b07 | ||
|
|
ab2368f3f3 | ||
|
|
d831cbd23a | ||
|
|
cd94a26b9f | ||
|
|
708c96b6f6 | ||
|
|
528ec75f32 | ||
|
|
f2fcd82290 | ||
|
|
e3061872cc | ||
|
|
a915bdb3e5 | ||
|
|
ee8b94d987 | ||
|
|
2b83606765 | ||
|
|
1a69368ae6 | ||
|
|
d886be68a8 | ||
|
|
4a92ed8377 |
No files matched your search
@@ -59,6 +59,10 @@ jobs:
|
||||
- name: Build production bundle
|
||||
run: npm run build
|
||||
|
||||
- name: Main chunk size budget
|
||||
if: matrix.node == '24.22.2'
|
||||
run: npm run check:main-chunk
|
||||
|
||||
street-level-browser:
|
||||
# Hermetic: every Mapillary request is answered from fixtures, so the
|
||||
# dummy token below never leaves the page.
|
||||
|
||||
@@ -33,6 +33,7 @@ How to read this:
|
||||
| **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) |
|
||||
| **EOX Sentinel-2 cloudless 2020** | Optional satellite basemap in Cesium and Navara. Navara uses daylight for terrain inspection. | [EOX Maps](https://maps.eox.at/) and [imagery licence](https://cloudless.eox.at/) | Sentinel-2 cloudless 2020 by EOX IT Services GmbH (contains modified Copernicus Sentinel data 2020) |
|
||||
| **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) |
|
||||
| **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" |
|
||||
| **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" |
|
||||
|
||||
@@ -340,6 +340,8 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea
|
||||
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."_
|
||||
|
||||
Open **Visual Presets → Globe Engine** to switch between Cesium and experimental Navara. The globe switches in place at the current view and remembers your choice. A link with `?engine=cesium` or `?engine=navara` overrides the saved choice. A fresh visit starts with Cesium.
|
||||
|
||||
**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` cockpit · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe.
|
||||
|
||||
---
|
||||
|
||||
+21
-2
@@ -2,8 +2,8 @@
|
||||
|
||||
The project depends on third-party npm packages whose own licenses apply. This
|
||||
file records the packages added for the browser-local SDR feature and the
|
||||
Street Level viewer; the complete resolved dependency inventory remains in
|
||||
`package-lock.json`.
|
||||
Street Level viewer, and the CesiumJS code ported into `src/geo/`; the complete
|
||||
resolved dependency inventory remains in `package-lock.json`.
|
||||
|
||||
## Web RTL-SDR
|
||||
|
||||
@@ -45,3 +45,22 @@ Street Level viewer; the complete resolved dependency inventory remains in
|
||||
- Source: <https://github.com/mrdoob/three.js>
|
||||
- License: MIT
|
||||
- License text: <https://github.com/mrdoob/three.js/blob/dev/LICENSE>
|
||||
|
||||
## CesiumJS (portions ported to src/geo)
|
||||
|
||||
- Package: `cesium` 1.138.0 (`@cesium/engine` 22.3.0); the files in `src/geo/`
|
||||
are adapted from `@cesium/engine/Source/Core` (`Math.js`, `Ellipsoid.js`,
|
||||
`scaleToGeodeticSurface.js`, `Cartographic.js`, `Cartesian2.js`,
|
||||
`Cartesian3.js`, `Matrix3.js`, `Matrix4.js`, `Quaternion.js`,
|
||||
`Transforms.js`) and say so in their first
|
||||
line. The changes: the classes' static functions became module functions,
|
||||
vectors, geodetic positions and quaternions became plain-data instances of
|
||||
module-private constructors (own enumerable fields only, no methods),
|
||||
matrices plain arrays, and the ellipsoid a frozen object; the debug
|
||||
argument checks were left out, as in Cesium's release build; only the subset
|
||||
gev uses was kept.
|
||||
- Author: Copyright 2011-2024 CesiumJS Contributors
|
||||
- Source: <https://github.com/CesiumGS/cesium>
|
||||
- License: Apache License 2.0
|
||||
- License text: `node_modules/@cesium/engine/LICENSE.md` (shipped with the
|
||||
dependency) and <https://www.apache.org/licenses/LICENSE-2.0>
|
||||
@@ -0,0 +1,58 @@
|
||||
// Build guard: the Cesium-mode page must not load three or Navara. The
|
||||
// initial chunks are every entry chunk and what it imports statically;
|
||||
// navaraMode.js reaches Navara only through import(), so three and
|
||||
// @navaramap/* belong to lazy chunks. This checks the bundle's module ids
|
||||
// (what Rollup put in each chunk), and fails the build otherwise.
|
||||
|
||||
/**
|
||||
* Module ids that only the Navara engine may load, when no package list is
|
||||
* given (the browser build passes the full dependency closure,
|
||||
* navaraDependencies.js).
|
||||
*/
|
||||
export const ENGINE_ONLY_MODULES =
|
||||
/[\\/]node_modules[\\/](?:three|@navaramap[\\/][^\\/]+|postprocessing)[\\/]/;
|
||||
|
||||
/**
|
||||
* Engine-only modules in initial chunks of a Rollup bundle, sorted by chunk.
|
||||
* @param {Record<string, { type: string, isEntry?: boolean, imports?: string[], moduleIds?: string[] }>} bundle
|
||||
* @returns {{ chunk: string, module: string }[]}
|
||||
*/
|
||||
export function initialChunkViolations(bundle, pattern = ENGINE_ONLY_MODULES) {
|
||||
const initial = new Set();
|
||||
const visit = (fileName) => {
|
||||
const chunk = bundle[fileName];
|
||||
if (!chunk || chunk.type !== 'chunk' || initial.has(fileName)) return;
|
||||
initial.add(fileName);
|
||||
for (const imported of chunk.imports ?? []) visit(imported);
|
||||
};
|
||||
for (const [fileName, chunk] of Object.entries(bundle))
|
||||
if (chunk.type === 'chunk' && chunk.isEntry) visit(fileName);
|
||||
const violations = [];
|
||||
for (const fileName of [...initial].sort())
|
||||
for (const module of bundle[fileName].moduleIds ?? [])
|
||||
if (pattern.test(module)) violations.push({ chunk: fileName, module });
|
||||
return violations;
|
||||
}
|
||||
|
||||
/** The Rollup plugin: fails generateBundle when an initial chunk holds three or Navara. */
|
||||
export function initialChunkGuard({
|
||||
log = console.log,
|
||||
pattern = ENGINE_ONLY_MODULES,
|
||||
} = {}) {
|
||||
return {
|
||||
name: 'gev-initial-chunk-guard',
|
||||
generateBundle(_options, bundle) {
|
||||
const violations = initialChunkViolations(bundle, pattern);
|
||||
if (violations.length) {
|
||||
this.error(
|
||||
'Navara-only modules in the Cesium-mode initial chunks:\n' +
|
||||
violations
|
||||
.map(({ chunk, module }) => ` ${chunk}: ${module}`)
|
||||
.join('\n'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
log('[initial-chunks] no Navara-only modules in the initial chunks');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/** Where @navaramap/three keeps its prebuilt worker assets, or null. */
|
||||
function defaultAssetsDir() {
|
||||
try {
|
||||
const entry = fileURLToPath(import.meta.resolve('@navaramap/three'));
|
||||
return path.join(path.dirname(entry), 'assets');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navara's prebuilt workers (@navaramap/three/dist/assets) load their wasm by
|
||||
* file name next to themselves (`new URL('x-hash.wasm', self.location)`), a
|
||||
* reference the bundler cannot see. When a build includes Navara (the
|
||||
* ?engine=navara chunk), copy every such wasm the bundle lacks into
|
||||
* `assets/` under its own name. A build without Navara gets none.
|
||||
* @param {{ assetsDir?: string | null }} [options]
|
||||
*/
|
||||
export function navaraWorkerAssets({ assetsDir } = {}) {
|
||||
return {
|
||||
name: 'navara-worker-assets',
|
||||
generateBundle(_options, bundle) {
|
||||
const usesNavara = Object.values(bundle).some(
|
||||
(file) => file.type === 'chunk' && file.code.includes('navara_wasm'),
|
||||
);
|
||||
if (!usesNavara) return;
|
||||
const dir = assetsDir === undefined ? defaultAssetsDir() : assetsDir;
|
||||
if (!dir || !existsSync(dir)) return;
|
||||
for (const name of readdirSync(dir).sort()) {
|
||||
if (!name.endsWith('.wasm')) continue;
|
||||
const fileName = `assets/${name}`;
|
||||
if (bundle[fileName]) continue;
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName,
|
||||
source: readFileSync(path.join(dir, name)),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const NAVARA_MODULE = /\/src\/globe\/navara\//;
|
||||
|
||||
/**
|
||||
* Rollup chunkFileNames: the lazy chunk holding the Navara engine
|
||||
* (src/globe/navara, with the Navara packages it pulls in) is named
|
||||
* `navara-engine-[hash].js`; every other chunk keeps Vite's default name.
|
||||
* Naming only (not manualChunks, which would pull modules shared with the
|
||||
* Cesium entry into the Navara chunk), so the chunking is unchanged.
|
||||
* @param {{ isEntry?: boolean, moduleIds?: string[] }} chunk
|
||||
* @returns {string}
|
||||
*/
|
||||
export function navaraChunkFileNames(chunk) {
|
||||
const navara =
|
||||
!chunk.isEntry &&
|
||||
(chunk.moduleIds ?? []).some((id) =>
|
||||
NAVARA_MODULE.test(id.replaceAll('\\', '/')),
|
||||
);
|
||||
return navara ? 'assets/navara-engine-[hash].js' : 'assets/[name]-[hash].js';
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// The packages only the Navara engine brings: the dependency closure of
|
||||
// @navaramap/three and @navaramap/three-default-plugin (dependencies and
|
||||
// peer dependencies, read from node_modules), without the packages the
|
||||
// app's other bundled dependencies reach too (Cesium is external), plus
|
||||
// three and postprocessing, which
|
||||
// the Cesium-mode page never loads statically either. The initial-chunk
|
||||
// guard (initialChunkGuard.js) fails the build when one of them is in an
|
||||
// initial chunk.
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const NAVARA_ROOTS = Object.freeze([
|
||||
'@navaramap/three',
|
||||
'@navaramap/three-default-plugin',
|
||||
]);
|
||||
const ALWAYS_ENGINE_ONLY = Object.freeze(['three', 'postprocessing']);
|
||||
// Not bundled: vite-plugin-cesium serves Cesium as a script, so what only
|
||||
// Cesium reaches (draco3d, …) is not in the app's bundled graph.
|
||||
const EXTERNAL = Object.freeze(['cesium']);
|
||||
|
||||
function readManifest(root, name) {
|
||||
const file = path.join(root, 'node_modules', name, 'package.json');
|
||||
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null;
|
||||
}
|
||||
|
||||
/** Every package reachable from `names` through installed manifests. */
|
||||
export function dependencyClosure(root, names) {
|
||||
const seen = new Set();
|
||||
const queue = [...names];
|
||||
while (queue.length) {
|
||||
const name = queue.pop();
|
||||
if (seen.has(name)) continue;
|
||||
const manifest = readManifest(root, name);
|
||||
if (!manifest) continue;
|
||||
seen.add(name);
|
||||
for (const next of Object.keys({
|
||||
...manifest.dependencies,
|
||||
...manifest.peerDependencies,
|
||||
}))
|
||||
queue.push(next);
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
/** The engine-only package names, sorted. */
|
||||
export function navaraOnlyPackages(root) {
|
||||
const app = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||
const navara = dependencyClosure(root, NAVARA_ROOTS);
|
||||
const appRoots = Object.keys(app.dependencies ?? {}).filter(
|
||||
(name) =>
|
||||
!navara.has(name) &&
|
||||
!ALWAYS_ENGINE_ONLY.includes(name) &&
|
||||
!EXTERNAL.includes(name),
|
||||
);
|
||||
const shared = dependencyClosure(root, appRoots);
|
||||
return [
|
||||
...new Set([
|
||||
...[...navara].filter((name) => !shared.has(name)),
|
||||
...ALWAYS_ENGINE_ONLY,
|
||||
]),
|
||||
].sort();
|
||||
}
|
||||
|
||||
/** A module-id pattern matching node_modules/<package>/ for the names. */
|
||||
export function packagePattern(names) {
|
||||
const escaped = names.map((name) =>
|
||||
name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace('/', '[\\\\/]'),
|
||||
);
|
||||
return new RegExp(
|
||||
`[\\\\/]node_modules[\\\\/](?:${escaped.join('|')})[\\\\/]`,
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,12 @@ export function panelBuildConfig(config) {
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
...config.build?.rollupOptions,
|
||||
// The panel inlines every dynamic import into its one script, the
|
||||
// experimental Navara mode included, so the guard that keeps Navara
|
||||
// out of the app's initial chunks does not apply here.
|
||||
plugins: (config.build?.rollupOptions?.plugins ?? []).filter(
|
||||
(plugin) => plugin?.name !== 'gev-initial-chunk-guard',
|
||||
),
|
||||
output: { inlineDynamicImports: true },
|
||||
},
|
||||
},
|
||||
|
||||
+36
-2
@@ -5,6 +5,9 @@ import { VUE_PLUGIN_OPTIONS } from './vueOptions.js';
|
||||
import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js';
|
||||
import { embedFramingPlugin } from './embed-framing.js';
|
||||
import { panelBuildPlugin } from './panel.js';
|
||||
import { navaraChunkFileNames, navaraWorkerAssets } from './navaraAssets.js';
|
||||
import { initialChunkGuard } from './initialChunkGuard.js';
|
||||
import { navaraOnlyPackages, packagePattern } from './navaraDependencies.js';
|
||||
|
||||
/**
|
||||
* The repository root (this file lives at `<root>/build/vite.js`), as a
|
||||
@@ -183,7 +186,9 @@ export function createBrowserViteConfig({
|
||||
apiResponseHeadersPlugin(),
|
||||
// Next, so no provider route answers a Host the server does not allow.
|
||||
hostCheckPlugin(),
|
||||
cesium(),
|
||||
// Keep the SDK asset serving/copying and external imports, but let the
|
||||
// scene load the script and stylesheet only when Cesium is requested.
|
||||
{ ...cesium(), transformIndexHtml: undefined },
|
||||
// Application views are Vue single-file components.
|
||||
vue(VUE_PLUGIN_OPTIONS),
|
||||
...plugins,
|
||||
@@ -206,6 +211,18 @@ export function createBrowserViteConfig({
|
||||
// Loaded on demand when the first street-level image opens.
|
||||
'mapillary-js',
|
||||
],
|
||||
// Navara's wasm-bindgen glue loads new URL('x_bg.wasm', import.meta.url)
|
||||
// and its workers by URL; both must resolve next to the package's own
|
||||
// files, not inside .vite/deps. Dev server only: the experimental
|
||||
// Navara engine is a dynamic import outside the Cesium-mode bundle.
|
||||
exclude: [
|
||||
'@navaramap/three',
|
||||
'@navaramap/three-default-plugin',
|
||||
'@navaramap/engine',
|
||||
'@navaramap/engine-api',
|
||||
'@navaramap/engine-worker',
|
||||
'@navaramap/engine-font-worker',
|
||||
],
|
||||
},
|
||||
server: {
|
||||
host: host || 'localhost',
|
||||
@@ -258,6 +275,23 @@ export function createBrowserViteConfig({
|
||||
mapillaryToken ?? '',
|
||||
),
|
||||
},
|
||||
build: { chunkSizeWarningLimit: 1500 },
|
||||
build: {
|
||||
chunkSizeWarningLimit: 1500,
|
||||
// Build only: the wasm Navara's workers load by name (the Navara
|
||||
// engine is a separate chunk loaded on ?engine=navara).
|
||||
// The lazy chunk with the Navara engine is named navara-engine.
|
||||
rollupOptions: {
|
||||
// initialChunkGuard: no three/Navara module in the initial chunks.
|
||||
plugins: [
|
||||
navaraWorkerAssets(),
|
||||
initialChunkGuard({
|
||||
pattern: packagePattern(
|
||||
navaraOnlyPackages(fileURLToPath(new URL('..', import.meta.url))),
|
||||
),
|
||||
}),
|
||||
],
|
||||
output: { chunkFileNames: navaraChunkFileNames },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
- Add Navara, an experimental three.js/WASM globe, as a second engine next to
|
||||
Cesium, switchable live under Display → Globe Engine or with
|
||||
`?engine=navara`. Navara draws at CSS pixels like Cesium; Navara HD
|
||||
(`?engine=navara-hd`) draws at the screen's full resolution and needs about
|
||||
1 GiB more on a Retina screen. Layers keep their data across a switch and
|
||||
draw on both engines; Cesium loads only when a Cesium globe or a Cesium-only
|
||||
feature needs it (German, @gekh, #1555).
|
||||
+16
-4
@@ -32,6 +32,8 @@ export inventory. Use declared exports rather than reaching into internal files.
|
||||
| `src/data/` | Lifecycle, context and feed state; legacy default-layer facades |
|
||||
| `src/layers/<family>/` | Source acquisition, records and Cesium resources with explicit controller/renderer owners |
|
||||
| `src/sources/` | Portable protocols and source contracts; request state belongs to each factory instance |
|
||||
| `src/geo/` | Engine-neutral WGS84 maths, ported from Cesium, never imports Cesium |
|
||||
| `src/globe/` | Engine-neutral globe contract and its test support; engines only in `globe/cesium`, `globe/navara` |
|
||||
| `src/services/` | Supplied application operations; scene construction owns caches and cancellation |
|
||||
| `src/voice/` | Portable action schemas/session, common controls, action execution and separate protocol adapters |
|
||||
| `server/providers/` | Node route factories, process-scoped provider caches and shutdown cleanup |
|
||||
@@ -53,15 +55,25 @@ See [application construction](APPLICATION.md) and the
|
||||
2. `scripts/check-package-boundaries.mjs` builds every declared export without
|
||||
app Vite configuration or environment files. `scripts/package-boundaries.json`
|
||||
assigns each export exactly once and lists its owned modules and external
|
||||
dependencies. Unused imports still count. Node exports have only a `node`
|
||||
dependencies; a module entry ending in `/` (such as `src/geo/`) owns every
|
||||
file under that directory. Unused imports still count. Node exports have only a `node`
|
||||
condition; browser groups cannot use build-only dependency exceptions.
|
||||
|
||||
Portable source graphs cannot reach application/rendering, Node, Cesium or
|
||||
browser globals. This includes `sources/*`, dedicated `layers/*/source` exports,
|
||||
flight/military/vessel record and ingestion exports, action schemas, the session
|
||||
interface, lifecycle and feed state. The browser-global rule reserves platform
|
||||
names such as `document` and `window` in these modules; it is an architectural
|
||||
check, not a JavaScript sandbox. Common voice controls cannot depend on a
|
||||
interface, lifecycle and feed state, and every runtime file in `src/geo/` (the
|
||||
engine-neutral geometry ported from Cesium). The browser-global rule reserves
|
||||
platform names such as `document` and `window` in these modules; it is an
|
||||
architectural check, not a JavaScript sandbox. The globe contract
|
||||
(`src/globe/*.js` and `src/globe/testing/**`) cannot reach `cesium`,
|
||||
`@cesium/*`, `three`, `postprocessing` or `@navaramap/*`, directly or through
|
||||
helpers, nor import an engine implementation; the `src/globe/cesium/**` graph
|
||||
reaches no Navara/three package or `src/globe/navara/**` module, and the
|
||||
`src/globe/navara/**` graph no Cesium package or `src/globe/cesium/**` module,
|
||||
through helpers too. Outside its own directory, `src/globe/navara/**` is
|
||||
reached only through a literal `import()` (the app's `?engine=navara` mode), so
|
||||
Navara stays out of the Cesium-mode bundle. Common voice controls cannot depend on a
|
||||
Realtime protocol implementation. Negative fixtures cover indirect helpers,
|
||||
self-package imports, symlinks and unreachable files.
|
||||
|
||||
|
||||
+86
-1
@@ -98,6 +98,91 @@ across the antimeridian, so they cannot use the strict ±180 check as-is, and
|
||||
`src/sources/adsbRecords.js` and `server/providers/common/geo.js` keep their own
|
||||
implementations.
|
||||
|
||||
## Navara globe engine — November 5, 2026
|
||||
|
||||
Navara, an experimental three.js/WASM globe (`@navaramap/three` 0.2.1), runs
|
||||
next to Cesium. It is picked under Display → Globe Engine or with
|
||||
`?engine=navara`; Cesium stays the default. Switching is live: the page keeps
|
||||
one application, and only the globe-bound parts move to the next engine.
|
||||
|
||||
- **Two Navara choices** (`src/app/engineChoice.js`). **Navara** draws at CSS
|
||||
pixels without MSAA, as Cesium does; SMAA still smooths edges. **Navara HD**
|
||||
(`?engine=navara-hd`) draws at the device pixel ratio with MSAA 4. Both are
|
||||
the `navara` engine to the layers; a pick between them builds the other
|
||||
variant cold and releases the first, so only one Navara holds memory.
|
||||
Measured in headless Chrome (Dubai, 1280×800): Navara 1.6 GiB at DPR 1 and
|
||||
1.8 GiB at DPR 2, Navara HD 1.8 and 2.7 GiB, Cesium 0.93 and 1.08 GiB
|
||||
(Navara used 1.95 and 3.0 GiB before).
|
||||
- **Memory.** The canvas context has no antialias (Navara draws into its own
|
||||
targets and copies the result), the cloud shadow maps use the low preset's
|
||||
256² and 2 cascades, and the tile cache budget is the screen-sized targets
|
||||
plus 768 MiB instead of Navara's default of up to 2 GiB, which a long session
|
||||
filled. A released Navara keeps no JS: catalog closures capture the
|
||||
operations, not the scene; a released globe aborts with a reason made at
|
||||
module load (a fresh one keeps the stack, and with it the scene); and
|
||||
`Pass.fullscreenGeometry` from postprocessing is disposed after the view,
|
||||
which otherwise pins the canvas through a three listener.
|
||||
|
||||
- **The engine contract** is `src/globe/contract.js`. `src/globe/cesium/*` and
|
||||
`src/globe/navara/*` implement it, `src/globe/testing/fakeEngine.js` stands
|
||||
in for tests, and `src/globe/testing/contractSuite.mjs` runs the same checks
|
||||
on every engine. `movers` and `lines` are optional capabilities: only
|
||||
Navara has them, and the suite skips their checks elsewhere.
|
||||
- **The session owns the state.** Catalog, layer models, sources, selection,
|
||||
camera mode and URL state outlive a switch. A layer that is a session model
|
||||
gets `attachRenderer(engine)` / `detachRenderer()` instead of being rebuilt,
|
||||
so its data is not fetched again. The outgoing globe is parked
|
||||
(`src/standalone/rendererCache.js`), so switching back within three minutes
|
||||
is warm (60-90 ms). After that the parked globe is disposed and its WebGL
|
||||
context lost, which frees about 1.1 GiB for a parked Navara; switching back
|
||||
then builds it cold (1.5-2 s). `?parkedMs=` shortens the delay for QA
|
||||
(`scripts/qa-strict-parked-release.mjs`).
|
||||
- **Renderers per engine.** Each layer keeps its model and display policy in
|
||||
engine-neutral files and draws through `render/cesium*.js` or
|
||||
`render/navara*.js`. Cesium renderers are listed in
|
||||
`src/app/layers/renderers.js` (`CESIUM_LAYER_RENDERERS`), Navara ones are
|
||||
loaded by `registerNavaraLayerRenderers` in `src/app/navaraMode.js`. Navara
|
||||
ground effects (heat, pulses, trails, pads, drapes) are declared with
|
||||
`defineGroundEffect` (`src/layers/shared/render/navaraGroundEffect.js`);
|
||||
their insert order is pinned by `navaraGroundOrder.test.mjs`.
|
||||
- **Cesium only on demand.** The production build loads `/cesium/Cesium.js`
|
||||
when a Cesium globe starts or a Navara user turns on a feature that still
|
||||
uses Cesium's maths or entity model (traffic, Street Level, the Bhote Koshi
|
||||
event and locator, geo import; `src/app/loadCesium.js`). App code does its maths with `src/geo`, a Cesium-free
|
||||
port of the vector, ellipsoid, transform and time functions that
|
||||
`bitExact.test.mjs` compares with Cesium bit for bit.
|
||||
**Invariant: no module reads Cesium while it loads**
|
||||
(`scripts/check-cesium-load-time.mjs`, run by `npm test`).
|
||||
- **Boundaries.** Cesium is imported only from `render/cesium*.js` and
|
||||
`src/globe/cesium/*`; three, postprocessing and Navara only from `navara*`
|
||||
modules, which load through `import()`. `build/initialChunkGuard.js` fails
|
||||
the build when one of them reaches an initial chunk, and
|
||||
`npm run check:main-chunk` holds the main chunk to its gzip budget
|
||||
(`scripts/main-chunk-baseline.json`). Only `src/globe/navara/internals.js`
|
||||
touches Navara's private members.
|
||||
- **Navara 0.2.1 workaround.** The SDK keeps feature-change events of deleted
|
||||
vector layers in its queue, so the view never reaches idle. They are dropped
|
||||
each frame (`src/globe/navara/featureQueue.js`).
|
||||
- **Strict acceptance.** `node scripts/qa-all-layer-switches.mjs <origin> <out>
|
||||
--serve` builds the commit keyless in a clean worktree and runs every layer
|
||||
and switch scenario under a deny-by-default network policy, with free map
|
||||
tiles from a disk cache and a trap that fails any read of Cesium before it
|
||||
has loaded. Every catalog layer must be asserted on both engines. Point
|
||||
`PUPPETEER_EXECUTABLE_PATH` at an installed Chrome if Puppeteer's pinned
|
||||
build is missing. With an OSM base map Navara lights the globe by the real
|
||||
sun, so scenarios over Austin or San Francisco see a dark frame at local
|
||||
night and fail their pixel checks; run them in local daylight.
|
||||
- **Navara 0.2.1 workaround.** The SDK never frees pick batch ids, and the
|
||||
mover banks rebuild their instanced meshes on each attach, so every switch
|
||||
leaked one id per instance of capacity (about 260 KiB of WebAssembly memory).
|
||||
A per-view pool hands released ids back out
|
||||
(`src/globe/navara/movers/batchIds.js`); the long soak asserts both JS
|
||||
objects and native memory stay flat.
|
||||
- **Navara 0.2.1 workaround.** The first `layer.update({ show: false })` after
|
||||
a vector layer is added is sometimes dropped, so a zone turned off right
|
||||
after a switch stayed drawn. Polygon displays hide by deleting their layers
|
||||
and rebuild them when shown (`src/layers/shared/render/navaraPolygons.js`).
|
||||
|
||||
## Prohibited & Restricted Airspace — FAA special-use airspace
|
||||
|
||||
The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`,
|
||||
@@ -5585,7 +5670,7 @@ and unreachable upstream (502/504) separately from road geometry.
|
||||
> branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on
|
||||
> `main` + 26 route pins; the same tree on Node 24 reports 2,295.
|
||||
|
||||
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee.
|
||||
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per frame of the globe engine (Cesium or Navara) through the globe contract. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, the unchanged Globe View destination is checked against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry fails closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee.
|
||||
- **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing.
|
||||
- **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped.
|
||||
- Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet).
|
||||
|
||||
@@ -106,7 +106,7 @@ probability of about 255 in 256 (many of them already fail as base64url).
|
||||
| 12 | `panelState` | LEN | none | packed panel entries, see panels |
|
||||
| 13 | thing | LEN | none | sub-message, see thing views |
|
||||
| 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow (retired) |
|
||||
| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm |
|
||||
| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm, sentinel2 |
|
||||
| 16 | `hudVariant` | enum | style | tactical, operator (retired), minimal, cyber |
|
||||
| 17 | `bloomIntensity` | uint | style | |
|
||||
| 18 | `bloomVersion` | uint | 2 | |
|
||||
|
||||
Generated
+1112
-5
File diff suppressed because it is too large.
Load diff
+7
-1
@@ -53,21 +53,27 @@
|
||||
"qa:street-level:fixtures": "node scripts/qa-street-level.mjs --fixtures",
|
||||
"check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs",
|
||||
"qa:transit": "node scripts/qa-transit.mjs",
|
||||
"mcp": "node server/mcp/stdio.js"
|
||||
"mcp": "node server/mcp/stdio.js",
|
||||
"check:main-chunk": "node scripts/check-main-chunk.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jtarrio/signals": "^0.10.0",
|
||||
"@jtarrio/webrtlsdr": "^3.0.6",
|
||||
"@mapbox/vector-tile": "^3.0.0",
|
||||
"@meri-imperiumi/eccodes-wasm": "^2.48.2",
|
||||
"@navaramap/three": "0.2.1",
|
||||
"@navaramap/three-default-plugin": "0.2.1",
|
||||
"cesium": "^1.124.0",
|
||||
"egm96-universal": "^1.1.1",
|
||||
"hls.js": "^1.7.3",
|
||||
"kdbush": "4.0.2",
|
||||
"mapillary-js": "^4.1.2",
|
||||
"mgrs": "^2.1.0",
|
||||
"pbf": "^5.1.2",
|
||||
"pinia": "^2.3.1",
|
||||
"postprocessing": "6.39.4",
|
||||
"satellite.js": "^6.0.2",
|
||||
"three": "0.185.1",
|
||||
"vue": "^3.5.44",
|
||||
"vue-i18n": "^11.4.15"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// The production build replaces every `cesium` import with the global that
|
||||
// /cesium/Cesium.js defines, and that script loads only when a Cesium globe
|
||||
// first starts (src/app/loadCesium.js). A module that reads a Cesium binding
|
||||
// while it loads would therefore throw on a page that starts on Navara. This
|
||||
// check lists such reads: a reference to a binding imported from `cesium`, or
|
||||
// to the `Cesium` global, that runs when its module is evaluated (top level,
|
||||
// class heritage, static members, immediately invoked functions) rather than
|
||||
// inside a function that runs later.
|
||||
//
|
||||
// node scripts/check-cesium-load-time.mjs [--json]
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { parse } from '@babel/parser';
|
||||
|
||||
const root = new URL('..', import.meta.url);
|
||||
|
||||
/** Files under src/ that the app ships (tests and test support excluded). */
|
||||
export function sourceFiles() {
|
||||
return execFileSync('git', ['ls-files', 'src'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
.split('\n')
|
||||
.filter(
|
||||
(path) =>
|
||||
/\.(?:js|mjs)$/.test(path) &&
|
||||
!/\.test\.m?js$/.test(path) &&
|
||||
!path.startsWith('src/testSupport/') &&
|
||||
!path.includes('/testing/'),
|
||||
);
|
||||
}
|
||||
|
||||
const FUNCTION_TYPES = new Set([
|
||||
'FunctionDeclaration',
|
||||
'FunctionExpression',
|
||||
'ArrowFunctionExpression',
|
||||
'ObjectMethod',
|
||||
'ClassMethod',
|
||||
'ClassPrivateMethod',
|
||||
]);
|
||||
|
||||
/** Names a module binds to `cesium` imports. */
|
||||
function cesiumBindings(program) {
|
||||
const names = new Set();
|
||||
for (const node of program.body) {
|
||||
if (node.type !== 'ImportDeclaration' || node.source.value !== 'cesium')
|
||||
continue;
|
||||
for (const specifier of node.specifiers) names.add(specifier.local.name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function isReference(node, parent, key) {
|
||||
if (node.type !== 'Identifier') return false;
|
||||
if (!parent) return true;
|
||||
if (
|
||||
(parent.type === 'MemberExpression' ||
|
||||
parent.type === 'OptionalMemberExpression') &&
|
||||
key === 'property' &&
|
||||
!parent.computed
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
(parent.type === 'ObjectProperty' ||
|
||||
parent.type === 'ClassProperty' ||
|
||||
parent.type === 'ObjectMethod' ||
|
||||
parent.type === 'ClassMethod') &&
|
||||
key === 'key' &&
|
||||
!parent.computed
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
parent.type === 'ImportSpecifier' ||
|
||||
parent.type === 'ImportNamespaceSpecifier' ||
|
||||
parent.type === 'ImportDefaultSpecifier' ||
|
||||
parent.type === 'ExportSpecifier'
|
||||
)
|
||||
return false;
|
||||
if (parent.type === 'LabeledStatement' || parent.type === 'BreakStatement')
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load-time references in one module. `deferred` is true below a function
|
||||
* body that runs later; an immediately invoked function and a class's static
|
||||
* members and heritage run at load and stay not deferred.
|
||||
*/
|
||||
export function loadTimeReferences(code, path = '<module>') {
|
||||
const program = parse(code, {
|
||||
sourceType: 'module',
|
||||
plugins: ['classProperties', 'classStaticBlock', 'importAttributes'],
|
||||
}).program;
|
||||
const names = cesiumBindings(program);
|
||||
const found = [];
|
||||
// A re-export from `cesium` becomes a read of the global's property when
|
||||
// the module loads.
|
||||
for (const node of program.body)
|
||||
if (
|
||||
(node.type === 'ExportNamedDeclaration' ||
|
||||
node.type === 'ExportAllDeclaration') &&
|
||||
node.source?.value === 'cesium'
|
||||
)
|
||||
found.push({
|
||||
path,
|
||||
line: node.loc.start.line,
|
||||
name: 'export from cesium',
|
||||
});
|
||||
const hasGlobal = /\bCesium\b/.test(code);
|
||||
if (!names.size && !hasGlobal) return found;
|
||||
// A `Cesium` global reference counts when the module does not bind it.
|
||||
const watched = (name) =>
|
||||
names.has(name) || (name === 'Cesium' && !names.has('Cesium'));
|
||||
const visit = (node, parent, key, deferred) => {
|
||||
if (!node || typeof node.type !== 'string') return;
|
||||
if (node.type === 'ImportDeclaration') return;
|
||||
if (!deferred && isReference(node, parent, key) && watched(node.name))
|
||||
found.push({ path, line: node.loc.start.line, name: node.name });
|
||||
let childDeferred = deferred;
|
||||
if (FUNCTION_TYPES.has(node.type)) {
|
||||
const invoked =
|
||||
parent &&
|
||||
(parent.type === 'CallExpression' || parent.type === 'NewExpression') &&
|
||||
key === 'callee';
|
||||
childDeferred = deferred || !invoked;
|
||||
}
|
||||
if (
|
||||
(node.type === 'ClassProperty' || node.type === 'ClassPrivateProperty') &&
|
||||
!node.static
|
||||
)
|
||||
childDeferred = true;
|
||||
for (const [childKey, value] of Object.entries(node)) {
|
||||
if (
|
||||
childKey === 'loc' ||
|
||||
childKey === 'leadingComments' ||
|
||||
childKey === 'trailingComments' ||
|
||||
childKey === 'innerComments'
|
||||
)
|
||||
continue;
|
||||
// Computed keys and parameter defaults of a deferred function: its key
|
||||
// is evaluated with the enclosing scope, its body later.
|
||||
const keyDeferred =
|
||||
FUNCTION_TYPES.has(node.type) && childKey === 'key'
|
||||
? deferred
|
||||
: childDeferred;
|
||||
if (Array.isArray(value))
|
||||
for (const child of value) visit(child, node, childKey, keyDeferred);
|
||||
else if (value && typeof value === 'object')
|
||||
visit(value, node, childKey, keyDeferred);
|
||||
}
|
||||
};
|
||||
visit(program, null, null, false);
|
||||
return found;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const found = [];
|
||||
for (const path of sourceFiles()) {
|
||||
const code = readFileSync(new URL(path, root), 'utf8');
|
||||
try {
|
||||
found.push(...loadTimeReferences(code, path));
|
||||
} catch (error) {
|
||||
console.error(`${path}: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
if (process.argv.includes('--json'))
|
||||
console.log(JSON.stringify(found, null, 2));
|
||||
else
|
||||
for (const { path, line, name } of found)
|
||||
console.log(`${path}:${line} reads ${name} while the module loads`);
|
||||
if (found.length) {
|
||||
console.error(
|
||||
`${found.length} load-time Cesium read(s); a page that starts on Navara has no Cesium global yet.`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} else console.log('No module reads Cesium while it loads.');
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { loadTimeReferences, sourceFiles } from './check-cesium-load-time.mjs';
|
||||
|
||||
const reads = (code) =>
|
||||
loadTimeReferences(code).map(({ line, name }) => `${line}:${name}`);
|
||||
|
||||
test('reads that run while a module loads are found; reads inside later code are not', () => {
|
||||
assert.deepEqual(
|
||||
reads(`import * as Cesium from 'cesium';
|
||||
const color = Cesium.Color.RED;
|
||||
export function later() { return Cesium.Color.BLUE; }
|
||||
const lazy = () => Cesium.Math.PI;
|
||||
class Shape { static axis = Cesium.Axis.Y; field = Cesium.Axis.Z; draw() { return Cesium; } }
|
||||
(() => Cesium.VERSION)();
|
||||
export const fallback = (sdk = Cesium) => sdk;`),
|
||||
['2:Cesium', '5:Cesium', '6:Cesium'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
reads(`import { Cartesian3 as C3, Color } from 'cesium';
|
||||
export const ORIGIN = C3.ZERO;
|
||||
export function white() { return Color.WHITE; }`),
|
||||
['2:C3'],
|
||||
);
|
||||
assert.deepEqual(reads(`export { Axis } from 'cesium';`), [
|
||||
'1:export from cesium',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
reads(`window.__CESIUM__ = Cesium;`),
|
||||
['1:Cesium'],
|
||||
'the global counts where the module does not import it',
|
||||
);
|
||||
assert.deepEqual(reads(`const geo = { Cesium: 1 }; geo.Cesium;`), []);
|
||||
});
|
||||
|
||||
test('no shipped module reads Cesium while it loads', () => {
|
||||
const found = sourceFiles().flatMap((path) =>
|
||||
loadTimeReferences(readFileSync(path, 'utf8'), path),
|
||||
);
|
||||
assert.deepEqual(found, []);
|
||||
});
|
||||
@@ -19,6 +19,63 @@ const tests = (file) =>
|
||||
const source = (file) =>
|
||||
file.startsWith('src/sources/') ||
|
||||
/^src\/layers\/[^/]+\/(?:source|bundledSource|flowSource)\.js$/.test(file);
|
||||
// Engine-neutral geometry ported from Cesium; every runtime file is a portable root.
|
||||
const geo = (file) => file.startsWith('src/geo/');
|
||||
// The app clock (src/time): plain numbers, no engine, no DOM.
|
||||
const time = (file) => file.startsWith('src/time/');
|
||||
// The engine-neutral globe contract and its test support; engines live below it.
|
||||
const globeContract = (file) =>
|
||||
/^src\/globe\/[^/]+$/.test(file) || file.startsWith('src/globe/testing/');
|
||||
const globeEngine = (file) => /^src\/globe\/(?:cesium|navara)\//.test(file);
|
||||
const cesiumPackage = /^(?:cesium(?:\/|$)|@cesium\/)/;
|
||||
// Navara renders through three.js and its postprocessing peer.
|
||||
const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
|
||||
// Layers built on layer renderers keep each engine in their renderers:
|
||||
// cesium only in render/cesium*.js, three and Navara only in
|
||||
// render/navara*.js. The list grows with each layer moved onto renderers.
|
||||
const RENDERED_LAYER_DIRS = [
|
||||
'src/layers/alpr/',
|
||||
'src/layers/airQuality/',
|
||||
'src/layers/gnss/',
|
||||
'src/layers/perimeters/',
|
||||
'src/layers/radio/',
|
||||
'src/layers/bikeshare/',
|
||||
'src/layers/earthquakes/',
|
||||
'src/layers/firms/',
|
||||
'src/layers/flights/',
|
||||
'src/layers/military/',
|
||||
'src/layers/localAdsb/',
|
||||
'src/layers/vessels/',
|
||||
'src/layers/satellites/',
|
||||
'src/layers/launches/',
|
||||
'src/layers/aircraft/',
|
||||
'src/layers/shared/',
|
||||
];
|
||||
const W1_SHARED_FILES = new Set(
|
||||
[
|
||||
'trackedCamera',
|
||||
'trailRenderer',
|
||||
'contactTrailRenderer',
|
||||
'focusDeemphasis',
|
||||
'spriteOrder',
|
||||
'iconOrientation',
|
||||
'modelVisualAnchor',
|
||||
'trackingClickGesture',
|
||||
].map((name) => `src/data/${name}.js`),
|
||||
);
|
||||
const renderedLayer = (file) =>
|
||||
W1_SHARED_FILES.has(file) ||
|
||||
file.startsWith('src/data/render/') ||
|
||||
file === 'src/celestialRing.js' ||
|
||||
file.startsWith('src/celestialRing/') ||
|
||||
RENDERED_LAYER_DIRS.some((dir) => file.startsWith(dir));
|
||||
const cesiumLayerRenderer = (file) =>
|
||||
/\/render\/cesium(?:[^/]*|\/[^/]+)\.js$/.test(file);
|
||||
// Navara renderers outside a layer's render/ directory: like those, they
|
||||
// reach the app only through the Navara mode's import().
|
||||
const NAVARA_SCENE_RENDERERS = new Set(['src/scenes/dataPacks/navara.js']);
|
||||
const navaraLayerRenderer = (file) =>
|
||||
/\/render\/navara[^/]*\.js$/.test(file) || NAVARA_SCENE_RENDERERS.has(file);
|
||||
const renderer = (file) =>
|
||||
/^src\/(?:ui|app|standalone|overlays)\//.test(file) ||
|
||||
/^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test(
|
||||
@@ -87,7 +144,9 @@ export function checkImportDirections(root) {
|
||||
}
|
||||
let analysis;
|
||||
try {
|
||||
analysis = analyzeModule(readFileSync(resolved, 'utf8'));
|
||||
analysis = analyzeModule(readFileSync(resolved, 'utf8'), {
|
||||
dynamic: true,
|
||||
});
|
||||
} catch (error) {
|
||||
report(file, error.message);
|
||||
return null;
|
||||
@@ -109,6 +168,18 @@ export function checkImportDirections(root) {
|
||||
target = path.resolve(root, exported);
|
||||
} else {
|
||||
record.external.push(specifier);
|
||||
if (renderedLayer(file) && !tests(file)) {
|
||||
if (cesiumPackage.test(specifier) && !cesiumLayerRenderer(file))
|
||||
report(
|
||||
file,
|
||||
`cesium only in the layer's render/cesium*.js: ${specifier}`,
|
||||
);
|
||||
if (navaraPackage.test(specifier) && !navaraLayerRenderer(file))
|
||||
report(
|
||||
file,
|
||||
`three and Navara only in the layer's render/navara*.js: ${specifier}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
file.startsWith('src/') &&
|
||||
(builtins.has(specifier) || specifier.startsWith('node:'))
|
||||
@@ -131,6 +202,32 @@ export function checkImportDirections(root) {
|
||||
continue;
|
||||
}
|
||||
record.edges.push(to);
|
||||
// Navara stays out of the Cesium-mode bundle: outside its own directory
|
||||
// it is reached only through import() (src/app/scene.js), which the
|
||||
// bundler splits into a chunk loaded on ?engine=navara.
|
||||
if (
|
||||
to.startsWith('src/globe/navara/') &&
|
||||
!file.startsWith('src/globe/navara/') &&
|
||||
!analysis.dynamicImports.includes(specifier)
|
||||
)
|
||||
report(
|
||||
file,
|
||||
`The Navara engine loads only through a dynamic import(): ${to}`,
|
||||
);
|
||||
// Like the engine, a layer's Navara renderer reaches the app only
|
||||
// through the Navara mode's import() (src/app/navaraMode.js).
|
||||
if (
|
||||
navaraLayerRenderer(to) &&
|
||||
!navaraLayerRenderer(file) &&
|
||||
!(
|
||||
file === 'src/app/navaraMode.js' &&
|
||||
analysis.dynamicImports.includes(specifier)
|
||||
)
|
||||
)
|
||||
report(
|
||||
file,
|
||||
`A Navara layer renderer loads only through the Navara mode's dynamic import(): ${to}`,
|
||||
);
|
||||
if (file.startsWith('src/') && to.startsWith('server/'))
|
||||
report(file, `Browser imports server: ${to}`);
|
||||
if (
|
||||
@@ -177,6 +274,8 @@ export function checkImportDirections(root) {
|
||||
for (const file of files.filter((file) => file.startsWith('src/')))
|
||||
browser(file);
|
||||
const roots = new Map(files.filter(source).map((file) => [file, 'source']));
|
||||
for (const file of files.filter(geo)) roots.set(file, 'geo');
|
||||
for (const file of files.filter(time)) roots.set(file, 'time');
|
||||
for (const [key, value] of Object.entries(pkg.exports || {})) {
|
||||
if (portableExport(key) && typeof value === 'string')
|
||||
roots.set(value.replace(/^\.\//, ''), key);
|
||||
@@ -202,7 +301,12 @@ export function checkImportDirections(root) {
|
||||
if (
|
||||
builtins.has(specifier) ||
|
||||
specifier.startsWith('node:') ||
|
||||
/^(?:cesium|vite)(?:\/|$)/.test(specifier)
|
||||
/^(?:cesium|vite)(?:\/|$)/.test(specifier) ||
|
||||
// Geo must not reach Cesium through its workspace packages either.
|
||||
(label === 'geo' && /^@cesium\//.test(specifier)) ||
|
||||
// Neither engine reaches the app clock.
|
||||
(label === 'time' &&
|
||||
(cesiumPackage.test(specifier) || navaraPackage.test(specifier)))
|
||||
)
|
||||
report(
|
||||
file,
|
||||
@@ -217,6 +321,48 @@ export function checkImportDirections(root) {
|
||||
}
|
||||
visit(start);
|
||||
}
|
||||
// The globe contract reaches no engine, directly or through helpers; each
|
||||
// engine implementation imports only its own engine's packages.
|
||||
const globeSeen = new Set();
|
||||
function globe(file) {
|
||||
if (globeSeen.has(file) || !code.test(file)) return;
|
||||
globeSeen.add(file);
|
||||
const info = load(file);
|
||||
if (!info) return;
|
||||
for (const specifier of info.external) {
|
||||
if (cesiumPackage.test(specifier) || navaraPackage.test(specifier))
|
||||
report(file, `globe contract reaches engine dependency: ${specifier}`);
|
||||
}
|
||||
for (const to of info.edges) {
|
||||
if (globeEngine(to))
|
||||
report(file, `globe contract imports engine implementation: ${to}`);
|
||||
else globe(to);
|
||||
}
|
||||
}
|
||||
for (const file of files.filter(globeContract)) globe(file);
|
||||
// Each engine graph stays on its own engine, through helpers too.
|
||||
for (const start of files.filter(globeEngine)) {
|
||||
const navara = start.startsWith('src/globe/navara/');
|
||||
const label = navara ? 'Navara globe engine' : 'Cesium globe engine';
|
||||
const foreignPackage = navara ? cesiumPackage : navaraPackage;
|
||||
const foreignEngine = navara ? 'src/globe/cesium/' : 'src/globe/navara/';
|
||||
const seen = new Set();
|
||||
const visit = (file) => {
|
||||
if (seen.has(file) || !code.test(file)) return;
|
||||
seen.add(file);
|
||||
const info = load(file);
|
||||
if (!info) return;
|
||||
for (const specifier of info.external)
|
||||
if (foreignPackage.test(specifier))
|
||||
report(file, `${label} reaches ${specifier}`);
|
||||
for (const to of info.edges) {
|
||||
if (to.startsWith(foreignEngine))
|
||||
report(file, `${label} reaches ${to}`);
|
||||
else visit(to);
|
||||
}
|
||||
};
|
||||
visit(start);
|
||||
}
|
||||
// Common voice controls may use their DOM view, but never a protocol implementation.
|
||||
const voiceSeen = new Set();
|
||||
function voice(file) {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The size budget of the Cesium-mode main chunk (run after `npm run build`):
|
||||
* gzip-9 of the entry chunk the built page loads (from the Vite manifest, or
|
||||
* the module script dist/index.html loads) may exceed the baseline in
|
||||
* scripts/main-chunk-baseline.json by at most its allowance. What the initial
|
||||
* chunks may hold is the build's own check (build/initialChunkGuard.js).
|
||||
*
|
||||
* Prints the size. `--json` prints the whole result; `--dist=DIR` reads
|
||||
* another build.
|
||||
*
|
||||
* npm run build && npm run check:main-chunk
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { entryFromHtml, entryFromManifest } from '../src/tooling/mainChunk.js';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
/** The verdict for a main-chunk size against the baseline. */
|
||||
export function checkMainChunk(
|
||||
gzipBytes,
|
||||
{ gzipBytes: baseline, allowanceBytes },
|
||||
) {
|
||||
const limit = baseline + allowanceBytes;
|
||||
const growth = gzipBytes - baseline;
|
||||
return {
|
||||
ok: gzipBytes <= limit,
|
||||
message: `main chunk ${gzipBytes} bytes gzip-9 (${growth >= 0 ? '+' : ''}${growth} over the ${baseline} baseline; limit ${limit})`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* gzip-9 size of the main chunk, with its name: the entry the built page
|
||||
* loads (`entry`, else found from the Vite manifest or dist/index.html).
|
||||
*/
|
||||
export function measureMainChunk(distDir, { entry = null } = {}) {
|
||||
const file = entry ?? findEntry(distDir).entry;
|
||||
const bytes = fs.readFileSync(path.join(distDir, file));
|
||||
return {
|
||||
name: path.basename(file),
|
||||
gzipBytes: gzipSync(bytes, { level: 9 }).length,
|
||||
};
|
||||
}
|
||||
|
||||
/** The entry chunk (relative to dist) and how it was found. */
|
||||
function findEntry(dist) {
|
||||
const manifestPath = path.join(dist, '.vite', 'manifest.json');
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
const entry = entryFromManifest(
|
||||
JSON.parse(fs.readFileSync(manifestPath, 'utf8')),
|
||||
);
|
||||
if (entry) return { entry, via: 'manifest' };
|
||||
}
|
||||
const htmlPath = path.join(dist, 'index.html');
|
||||
if (!fs.existsSync(htmlPath))
|
||||
throw new Error(`No build at ${dist}: run npm run build first`);
|
||||
const entry = entryFromHtml(fs.readFileSync(htmlPath, 'utf8'));
|
||||
if (!entry) throw new Error(`${htmlPath} loads no module script`);
|
||||
return { entry, via: 'index.html' };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const option = (name) =>
|
||||
process.argv.find((arg) => arg.startsWith(`--${name}=`))?.split('=')[1];
|
||||
const dist = path.resolve(ROOT, option('dist') ?? 'dist');
|
||||
const { entry, via } = findEntry(dist);
|
||||
const baselinePath = path.join(ROOT, 'scripts/main-chunk-baseline.json');
|
||||
let budget = null;
|
||||
if (fs.existsSync(baselinePath)) {
|
||||
const measured = measureMainChunk(dist, { entry });
|
||||
budget = {
|
||||
...measured,
|
||||
...checkMainChunk(
|
||||
measured.gzipBytes,
|
||||
JSON.parse(fs.readFileSync(baselinePath, 'utf8')),
|
||||
),
|
||||
};
|
||||
}
|
||||
const ok = budget?.ok ?? true;
|
||||
if (process.argv.includes('--json')) {
|
||||
console.log(JSON.stringify({ entry, via, budget, ok }, null, 2));
|
||||
} else {
|
||||
console.log(`[check:main-chunk] entry ${entry} (via ${via})`);
|
||||
console.log(
|
||||
budget
|
||||
? `[check:main-chunk] ${budget.name}: ${budget.message}`
|
||||
: '[check:main-chunk] no scripts/main-chunk-baseline.json: size budget not checked',
|
||||
);
|
||||
}
|
||||
if (!ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
||||
main();
|
||||
@@ -37,7 +37,11 @@ export async function checkPackageBoundaries(root) {
|
||||
) {
|
||||
throw new Error(`Invalid package boundary: ${name}`);
|
||||
}
|
||||
// A module entry ending in "/" owns every file under that directory.
|
||||
const allowed = new Set();
|
||||
const allowedDirectories = [];
|
||||
const owns = (id) =>
|
||||
allowed.has(id) || allowedDirectories.some((dir) => id.startsWith(dir));
|
||||
for (const module of group.modules) {
|
||||
if (
|
||||
typeof module !== 'string' ||
|
||||
@@ -56,7 +60,11 @@ export async function checkPackageBoundaries(root) {
|
||||
) {
|
||||
throw new Error(`Boundary module escapes repository: ${name}`);
|
||||
}
|
||||
allowed.add(normalizePath(resolved));
|
||||
if (module.endsWith('/')) {
|
||||
allowedDirectories.push(`${normalizePath(resolved)}/`);
|
||||
} else {
|
||||
allowed.add(normalizePath(resolved));
|
||||
}
|
||||
}
|
||||
for (const external of group.external) {
|
||||
if (
|
||||
@@ -82,7 +90,7 @@ export async function checkPackageBoundaries(root) {
|
||||
if (
|
||||
typeof target !== 'string' ||
|
||||
!target.startsWith('./') ||
|
||||
!allowed.has(normalizePath(path.resolve(root, target)))
|
||||
!owns(normalizePath(path.resolve(root, target)))
|
||||
) {
|
||||
throw new Error(`Export must point to an owned module: ${key}`);
|
||||
}
|
||||
@@ -109,7 +117,7 @@ export async function checkPackageBoundaries(root) {
|
||||
// template blocks belong to the component file itself.
|
||||
if (info.id.startsWith('\0')) return;
|
||||
const id = info.id.split('?')[0];
|
||||
if (!allowed.has(id)) {
|
||||
if (!owns(id)) {
|
||||
throw new Error(
|
||||
`Package boundary ${name} imports an unowned module: ${path.relative(root, id)}`,
|
||||
);
|
||||
|
||||
@@ -250,7 +250,7 @@ if [[ ! -f "$SOURCE_ROOT/src/data/cctv.js" ]]; then
|
||||
fi
|
||||
|
||||
if ! grep -q "return createApplicationCatalog(" "$SOURCE_ROOT/src/standalone/catalog.js" || \
|
||||
! grep -q "^[[:space:]]*createApplicationCctv({" "$SOURCE_ROOT/src/app/constructCatalog.js"; then
|
||||
! grep -q "createApplicationCctv(" "$SOURCE_ROOT/src/app/constructCatalog.js"; then
|
||||
echo "error: CCTV layer not wired in src/standalone/catalog.js"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,132 @@
|
||||
import { PbfWriter } from 'pbf';
|
||||
|
||||
// Recorded journeys cover both a dense neighbourhood and the wider Austin view.
|
||||
export const cameras = [
|
||||
...Array.from({ length: 225 }, (_, i) => ({
|
||||
latitude: 30.262 + (Math.floor(i / 15) - 7) * 0.003,
|
||||
longitude: -97.7431 + ((i % 15) - 7) * 0.003,
|
||||
})),
|
||||
...Array.from({ length: 100 }, (_, i) => ({
|
||||
latitude: 30.308 + (Math.floor(i / 10) - 4.5) * 0.001,
|
||||
longitude: -97.77 + ((i % 10) - 4.5) * 0.001,
|
||||
})),
|
||||
].map((position, i) => ({
|
||||
...position,
|
||||
osmId: 9000000 + i,
|
||||
operator: i % 3 ? 'W2 fixture' : 'Cámara pública',
|
||||
brand: 'Fixture camera',
|
||||
cameraType: 'fixed',
|
||||
direction: i % 5 ? String((i * 37) % 360) : '',
|
||||
}));
|
||||
|
||||
export function cameraTile(z, x, y, records = cameras) {
|
||||
const extent = 4096;
|
||||
const scale = 2 ** z;
|
||||
const rows = records.flatMap((record) => {
|
||||
const latitude = (record.latitude * Math.PI) / 180;
|
||||
const tx = ((record.longitude + 180) / 360) * scale;
|
||||
const ty = ((1 - Math.asinh(Math.tan(latitude)) / Math.PI) / 2) * scale;
|
||||
if (Math.floor(tx) !== x || Math.floor(ty) !== y) return [];
|
||||
return [
|
||||
{
|
||||
record,
|
||||
x: Math.round((tx - x) * extent),
|
||||
y: Math.round((ty - y) * extent),
|
||||
},
|
||||
];
|
||||
});
|
||||
const keys = [
|
||||
'osmType',
|
||||
'osmId',
|
||||
'operator',
|
||||
'brand',
|
||||
'cameraType',
|
||||
'direction',
|
||||
];
|
||||
const writer = new PbfWriter();
|
||||
writer.writeMessage(
|
||||
3,
|
||||
(_, layer) => {
|
||||
layer.writeVarintField(15, 2);
|
||||
layer.writeStringField(1, 'cameras');
|
||||
layer.writeVarintField(5, extent);
|
||||
for (const key of keys) layer.writeStringField(3, key);
|
||||
rows.forEach(({ record, x, y }, index) => {
|
||||
for (const key of keys)
|
||||
layer.writeMessage(
|
||||
4,
|
||||
(_, value) =>
|
||||
value.writeStringField(
|
||||
1,
|
||||
key === 'osmType' ? 'node' : String(record[key]),
|
||||
),
|
||||
null,
|
||||
);
|
||||
layer.writeMessage(
|
||||
2,
|
||||
(_, feature) => {
|
||||
feature.writeVarintField(1, record.osmId);
|
||||
feature.writePackedVarint(
|
||||
2,
|
||||
keys.flatMap((_, i) => [i, index * keys.length + i]),
|
||||
);
|
||||
feature.writeVarintField(3, 1);
|
||||
feature.writePackedVarint(4, [9, x * 2, y * 2]);
|
||||
},
|
||||
null,
|
||||
);
|
||||
});
|
||||
},
|
||||
null,
|
||||
);
|
||||
return Buffer.from(writer.finish());
|
||||
}
|
||||
|
||||
/** Real TileJSON/MVT transport; no replacement of the layer or source parser. */
|
||||
export async function installAlprFixture(page) {
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
if (request.isInterceptResolutionHandled()) return;
|
||||
const url = new URL(request.url());
|
||||
// The journey also asks for nearby-place labels while moving. Pin that
|
||||
// auxiliary service; these runs do not test Google Places availability.
|
||||
if (
|
||||
['localhost', '127.0.0.1'].includes(url.hostname) &&
|
||||
url.pathname === '/api/google/nearby-places'
|
||||
) {
|
||||
void request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ places: [] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.hostname !== 'tiles.dontgetflocked.com') {
|
||||
void request.continue();
|
||||
return;
|
||||
}
|
||||
const headers = { 'access-control-allow-origin': '*' };
|
||||
if (url.pathname.endsWith('.json')) {
|
||||
const country = url.pathname.includes('-ca-') ? 'ca' : 'us';
|
||||
void request.respond({
|
||||
status: 200,
|
||||
headers,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
tiles: [
|
||||
`https://tiles.dontgetflocked.com/cameras-${country}-hourly/{z}/{x}/{y}.mvt`,
|
||||
],
|
||||
bounds: country === 'ca' ? [-124, 42, -63, 54] : [-160, 17, -64, 59],
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const match = url.pathname.match(/\/(\d+)\/(\d+)\/(\d+)\.mvt$/);
|
||||
void request.respond({
|
||||
status: match ? 200 : 404,
|
||||
headers,
|
||||
contentType: 'application/x-protobuf',
|
||||
body: match ? cameraTile(...match.slice(1).map(Number)) : '',
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
// Transport fixtures are parsed by the real CCTV catalog and media owners.
|
||||
export const cameras = Array.from({ length: 25 }, (_, i) => ({
|
||||
id: `w2-cctv-${String(i).padStart(3, '0')}`,
|
||||
name: i === 12 ? 'Cámara pública — Congress' : `W2 camera ${i}`,
|
||||
cityId: 'austin',
|
||||
city: 'Austin',
|
||||
lat: 30.2672 + (Math.floor(i / 5) - 2) * 0.0018,
|
||||
lon: -97.7431 + ((i % 5) - 2) * 0.0018,
|
||||
headingDeg: (i * 37) % 360,
|
||||
fovDeg: i % 2 ? 44 : 56,
|
||||
rangeM: i % 2 ? 145 : 210,
|
||||
mountHeightM: i % 2 ? 8 : 10,
|
||||
pitchDeg: i % 2 ? -18 : -24,
|
||||
groundElevationM: 150,
|
||||
headingConfidence: i % 3 ? 'medium' : 'low',
|
||||
sourceKind: 'configured',
|
||||
feedType: 'image',
|
||||
provider: 'W2 deterministic fixture',
|
||||
url: 'https://cctv-fixture.invalid/frame.png',
|
||||
}));
|
||||
|
||||
export async function cameraFrame() {
|
||||
const width = 320,
|
||||
height = 180;
|
||||
const pixels = Buffer.alloc(width * height * 3);
|
||||
const colors = [
|
||||
[240, 45, 70],
|
||||
[35, 220, 90],
|
||||
[40, 90, 235],
|
||||
[245, 210, 30],
|
||||
];
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const color =
|
||||
colors[(x >= width / 2 ? 1 : 0) + (y >= height / 2 ? 2 : 0)];
|
||||
const grid = x % 40 < 2 || y % 30 < 2;
|
||||
const at = (y * width + x) * 3;
|
||||
for (let c = 0; c < 3; c++) pixels[at + c] = grid ? 15 : color[c];
|
||||
}
|
||||
}
|
||||
return sharp(pixels, { raw: { width, height, channels: 3 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
export async function installCctvFixture(page, { video } = {}) {
|
||||
const frame = await cameraFrame();
|
||||
const catalog = cameras.map((camera) => ({ ...camera }));
|
||||
if (video)
|
||||
catalog.push({
|
||||
...cameras[0],
|
||||
id: 'w2-cctv-video',
|
||||
name: 'W2 moving video',
|
||||
lat: 30.2718,
|
||||
lon: -97.744,
|
||||
feedType: 'video',
|
||||
url: 'https://cctv-fixture.invalid/camera.mp4',
|
||||
});
|
||||
const ids = new Set(catalog.map((camera) => camera.id));
|
||||
// Global Puppeteer interception can stall Cesium's module workers in
|
||||
// Chromium 152. Intercept only fixture API routes on the page's CDP session.
|
||||
const session = await page.createCDPSession();
|
||||
let disposed = false;
|
||||
const routes = [
|
||||
'/api/cctv/*',
|
||||
'/api/flights*',
|
||||
'/api/military*',
|
||||
'/api/vessels*',
|
||||
'/api/google/nearby-places*',
|
||||
];
|
||||
await session.send('Fetch.enable', {
|
||||
patterns: ['localhost', '127.0.0.1'].flatMap((host) =>
|
||||
routes.map((route) => ({ urlPattern: `http://${host}:*${route}` })),
|
||||
),
|
||||
});
|
||||
const respond = (
|
||||
requestId,
|
||||
{ status, contentType, headers = {}, body = '' },
|
||||
) =>
|
||||
session.send('Fetch.fulfillRequest', {
|
||||
requestId,
|
||||
responseCode: status,
|
||||
responseHeaders: Object.entries({
|
||||
...(contentType ? { 'content-type': contentType } : {}),
|
||||
...headers,
|
||||
}).map(([name, value]) => ({ name, value: String(value) })),
|
||||
body: Buffer.from(body).toString('base64'),
|
||||
});
|
||||
const handle = (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (!['localhost', '127.0.0.1'].includes(url.hostname)) {
|
||||
return request.continue();
|
||||
}
|
||||
const json = (body) =>
|
||||
request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const auxiliary = {
|
||||
'/api/flights': { states: [] },
|
||||
'/api/military': { ac: [] },
|
||||
'/api/vessels': { connected: false, vessels: [] },
|
||||
};
|
||||
if (auxiliary[url.pathname]) {
|
||||
return json(auxiliary[url.pathname]);
|
||||
}
|
||||
if (url.pathname === '/api/cctv/sources') {
|
||||
return json({ sources: catalog });
|
||||
}
|
||||
if (url.pathname === '/api/cctv/health') {
|
||||
return json({
|
||||
cameras: catalog.map(({ id, feedType }) => ({
|
||||
id,
|
||||
status: 'online',
|
||||
sourceKind: feedType,
|
||||
label: 'W2 deterministic fixture',
|
||||
updatedAt: Date.now(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
const media = url.pathname.match(/^\/api\/cctv\/(frame|media)\/([^/]+)$/);
|
||||
if (media) {
|
||||
const id = decodeURIComponent(media[2]);
|
||||
if (
|
||||
!ids.has(id) ||
|
||||
(media[1] === 'media' && (id !== 'w2-cctv-video' || !video))
|
||||
) {
|
||||
return request.respond({ status: 404, body: 'Unknown fixture camera' });
|
||||
}
|
||||
if (media[1] === 'frame') {
|
||||
return request.respond({
|
||||
status: 200,
|
||||
contentType: 'image/png',
|
||||
body: frame,
|
||||
});
|
||||
}
|
||||
const range = request.headers().range?.match(/^bytes=(\d+)-(\d*)$/);
|
||||
const start = range ? Number(range[1]) : 0;
|
||||
const end =
|
||||
range && range[2]
|
||||
? Math.min(Number(range[2]), video.length - 1)
|
||||
: video.length - 1;
|
||||
if (start > end || start >= video.length) {
|
||||
return request.respond({
|
||||
status: 416,
|
||||
headers: { 'content-range': `bytes */${video.length}` },
|
||||
});
|
||||
}
|
||||
return request.respond({
|
||||
status: range ? 206 : 200,
|
||||
contentType: 'video/mp4',
|
||||
headers: {
|
||||
'accept-ranges': 'bytes',
|
||||
...(range
|
||||
? { 'content-range': `bytes ${start}-${end}/${video.length}` }
|
||||
: {}),
|
||||
},
|
||||
body: video.subarray(start, end + 1),
|
||||
});
|
||||
}
|
||||
if (url.pathname === '/api/google/nearby-places') {
|
||||
return json({ places: [] });
|
||||
}
|
||||
return request.continue();
|
||||
};
|
||||
session.on('Fetch.requestPaused', (event) => {
|
||||
const request = {
|
||||
url: () => event.request.url,
|
||||
headers: () =>
|
||||
Object.fromEntries(
|
||||
Object.entries(event.request.headers).map(([name, value]) => [
|
||||
name.toLowerCase(),
|
||||
value,
|
||||
]),
|
||||
),
|
||||
respond: (response) => respond(event.requestId, response),
|
||||
continue: () =>
|
||||
session.send('Fetch.continueRequest', { requestId: event.requestId }),
|
||||
};
|
||||
Promise.resolve()
|
||||
.then(() => handle(request))
|
||||
.catch((error) => {
|
||||
if (!disposed && !page.isClosed()) page.emit('pageerror', error);
|
||||
});
|
||||
});
|
||||
return {
|
||||
cameras: catalog,
|
||||
frame,
|
||||
async dispose() {
|
||||
disposed = true;
|
||||
await session.detach();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { cameras as alprCameras, cameraTile } from './alpr.mjs';
|
||||
import { cameras as cctvCameras, cameraFrame } from './cctv.mjs';
|
||||
import { stations as radioStations } from './radio.mjs';
|
||||
import { earthquakePerformanceFixture } from './earthquakes.mjs';
|
||||
|
||||
export const COMBINED_EPOCH = Date.parse('2026-10-28T18:00:00Z');
|
||||
export const COMBINED_ORIGIN = { lat: 42.3554, lon: -71.0605 };
|
||||
const grid = (i, cols, spacing) => ({
|
||||
lat: COMBINED_ORIGIN.lat + (Math.floor(i / cols) - cols / 2) * spacing,
|
||||
lon: COMBINED_ORIGIN.lon + ((i % cols) - cols / 2) * spacing,
|
||||
});
|
||||
const radio = radioStations.map((r, i) => ({ ...r, ...grid(i, 28, 0.001) }));
|
||||
const cctv = cctvCameras.map((r, i) => ({
|
||||
...r,
|
||||
...grid(i, 5, 0.002),
|
||||
cityId: 'boston',
|
||||
city: 'Boston',
|
||||
groundElevationM: 20,
|
||||
}));
|
||||
const alpr = alprCameras.map((r, i) => {
|
||||
const p = grid(i, 19, 0.001);
|
||||
return { ...r, latitude: p.lat, longitude: p.lon };
|
||||
});
|
||||
const bikes = Array.from({ length: 200 }, (_, i) => ({
|
||||
station_id: `w2-bike-${i}`,
|
||||
name: `W2 station ${i}`,
|
||||
capacity: 20,
|
||||
...grid(i, 15, 0.0015),
|
||||
}));
|
||||
const earthquakes = earthquakePerformanceFixture(500);
|
||||
earthquakes.features = earthquakes.features.map((r, i) => {
|
||||
const p = grid(i, 23, 0.002);
|
||||
return {
|
||||
...r,
|
||||
properties: { ...r.properties, time: COMBINED_EPOCH - 3600000 },
|
||||
geometry:
|
||||
i < 5 ? { ...r.geometry, coordinates: [p.lon, p.lat, 5] } : r.geometry,
|
||||
};
|
||||
});
|
||||
const fires = Array.from({ length: 6000 }, (_, i) => ({
|
||||
...grid(i, 78, 0.001),
|
||||
frp: 5 + (i % 120),
|
||||
confidence: ['low', 'nominal', 'high'][i % 3],
|
||||
brightness: 280 + (i % 90),
|
||||
daynight: i % 2 ? 'N' : 'D',
|
||||
acqDate: '2026-10-28',
|
||||
acqTime: '1730',
|
||||
instrument: 'VIIRS',
|
||||
satellite: 'NOAA-20',
|
||||
}));
|
||||
const elements = Array.from({ length: 20 }, (_, i) => {
|
||||
const p = grid(i, 5, 0.006),
|
||||
d = 0.0007;
|
||||
return {
|
||||
type: 'way',
|
||||
id: 980000 + i,
|
||||
center: p,
|
||||
tags: { military: 'airfield', name: `W2 facility ${i}` },
|
||||
bounds: {
|
||||
minlat: p.lat - d,
|
||||
minlon: p.lon - d,
|
||||
maxlat: p.lat + d,
|
||||
maxlon: p.lon + d,
|
||||
},
|
||||
geometry: [
|
||||
[p.lon - d, p.lat - d],
|
||||
[p.lon + d, p.lat - d],
|
||||
[p.lon + d, p.lat + d],
|
||||
[p.lon - d, p.lat + d],
|
||||
[p.lon - d, p.lat - d],
|
||||
].map(([lon, lat]) => ({ lon, lat })),
|
||||
};
|
||||
});
|
||||
export const combinedPayloads = {
|
||||
radio,
|
||||
cctv,
|
||||
alpr,
|
||||
bikes,
|
||||
earthquakes,
|
||||
fires,
|
||||
elements,
|
||||
};
|
||||
|
||||
/** One source transport fixture, shared unchanged by both engines. */
|
||||
export async function installCombinedFixture(
|
||||
page,
|
||||
{ denseQuakes = false } = {},
|
||||
) {
|
||||
const quakePayload = denseQuakes
|
||||
? {
|
||||
...earthquakes,
|
||||
features: earthquakes.features.map((r, i) => {
|
||||
const p = grid(i, 23, 0.002);
|
||||
return {
|
||||
...r,
|
||||
geometry: { ...r.geometry, coordinates: [p.lon, p.lat, 5] },
|
||||
};
|
||||
}),
|
||||
}
|
||||
: earthquakes;
|
||||
const frame = await cameraFrame(),
|
||||
requests = [];
|
||||
const session = await page.createCDPSession();
|
||||
await session.send('Fetch.enable', {
|
||||
patterns: [
|
||||
...['127.0.0.1', 'localhost'].map((host) => ({
|
||||
urlPattern: `http://${host}:*/api/*`,
|
||||
})),
|
||||
{ urlPattern: 'https://earthquake.usgs.gov/*all_day.geojson' },
|
||||
{ urlPattern: 'https://tiles.dontgetflocked.com/*' },
|
||||
{ urlPattern: '*://*.earthdata.nasa.gov/*' },
|
||||
],
|
||||
});
|
||||
let disposed = false;
|
||||
session.on('Fetch.requestPaused', async (event) => {
|
||||
try {
|
||||
const u = new URL(event.request.url);
|
||||
let body,
|
||||
type = 'application/json';
|
||||
const p = u.pathname;
|
||||
if (u.hostname === 'earthquake.usgs.gov') body = quakePayload;
|
||||
else if (u.hostname === 'tiles.dontgetflocked.com') {
|
||||
if (p.endsWith('.json'))
|
||||
body = {
|
||||
tiles: [
|
||||
'https://tiles.dontgetflocked.com/cameras-us-hourly/{z}/{x}/{y}.mvt',
|
||||
],
|
||||
bounds: [-160, 17, -64, 59],
|
||||
};
|
||||
else {
|
||||
const m = p.match(/\/(\d+)\/(\d+)\/(\d+)\.mvt$/);
|
||||
body = m
|
||||
? cameraTile(...m.slice(1).map(Number), alpr)
|
||||
: Buffer.alloc(0);
|
||||
type = 'application/x-protobuf';
|
||||
}
|
||||
} else if (u.hostname === 'cmr.earthdata.nasa.gov')
|
||||
body = {
|
||||
hits: 2,
|
||||
items: ['2026-10-28', '2026-10-27'].map((day) => ({
|
||||
umm: {
|
||||
TemporalExtent: {
|
||||
RangeDateTime: { BeginningDateTime: `${day}T12:00:00Z` },
|
||||
},
|
||||
AdditionalAttributes: [{ Name: 'CLOUD_COVERAGE', Values: ['0'] }],
|
||||
SpatialExtent: {
|
||||
HorizontalSpatialDomain: {
|
||||
Geometry: {
|
||||
BoundingRectangles: [
|
||||
{
|
||||
WestBoundingCoordinate: -72,
|
||||
EastBoundingCoordinate: -70,
|
||||
SouthBoundingCoordinate: 41,
|
||||
NorthBoundingCoordinate: 43,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
};
|
||||
else if (u.hostname.endsWith('.earthdata.nasa.gov')) {
|
||||
body = frame;
|
||||
type = 'image/png';
|
||||
} else if (p === '/api/firms')
|
||||
body = { fires, fetchedAt: COMBINED_EPOCH - 120000, stale: false };
|
||||
else if (p === '/api/radio/stations')
|
||||
body = {
|
||||
stations: radio,
|
||||
updatedAt: new Date(COMBINED_EPOCH).toISOString(),
|
||||
stale: false,
|
||||
degraded: false,
|
||||
acceptedGeneration: 1,
|
||||
catalogInstance: 'w2-combined-fixture',
|
||||
};
|
||||
else if (p.startsWith('/api/gbfs/'))
|
||||
body = {
|
||||
version: '3.0',
|
||||
data: {
|
||||
stations: decodeURIComponent(u.href).includes('station_information')
|
||||
? bikes
|
||||
: bikes.map(({ station_id }) => ({
|
||||
station_id,
|
||||
num_bikes_available: 7,
|
||||
num_docks_available: 13,
|
||||
is_renting: true,
|
||||
is_returning: true,
|
||||
})),
|
||||
},
|
||||
};
|
||||
else if (p === '/api/military-installations')
|
||||
body = {
|
||||
status: 'ready',
|
||||
retrievedAt: new Date(COMBINED_EPOCH).toISOString(),
|
||||
saturated: false,
|
||||
elements,
|
||||
};
|
||||
else if (p === '/api/cctv/sources') body = { sources: cctv };
|
||||
else if (p === '/api/cctv/health')
|
||||
body = {
|
||||
cameras: cctv.map(({ id }) => ({
|
||||
id,
|
||||
status: 'online',
|
||||
sourceKind: 'image',
|
||||
updatedAt: COMBINED_EPOCH,
|
||||
})),
|
||||
};
|
||||
else if (p.startsWith('/api/cctv/frame/')) {
|
||||
body = frame;
|
||||
type = 'image/png';
|
||||
} else if (p.startsWith('/api/transit/'))
|
||||
body = {
|
||||
fetchedAt: COMBINED_EPOCH,
|
||||
vehicles: [],
|
||||
fixes: [],
|
||||
epochs: [],
|
||||
};
|
||||
else if (p === '/api/flights') body = { states: [] };
|
||||
else if (p === '/api/military') body = { ac: [] };
|
||||
else if (p === '/api/vessels') body = { connected: false, vessels: [] };
|
||||
else if (p === '/api/google/nearby-places') body = { places: [] };
|
||||
else if (p === '/api/setup/status') body = { ready: true };
|
||||
else if (p === '/api/openai/hud-summary')
|
||||
body = { summary: 'W2 performance fixture' };
|
||||
else if (p === '/api/terrain/heights')
|
||||
body = {
|
||||
results: (u.searchParams.get('points') || '')
|
||||
.split(';')
|
||||
.filter(Boolean)
|
||||
.map((point) => {
|
||||
const [lon, lat] = point.split(',').map(Number);
|
||||
return { lon, lat, elevation: 20, geoid: 0, ellipsoid: 20 };
|
||||
}),
|
||||
};
|
||||
if (body === undefined) {
|
||||
await session.send('Fetch.continueRequest', {
|
||||
requestId: event.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
requests.push(p);
|
||||
await session.send('Fetch.fulfillRequest', {
|
||||
requestId: event.requestId,
|
||||
responseCode: 200,
|
||||
responseHeaders: [
|
||||
{ name: 'Content-Type', value: type },
|
||||
{ name: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{ name: 'Access-Control-Expose-Headers', value: 'Data-Present' },
|
||||
{ name: 'Data-Present', value: 'true' },
|
||||
],
|
||||
body: (Buffer.isBuffer(body)
|
||||
? body
|
||||
: Buffer.from(JSON.stringify(body))
|
||||
).toString('base64'),
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
!disposed &&
|
||||
!page.isClosed() &&
|
||||
!String(error.message).includes('Invalid InterceptionId')
|
||||
)
|
||||
page.emit('pageerror', error);
|
||||
}
|
||||
});
|
||||
return {
|
||||
requests,
|
||||
async dispose() {
|
||||
disposed = true;
|
||||
await session.detach();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// USGS-shaped fixed observations; source parsers remain the production ones.
|
||||
export const W2_EPOCH_MS = 1792152000000;
|
||||
export function earthquakeFixture() {
|
||||
const features = [];
|
||||
for (const [region, lon, lat] of [
|
||||
['sf', -122.42, 37.77],
|
||||
['austin', -97.74, 30.27],
|
||||
['alaska', -150.4, 61.0],
|
||||
]) {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
id: `qa-${region}-${i}`,
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [
|
||||
lon + (i - 2) * 0.24,
|
||||
lat + (i % 2) * 0.3,
|
||||
[8, 70, 300, 301, 45][i],
|
||||
],
|
||||
},
|
||||
properties: {
|
||||
mag: [2.5, 3, 4, 5, 5.5][i],
|
||||
place: `W2 ${region} event ${i}`,
|
||||
time: W2_EPOCH_MS - i * 60000,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
id: 'qa-below',
|
||||
geometry: { type: 'Point', coordinates: [-122.42, 37.77, 5] },
|
||||
properties: { mag: 2.4 },
|
||||
});
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
id: 'qa-missing-mag',
|
||||
geometry: { type: 'Point', coordinates: [-122.4, 37.7, 5] },
|
||||
properties: { mag: null },
|
||||
});
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
metadata: { generated: W2_EPOCH_MS },
|
||||
features,
|
||||
};
|
||||
}
|
||||
|
||||
/** Dense regional and worldwide USGS-shaped load, separate from the oracle. */
|
||||
export function earthquakePerformanceFixture(count = 500) {
|
||||
let seed = 17;
|
||||
const random = () => {
|
||||
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
|
||||
return seed / 4294967296;
|
||||
};
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: Array.from({ length: count }, (_, i) => ({
|
||||
type: 'Feature',
|
||||
id: `perf-quake-${i}`,
|
||||
properties: {
|
||||
mag: 2.5 + (i % 7) * 0.5,
|
||||
place: `Recorded performance event ${i}`,
|
||||
time: W2_EPOCH_MS - i * 1000,
|
||||
},
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates:
|
||||
i < Math.min(100, count)
|
||||
? [
|
||||
-122.42 + (random() - 0.5),
|
||||
37.77 + (random() - 0.5),
|
||||
[8, 70, 301][i % 3],
|
||||
]
|
||||
: [
|
||||
-180 + random() * 360,
|
||||
-70 + random() * 140,
|
||||
[8, 70, 301][i % 3],
|
||||
],
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Real /api/firms envelope; fixed source receipt and acquisition clocks.
|
||||
export const FIRMS_EPOCH_MS = Date.UTC(2026, 9, 16, 12);
|
||||
export const FIRMS_VALID_COUNT = 371;
|
||||
|
||||
export function firmsFixture() {
|
||||
const fires = [];
|
||||
const regions = [
|
||||
[37.77, -122.42],
|
||||
[30.27, -97.74],
|
||||
[61.2, -149.9],
|
||||
];
|
||||
for (const [region, [latitude, longitude]] of regions.entries()) {
|
||||
for (let row = -5; row <= 5; row++) {
|
||||
for (let column = -5; column <= 5; column++) {
|
||||
const index = fires.length;
|
||||
fires.push({
|
||||
lat: latitude + row * 0.12,
|
||||
lon: longitude + column * 0.12,
|
||||
frp:
|
||||
region === 0 && row === 0 && column === 0 ? 999 : 5 + (index % 120),
|
||||
confidence: ['low', 'nominal', 'high', 85][index % 4],
|
||||
brightness: 280 + (index % 90),
|
||||
daynight: index % 2 ? 'N' : 'D',
|
||||
acqDate: '2026-10-16',
|
||||
acqTime: String(800 + (index % 60)),
|
||||
instrument: index % 4 === 3 ? 'MODIS' : 'VIIRS',
|
||||
satellite: index % 4 === 3 ? 'Terra' : 'NOAA-20',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [lat, lon] of [
|
||||
[10, 179.7],
|
||||
[10, -179.7],
|
||||
[89.5, 45],
|
||||
[-89.5, -135],
|
||||
[0.2, 179.8],
|
||||
[-0.2, -179.8],
|
||||
[70, 179.5],
|
||||
[-70, -179.5],
|
||||
]) {
|
||||
fires.push({
|
||||
lat,
|
||||
lon,
|
||||
frp: 30,
|
||||
confidence: 'high',
|
||||
brightness: 320,
|
||||
daynight: 'D',
|
||||
acqDate: '2026-10-16',
|
||||
acqTime: '930',
|
||||
instrument: 'VIIRS',
|
||||
satellite: 'NOAA-20',
|
||||
});
|
||||
}
|
||||
fires.push({ lat: 'invalid', lon: 0 }, { lat: 0, lon: 'invalid' });
|
||||
return { fires, fetchedAt: FIRMS_EPOCH_MS - 120000, stale: false };
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Shared 750-station directory for legacy and both-engine radio QA.
|
||||
const tags = [
|
||||
'news',
|
||||
'talk',
|
||||
'weather,emergency',
|
||||
'public safety,scanner',
|
||||
'aviation,atc',
|
||||
'marine,maritime',
|
||||
'traffic,transit',
|
||||
'music,jazz',
|
||||
'music,rock',
|
||||
'community',
|
||||
];
|
||||
export const stations = Array.from({ length: 750 }, (_, index) => {
|
||||
const row = Math.floor(index / 30);
|
||||
const column = index % 30;
|
||||
return {
|
||||
id: `00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`,
|
||||
name:
|
||||
index === 360
|
||||
? '100.3 The River - WQRV - Meridianville/Huntsville, AL'
|
||||
: index === 390
|
||||
? 'La Zeta (Hermosillo) - 93.9 FM - XHHY - Uniradio - Hermosillo, Sonora'
|
||||
: `QA Radio ${String(index + 1).padStart(3, '0')}`,
|
||||
lat: -72 + row * 6,
|
||||
lon: -174 + column * 12,
|
||||
streamUrl: `https://audio.example.test/${index}.mp3`,
|
||||
homepage: `https://station.example.test/${index}`,
|
||||
tags: tags[index % tags.length].split(','),
|
||||
languages: index === 360 ? ['Spanish'] : ['English'],
|
||||
state: `Region ${row + 1}`,
|
||||
country: 'United States',
|
||||
countryCode: 'US',
|
||||
metadataTrust: 'untrusted-community',
|
||||
codec: index % 2 ? 'AAC' : 'MP3',
|
||||
bitrate: 128,
|
||||
};
|
||||
});
|
||||
|
||||
export async function installRadioFixture(page) {
|
||||
await page.evaluateOnNewDocument((stations) => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
window.__qaRadio = {
|
||||
stations,
|
||||
generation: 1,
|
||||
plays: [],
|
||||
delay: 0,
|
||||
response: 'normal',
|
||||
pending: null,
|
||||
};
|
||||
HTMLMediaElement.prototype.play = function () {
|
||||
window.qaPlayer = this;
|
||||
window.__qaRadio.plays.push(this.src);
|
||||
if (window.__qaRadio.delayPlay) {
|
||||
window.__qaRadio.delayPlay = false;
|
||||
return new Promise((resolve) => {
|
||||
window.__qaRadio.releasePlay = () => {
|
||||
this.dispatchEvent(new Event('playing'));
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
queueMicrotask(() => this.dispatchEvent(new Event('playing')));
|
||||
return Promise.resolve();
|
||||
};
|
||||
const fetch = window.fetch.bind(window);
|
||||
window.fetch = async (input, init) => {
|
||||
const address = String(
|
||||
typeof input === 'string' || input instanceof URL ? input : input.url,
|
||||
);
|
||||
if (address.includes('/api/radio/stations')) {
|
||||
const qa = window.__qaRadio;
|
||||
qa.reads = (qa.reads || 0) + 1;
|
||||
const response = {
|
||||
stations: qa.response === 'empty' ? [] : qa.stations,
|
||||
updatedAt: new Date().toISOString(),
|
||||
stale: false,
|
||||
degraded: false,
|
||||
acceptedGeneration: qa.generation,
|
||||
catalogInstance: 'w2-radio-fixture',
|
||||
};
|
||||
const mode = qa.response;
|
||||
const delay = qa.delay;
|
||||
qa.delay = 0;
|
||||
if (delay)
|
||||
await new Promise((resolve) => {
|
||||
qa.pending = resolve;
|
||||
});
|
||||
init?.signal?.throwIfAborted();
|
||||
return new Response(
|
||||
mode === 'failure' ? '{}' : JSON.stringify(response),
|
||||
{
|
||||
status: mode === 'failure' ? 503 : 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
);
|
||||
}
|
||||
if (address.includes('/api/radio/click/'))
|
||||
return new Response(null, { status: 204 });
|
||||
if (address.includes('/api/flights'))
|
||||
return new Response('{"states":[]}');
|
||||
if (address.includes('/api/military')) return new Response('{"ac":[]}');
|
||||
if (address.includes('/api/vessels'))
|
||||
return new Response('{"connected":false,"vessels":[]}');
|
||||
return fetch(input, init);
|
||||
};
|
||||
}, stations);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
export const TRANSIT_EPOCH = 1_793_232_000_000;
|
||||
const routes = ['1', 'Green-B', 'Red', 'CR-Fitchburg', 'Boat-F1', ''];
|
||||
export function transitSnapshot(step = 8) {
|
||||
const fetchedAt = TRANSIT_EPOCH - 135000 + step * 15000;
|
||||
return {
|
||||
fetchedAt,
|
||||
vehicles: [
|
||||
...Array.from({ length: 12 }, (_, i) => ({
|
||||
id: `w2-${i}`,
|
||||
routeId: routes[i % 6],
|
||||
tripId: `trip-${i}`,
|
||||
lat:
|
||||
42.355 +
|
||||
(Math.floor(i / 4) - 1) * 0.0017 +
|
||||
(i < 6 ? step * 0.00012 : 0),
|
||||
lon: -71.065 + ((i % 4) - 1.5) * 0.0017,
|
||||
timestamp: fetchedAt / 1000,
|
||||
timestampSource: 'vehicle',
|
||||
bearing: 0,
|
||||
})),
|
||||
{
|
||||
id: 'stale',
|
||||
routeId: '1',
|
||||
tripId: 'old',
|
||||
lat: 42.355,
|
||||
lon: -71.065,
|
||||
timestamp: (TRANSIT_EPOCH - 900000) / 1000,
|
||||
timestampSource: 'vehicle',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
export async function installTransitFixture(page) {
|
||||
let step = 0,
|
||||
disposed = false;
|
||||
const requests = [];
|
||||
const session = await page.createCDPSession();
|
||||
await page.evaluateOnNewDocument((epoch) => {
|
||||
Date.now = () => epoch + performance.now();
|
||||
}, TRANSIT_EPOCH);
|
||||
await session.send('Fetch.enable', {
|
||||
patterns: ['localhost', '127.0.0.1'].flatMap((host) =>
|
||||
[
|
||||
'/api/transit/*',
|
||||
'/api/flights*',
|
||||
'/api/military*',
|
||||
'/api/vessels*',
|
||||
].map((route) => ({ urlPattern: `http://${host}:*${route}` })),
|
||||
),
|
||||
});
|
||||
session.on('Fetch.requestPaused', async (event) => {
|
||||
try {
|
||||
const url = new URL(event.request.url);
|
||||
requests.push(url.pathname);
|
||||
let body;
|
||||
if (url.pathname.startsWith('/api/transit/vehicles/'))
|
||||
body = url.pathname.endsWith('/mbta')
|
||||
? transitSnapshot(step)
|
||||
: { fetchedAt: TRANSIT_EPOCH, vehicles: [] };
|
||||
else if (url.pathname.startsWith('/api/transit/trail/')) {
|
||||
const vehicleId = decodeURIComponent(url.pathname.split('/').at(-1));
|
||||
const record = transitSnapshot().vehicles.find(
|
||||
(row) => row.id === vehicleId,
|
||||
);
|
||||
body = {
|
||||
version: 1,
|
||||
feedId: 'mbta',
|
||||
vehicleId,
|
||||
truncated: false,
|
||||
epochs: [
|
||||
{ id: 1, trip: record.tripId, route: record.routeId, mode: 'bus' },
|
||||
],
|
||||
fixes: Array.from({ length: 9 }, (_, i) => {
|
||||
const snapshot = transitSnapshot(i),
|
||||
row = snapshot.vehicles.find((v) => v.id === vehicleId);
|
||||
return [snapshot.fetchedAt, row.lat, row.lon, 0, 1];
|
||||
}),
|
||||
};
|
||||
} else body = { states: [], ac: [], vessels: [], feeds: [] };
|
||||
await session.send('Fetch.fulfillRequest', {
|
||||
requestId: event.requestId,
|
||||
responseCode: 200,
|
||||
responseHeaders: [{ name: 'Content-Type', value: 'application/json' }],
|
||||
body: Buffer.from(JSON.stringify(body)).toString('base64'),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!disposed && !page.isClosed()) page.emit('pageerror', error);
|
||||
}
|
||||
});
|
||||
return {
|
||||
requests,
|
||||
setStep(value) {
|
||||
step = value;
|
||||
},
|
||||
async dispose() {
|
||||
disposed = true;
|
||||
await session.detach();
|
||||
},
|
||||
};
|
||||
}
|
||||
+266
-7
@@ -368,8 +368,10 @@
|
||||
"src/layers/military/index.js",
|
||||
"src/layers/aircraft/index.js",
|
||||
"src/layers/aircraft/classification.js",
|
||||
"src/layers/aircraft/tracking.js",
|
||||
"src/layers/military/ownership.test.mjs",
|
||||
"src/layers/aircraft/classification.test.mjs",
|
||||
"src/layers/aircraft/tracking.test.mjs",
|
||||
"src/data/aisLiveVessels.js",
|
||||
"src/layers/vessels/cards.js",
|
||||
"src/layers/vessels/evidence.js",
|
||||
@@ -474,17 +476,14 @@
|
||||
"src/layers/satellites/stale.test.mjs",
|
||||
"src/layers/launches/source.test.mjs",
|
||||
"src/layers/launches/ingestion.test.mjs",
|
||||
"src/layers/installations/lifecycle.js",
|
||||
"src/layers/installations/viewport.js",
|
||||
"src/layers/installations/controls.js",
|
||||
"src/layers/installations/source.js",
|
||||
"src/layers/installations/state.js",
|
||||
"src/layers/installations/policy.js",
|
||||
"src/layers/installations/rendering.js",
|
||||
"src/layers/installations/ingestion.js",
|
||||
"src/layers/installations/index.js",
|
||||
"src/layers/installations/model.js",
|
||||
"src/layers/installations/selection.js",
|
||||
"src/layers/awareness/focus.js",
|
||||
"src/layers/awareness/lifecycle.js",
|
||||
"src/layers/awareness/dependencies.js",
|
||||
@@ -520,20 +519,17 @@
|
||||
"src/layers/traffic/model.js",
|
||||
"src/layers/traffic/timing.js",
|
||||
"src/layers/traffic/style.js",
|
||||
"src/layers/bikeshare/lifecycle.js",
|
||||
"src/layers/bikeshare/viewport.js",
|
||||
"src/layers/bikeshare/registry.js",
|
||||
"src/layers/bikeshare/controls.js",
|
||||
"src/layers/bikeshare/source.js",
|
||||
"src/layers/bikeshare/state.js",
|
||||
"src/layers/bikeshare/policy.js",
|
||||
"src/layers/bikeshare/rendering.js",
|
||||
"src/layers/bikeshare/testing.js",
|
||||
"src/layers/bikeshare/ingestion.js",
|
||||
"src/layers/bikeshare/queries.js",
|
||||
"src/layers/bikeshare/index.js",
|
||||
"src/layers/bikeshare/model.js",
|
||||
"src/layers/bikeshare/selection.js",
|
||||
"src/data/traffic.js",
|
||||
"src/data/bikeshare.js",
|
||||
"src/layers/traffic/source.test.mjs",
|
||||
@@ -594,9 +590,9 @@
|
||||
"src/layers/alpr/index.js",
|
||||
"src/layers/alpr/model.js",
|
||||
"src/layers/alpr/policy.js",
|
||||
"src/layers/alpr/presentation.js",
|
||||
"src/layers/alpr/visuals.js",
|
||||
"src/layers/alpr/overlay.js",
|
||||
"src/layers/alpr/presentation.js",
|
||||
"src/layers/alpr/overlay.test.mjs",
|
||||
"src/layers/alpr/source.js",
|
||||
"src/layers/alpr/source.test.mjs",
|
||||
@@ -1065,6 +1061,7 @@
|
||||
"src/layers/traffic/roadModes.test.mjs",
|
||||
"scripts/qa-traffic-motion.mjs",
|
||||
"src/layers/traffic/motion.test.mjs",
|
||||
"src/layers/traffic/handoff.test.mjs",
|
||||
"scripts/qa-journey-recorder.mjs",
|
||||
"scripts/qa-alpr-journey.mjs",
|
||||
"scripts/qa-context-arrivals.mjs",
|
||||
@@ -1211,6 +1208,268 @@
|
||||
"src/data/headingDegrees.test.mjs",
|
||||
"src/data/distanceFormat.test.mjs",
|
||||
"src/sources/adsbFields.test.mjs",
|
||||
"src/layers/flights/render/cesium.js",
|
||||
"src/layers/flights/render/navara.js",
|
||||
"src/layers/aircraft/render/navara.test.mjs",
|
||||
"src/layers/aircraft/render/navara.js",
|
||||
"src/layers/flights/renderer.test.mjs",
|
||||
"src/layers/military/renderer.test.mjs",
|
||||
"src/layers/military/policy.test.mjs",
|
||||
"src/testSupport/aircraftRecordingRenderer.mjs",
|
||||
"src/layers/aircraft/render/cesium.test.mjs",
|
||||
"src/layers/aircraft/render/cesium.js",
|
||||
"src/layers/aircraft/render/contract.js",
|
||||
"src/layers/shared/color.test.mjs",
|
||||
"src/layers/shared/color.js",
|
||||
"src/layers/shared/viewRectangle.js",
|
||||
"src/layers/military/render/cesium.js",
|
||||
"src/layers/military/render/navara.js",
|
||||
"src/layers/aircraft/qaDrawn.js",
|
||||
"src/app/navaraLayers.test.mjs",
|
||||
"src/layers/vessels/icons.js",
|
||||
"src/layers/vessels/render/cesium.js",
|
||||
"src/layers/vessels/render/contract.js",
|
||||
"src/layers/vessels/renderer.test.mjs",
|
||||
"src/layers/vessels/testFixtures.mjs",
|
||||
"src/layers/vessels/render/navara.js",
|
||||
"src/layers/shared/render/engineView.js",
|
||||
"src/layers/shared/render/navaraGroundEffect.js",
|
||||
"src/layers/shared/render/navaraMoverBank.js",
|
||||
"src/layers/shared/render/imageAtlas.js",
|
||||
"src/layers/installations/render/cesium/lifecycle.js",
|
||||
"src/layers/installations/render/cesium/rendering.js",
|
||||
"src/layers/installations/render/cesium/selection.js",
|
||||
"src/layers/bikeshare/render/cesium/lifecycle.js",
|
||||
"src/layers/bikeshare/presentation.js",
|
||||
"src/layers/bikeshare/stationPoints.js",
|
||||
"src/layers/bikeshare/presentation.test.mjs",
|
||||
"src/layers/bikeshare/selectionModel.js",
|
||||
"src/layers/bikeshare/render/cesium.js",
|
||||
"src/layers/bikeshare/render/cesium/viewport.js",
|
||||
"src/layers/bikeshare/render/cesium/lifecycle.test.mjs",
|
||||
"src/layers/bikeshare/render/navara.js",
|
||||
"src/layers/bikeshare/render/navara.test.mjs",
|
||||
"src/layers/bikeshare/render/cesium/rendering.js",
|
||||
"src/layers/bikeshare/render/cesium/selection.js",
|
||||
"src/layers/cctv/projectionScheduling.test.mjs",
|
||||
"src/layers/cctv/gizmo.test.mjs",
|
||||
"src/layers/cctv/render/cesiumGizmo.js",
|
||||
"src/layers/cctv/render/navara.js",
|
||||
"src/layers/cctv/render/navara.test.mjs",
|
||||
"src/layers/cctv/render/navaraMeshes.js",
|
||||
"src/layers/cctv/render/navaraMeshes.test.mjs",
|
||||
"src/layers/cctv/render/navaraGizmo.js",
|
||||
"src/layers/cctv/render/navaraGizmo.test.mjs",
|
||||
"src/layers/cctv/render/navaraLine.js",
|
||||
"src/layers/cctv/render/cesium.js",
|
||||
"src/layers/cctv/render/cesiumViewshed.js",
|
||||
"src/layers/cctv/render/cesium.test.mjs",
|
||||
"src/layers/cctv/lifecycle.test.mjs",
|
||||
"src/layers/cctv/projectionMedia.test.mjs",
|
||||
"src/layers/cctv/ingestion.test.mjs",
|
||||
"src/layers/alpr/render/cesiumPresentation.js",
|
||||
"src/layers/alpr/render/cesium.js",
|
||||
"src/layers/alpr/render/navara.js",
|
||||
"src/layers/alpr/render/navara.test.mjs",
|
||||
"src/layers/alpr/render/navaraOverlay.js",
|
||||
"src/layers/alpr/render/navaraOverlay.test.mjs",
|
||||
"scripts/fixtures/w2/alpr.mjs",
|
||||
"scripts/fixtures/w2/cctv.mjs",
|
||||
"src/layers/alpr/render/cesiumOverlay.js",
|
||||
"src/layers/radio/render/cesium.js",
|
||||
"src/layers/radio/render/navara.js",
|
||||
"src/layers/radio/render/navara.test.mjs",
|
||||
"src/layers/radio/screenClusters.js",
|
||||
"src/layers/radio/screenClusters.test.mjs",
|
||||
"src/maps/sceneApply.js",
|
||||
"src/maps/sceneApply.test.mjs",
|
||||
"src/globe/contract.test.mjs",
|
||||
"src/globe/testing/fakeEngine.test.mjs",
|
||||
"src/globe/cesium/events.test.mjs",
|
||||
"src/globe/cesium/camera.test.mjs",
|
||||
"src/globe/cesium/projector.test.mjs",
|
||||
"src/globe/cesium/picker.test.mjs",
|
||||
"src/globe/cesium/frames.test.mjs",
|
||||
"src/globe/cesium/render.test.mjs",
|
||||
"src/globe/cesium/surface.test.mjs",
|
||||
"src/globe/cesium/input.test.mjs",
|
||||
"src/globe/cesium/credits.test.mjs",
|
||||
"src/globe/cesium/maps.test.mjs",
|
||||
"src/globe/cesium/createCesiumEngine.test.mjs",
|
||||
"src/globe/cesium/engineFromViewer.js",
|
||||
"src/globe/cesium/engineFromViewer.test.mjs",
|
||||
"src/globe/cesium/testing/fakeViewer.mjs",
|
||||
"src/globe/cesium/testing/cameraViewer.mjs",
|
||||
"src/app/globeEngine.js",
|
||||
"src/app/globeEngine.test.mjs",
|
||||
"src/layers/transit/render/cesium.js",
|
||||
"src/layers/transit/render/cesium.test.mjs",
|
||||
"src/layers/transit/render/navara.js",
|
||||
"src/layers/transit/render/navaraView.js",
|
||||
"src/layers/transit/render/navaraSprites.js",
|
||||
"src/layers/transit/render/navaraTrail.js",
|
||||
"src/layers/transit/render/navara.test.mjs",
|
||||
"scripts/fixtures/w2/transit.mjs",
|
||||
"src/layers/localAdsb/render/cesium.js",
|
||||
"src/layers/localAdsb/render/navara.js",
|
||||
"src/layers/localAdsb/renderer.test.mjs",
|
||||
"build/navaraAssets.js",
|
||||
"scripts/qa-engine-switch.mjs",
|
||||
"scripts/qa/tileCache.mjs",
|
||||
"src/annotations/resolverEngine.test.mjs",
|
||||
"src/voice/gevActionsEngine.test.mjs",
|
||||
"src/app/engineChoice.js",
|
||||
"src/app/engineChoice.test.mjs",
|
||||
"src/app/navaraMode.js",
|
||||
"src/app/navaraScene.test.mjs",
|
||||
"src/app/viewerlessTools.test.mjs",
|
||||
"src/cameraAngles.js",
|
||||
"src/cameraAngles.test.mjs",
|
||||
"src/data/cameraArrival.test.mjs",
|
||||
"src/data/cameraSensitivity.test.mjs",
|
||||
"src/globe/cesium/camera.js",
|
||||
"src/globe/cesium/convert.js",
|
||||
"src/globe/cesium/createCesiumEngine.js",
|
||||
"src/globe/cesium/credits.js",
|
||||
"src/globe/cesium/events.js",
|
||||
"src/globe/cesium/frames.js",
|
||||
"src/globe/cesium/input.js",
|
||||
"src/globe/cesium/maps.js",
|
||||
"src/globe/cesium/picker.js",
|
||||
"src/globe/cesium/projector.js",
|
||||
"src/globe/cesium/render.js",
|
||||
"src/globe/cesium/surface.js",
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cameraMath.js",
|
||||
"src/globe/cameraMath.test.mjs",
|
||||
"src/globe/navara/cameraModel.js",
|
||||
"src/globe/navara/cameraModel.test.mjs",
|
||||
"src/globe/navara/createNavaraEngine.js",
|
||||
"src/globe/navara/createNavaraEngine.test.mjs",
|
||||
"src/globe/navara/credits.js",
|
||||
"src/globe/navara/credits.test.mjs",
|
||||
"src/globe/navara/groundFloor.js",
|
||||
"src/globe/navara/groundFloor.test.mjs",
|
||||
"src/globe/navara/index.js",
|
||||
"src/globe/navara/input.js",
|
||||
"src/globe/navara/input.test.mjs",
|
||||
"src/globe/navara/internals.js",
|
||||
"src/globe/navara/internals.test.mjs",
|
||||
"src/globe/navara/maps.js",
|
||||
"src/globe/navara/maps.test.mjs",
|
||||
"src/globe/navara/sharpen.js",
|
||||
"src/globe/navara/surface.js",
|
||||
"src/globe/navara/surface.test.mjs",
|
||||
"src/globe/navara/surfaceRay.test.mjs",
|
||||
"src/globe/navara/viewOptions.js",
|
||||
"src/globe/navara/viewOptions.test.mjs",
|
||||
"src/globe/testing/contractSuite.mjs",
|
||||
"src/globe/testing/fakeEngine.js",
|
||||
"src/mapStackController.test.mjs",
|
||||
"src/orbit.test.mjs",
|
||||
"src/services/groundSnap.test.mjs",
|
||||
"src/sharelinkPose.test.mjs",
|
||||
"src/tooling/navaraAssets.test.mjs",
|
||||
"src/tooling/tileCache.test.mjs",
|
||||
"scripts/check-main-chunk.mjs",
|
||||
"src/tooling/mainChunkComposition.test.mjs",
|
||||
"src/tooling/initialChunkGuard.test.mjs",
|
||||
"src/globe/movers.test.mjs",
|
||||
"src/globe/navara/movers/pack.test.mjs",
|
||||
"src/globe/navara/movers/pickIds.test.mjs",
|
||||
"src/globe/navara/movers/moverMesh.test.mjs",
|
||||
"src/globe/navara/movers/modelMover.test.mjs",
|
||||
"src/bench/syntheticFlights.test.mjs",
|
||||
"src/globe/lines.test.mjs",
|
||||
"src/globe/navara/lines/lineMesh.test.mjs",
|
||||
"src/globe/pickDelivery.test.mjs",
|
||||
"src/globe/navara/picking.test.mjs",
|
||||
"src/data/selectionDispatcher.test.mjs",
|
||||
"src/globe/navara/cascadedLightCopy.test.mjs",
|
||||
"src/tooling/navaraDependencies.test.mjs",
|
||||
"build/navaraDependencies.js",
|
||||
"build/initialChunkGuard.js",
|
||||
"src/globe/follow.js",
|
||||
"src/globe/follow.test.mjs",
|
||||
"src/globe/cesium/follow.js",
|
||||
"src/globe/cesium/follow.test.mjs",
|
||||
"src/globe/surfaceDetail.js",
|
||||
"src/globe/surfaceDetail.test.mjs",
|
||||
"src/globe/navara/followGestures.js",
|
||||
"src/globe/navara/followGestures.test.mjs",
|
||||
"src/globe/cpuPicks.test.mjs",
|
||||
"src/app/layers/directory.js",
|
||||
"src/app/layers/renderers.js",
|
||||
"src/app/layers/renderers.test.mjs",
|
||||
"src/data/lifecycleEngine.test.mjs",
|
||||
"src/ui/shareRestorationEngine.test.mjs",
|
||||
"src/app/qaProbe.js",
|
||||
"src/app/qaProbe.test.mjs",
|
||||
"src/app/layers/qaSources.js",
|
||||
"src/app/layers/qaSources.test.mjs",
|
||||
"src/bench/w1Feeds.js",
|
||||
"src/bench/satelliteFixture.js",
|
||||
"src/bench/satelliteFixture.test.mjs",
|
||||
"src/layers/satellites/render/cesium.js",
|
||||
"src/celestialRing/render/cesium.js",
|
||||
"src/celestialRing/view.js",
|
||||
"src/layers/satellites/render/navara.js",
|
||||
"src/layers/satellites/render/navara.test.mjs",
|
||||
"src/layers/satellites/render/cesium.test.mjs",
|
||||
"src/geo/ellipsoidGeodesic.test.mjs",
|
||||
"src/bench/launchFixture.js",
|
||||
"src/layers/launches/replayClock.test.mjs",
|
||||
"src/geo/boundingSphere.test.mjs",
|
||||
"src/layers/launches/render/cesium.js",
|
||||
"src/layers/launches/launchPadFrame.test.mjs",
|
||||
"src/layers/launches/render/navara.js",
|
||||
"src/layers/launches/render/navara.test.mjs",
|
||||
"src/layers/satellites/interaction.test.mjs",
|
||||
"src/layers/launches/lifecycleSnapshot.test.mjs",
|
||||
"src/data/render/cesiumProjection.js",
|
||||
"src/data/render/cesiumTrailRenderer.js",
|
||||
"src/data/render/cesiumContactTrailRenderer.js",
|
||||
"src/layers/flights/trackingRefresh.test.mjs",
|
||||
"scripts/qa-heading-navara.mjs",
|
||||
"src/layers/aircraft/qaDrawn.test.mjs",
|
||||
"src/data/render/cesiumConstants.js",
|
||||
"src/globe/navara/movers/frameCamera.js",
|
||||
"src/globe/navara/overlayPass.js",
|
||||
"src/globe/navara/overlayPass.test.mjs",
|
||||
"src/globe/navara/movers/modelTint.js",
|
||||
"src/globe/navara/movers/frameCamera.test.mjs",
|
||||
"src/layers/localAdsb/qaDrawn.js",
|
||||
"src/layers/localAdsb/qaDrawn.test.mjs",
|
||||
"src/tooling/w1RuntimeEvidence.test.mjs",
|
||||
"scripts/fixtures/w2/earthquakes.mjs",
|
||||
"src/layers/earthquakes/render/cesium.js",
|
||||
"src/layers/earthquakes/render/cesium.test.mjs",
|
||||
"src/layers/earthquakes/render/navara.js",
|
||||
"src/layers/earthquakes/render/navaraGeometry.js",
|
||||
"src/layers/earthquakes/render/navaraGeometry.test.mjs",
|
||||
"src/layers/earthquakes/render/navaraGrid.js",
|
||||
"src/layers/earthquakes/render/navaraGrid.test.mjs",
|
||||
"src/layers/earthquakes/render/navara.test.mjs",
|
||||
"src/layers/firms/render/cesium.js",
|
||||
"src/layers/firms/render/cesium.test.mjs",
|
||||
"src/layers/firms/rectangle.js",
|
||||
"src/layers/firms/rectangle.test.mjs",
|
||||
"src/layers/firms/viewport.test.mjs",
|
||||
"scripts/fixtures/w2/firms.mjs",
|
||||
"src/layers/firms/render/navara.test.mjs",
|
||||
"src/layers/firms/render/navaraGeometry.test.mjs",
|
||||
"src/layers/firms/render/navaraSprites.test.mjs",
|
||||
"src/layers/firms/lifecycle.test.mjs",
|
||||
"scripts/fixtures/w2/radio.mjs",
|
||||
"scripts/qa-map-policy.mjs",
|
||||
"scripts/qa-map-policy.test.mjs",
|
||||
"src/layers/recentImagery/render/tiles.test.mjs",
|
||||
"src/layers/recentImagery/render/navara.test.mjs",
|
||||
"src/ui/imageryBoxNavara.test.mjs",
|
||||
"scripts/qa-w2-imagery.mjs",
|
||||
"src/globe/navara/postSamplingPass.test.mjs",
|
||||
"src/globe/navara/emptySceneRender.test.mjs",
|
||||
"src/layers/installations/render/navaraStencilBatch.test.mjs",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/firmsIntensity.test.mjs",
|
||||
"src/ui/layerPanel/RowSwitch.vue",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"gzipBytes": 2021566,
|
||||
"allowanceBytes": 20216,
|
||||
"measuredAt": "merge of origin/main ac28a11e, keyless build (GOOGLE_MAPS_API_KEY and CESIUM_ION_TOKEN empty), entry index-ChmLBAMR.js, 6 220 779 bytes, gzip level 9; main itself grew 93 873 bytes gzip-9 from the merge base (1 853 283 to 1 947 156)",
|
||||
"allowance": "1% of the baseline; growth past it fails npm run check:main-chunk until the baseline is raised on purpose"
|
||||
}
|
||||
@@ -15,9 +15,13 @@ const browserGlobals = new Set([
|
||||
'getComputedStyle',
|
||||
]);
|
||||
|
||||
/** Inspect literal module dependencies and browser-platform references without executing code. */
|
||||
export function analyzeModule(source) {
|
||||
/**
|
||||
* Inspect literal module dependencies and browser-platform references without
|
||||
* executing code. With `dynamic`, also list the import() specifiers apart.
|
||||
*/
|
||||
export function analyzeModule(source, { dynamic = false } = {}) {
|
||||
const imports = [];
|
||||
const dynamicImports = [];
|
||||
const browser = new Set();
|
||||
function walk(node, parent, field) {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
@@ -35,6 +39,8 @@ export function analyzeModule(source) {
|
||||
'Computed module imports are not allowed in runtime code',
|
||||
);
|
||||
imports.push(node.source.value);
|
||||
if (node.type === 'ImportExpression')
|
||||
dynamicImports.push(node.source.value);
|
||||
}
|
||||
if (node.type === 'CallExpression' && node.callee?.name === 'require')
|
||||
throw new Error('Runtime modules must use ES imports');
|
||||
@@ -71,7 +77,11 @@ export function analyzeModule(source) {
|
||||
}
|
||||
}
|
||||
walk(parsers.babel.parse(source));
|
||||
return { imports, browser: [...browser] };
|
||||
return {
|
||||
imports,
|
||||
browser: [...browser],
|
||||
...(dynamic && { dynamicImports }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Return static imports, literal dynamic imports and re-exports. */
|
||||
|
||||
+515
-71
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,614 @@
|
||||
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import assert from 'node:assert/strict';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { APPLICATION_LAYER_DIRECTORY } from '../src/app/layers/directory.js';
|
||||
import {
|
||||
createStrict,
|
||||
ENGINES,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
// The strict acceptance: every browser scenario against one keyless local
|
||||
// preview, under the strict network policy (scripts/qa-strict-harness.mjs),
|
||||
// writing <output>/acceptance.json, one log and one assertion manifest per
|
||||
// scenario.
|
||||
//
|
||||
// node scripts/qa-all-layer-switches.mjs http://127.0.0.1:4208 <output>
|
||||
// [--serve[=<commit>]] build <commit> (default HEAD) keyless in a clean
|
||||
// worktree and serve it with every .env key blank
|
||||
// [--scratch=DIR] where --serve puts its checkout and build
|
||||
// [--only=a,b] run only these labels
|
||||
// [--quick] the soak with 2 runs of 10 switches and 100
|
||||
// switches for the long run
|
||||
//
|
||||
// The run fails when a scenario fails, when any catalog layer (read from
|
||||
// the live page and cross-checked with src/app/layers/directory.js) has no
|
||||
// assertion on either engine, or when any network policy violation shows.
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
const output = process.argv[3]?.startsWith('--')
|
||||
? mkdtempSync(join(tmpdir(), 'gev-acceptance-'))
|
||||
: resolve(process.argv[3] ?? mkdtempSync(join(tmpdir(), 'gev-acceptance-')));
|
||||
const flag = (name) =>
|
||||
process.argv
|
||||
.find((arg) => arg === `--${name}` || arg.startsWith(`--${name}=`))
|
||||
?.split('=')[1] ?? (process.argv.includes(`--${name}`) ? true : null);
|
||||
const only = flag('only') ? String(flag('only')).split(',') : null;
|
||||
const quick = Boolean(flag('quick'));
|
||||
mkdirSync(join(output, 'evidence'), { recursive: true });
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { cwd: root, encoding: 'utf8' });
|
||||
const dirty = () =>
|
||||
git('status', '--porcelain')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => line.slice(3));
|
||||
|
||||
// Fail once, with the fix, instead of once per scenario.
|
||||
let chrome;
|
||||
try {
|
||||
const browser = await puppeteer.launch({ headless: true });
|
||||
chrome = await browser.version();
|
||||
await browser.close();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Puppeteer cannot start Chrome: ' +
|
||||
String(error?.message)
|
||||
.split('\n')[0]
|
||||
.replace(/ This can occur.*/, '') +
|
||||
'\nPoint PUPPETEER_EXECUTABLE_PATH at an installed Chrome, for example\n' +
|
||||
' PUPPETEER_EXECUTABLE_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"\n' +
|
||||
'or download the pinned build with `npx puppeteer browsers install chrome`.',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Every name the checkout's .env files define, plus key-like names in this
|
||||
// environment: all blank for the build and the preview.
|
||||
const KEY_LIKE = /(_KEY|_TOKEN|_SECRET|_CLIENT_ID|PASSWORD)$/;
|
||||
/** The [name, value] pairs of the checkout's .env and .env.local, in order. */
|
||||
const dotenv = () =>
|
||||
['.env', '.env.local']
|
||||
.filter((file) => existsSync(join(root, file)))
|
||||
.flatMap((file) => readFileSync(join(root, file), 'utf8').split('\n'))
|
||||
.map((line) => line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)/))
|
||||
.filter(Boolean)
|
||||
.map(([, name, raw]) => [name, raw.trim().replace(/^["']|["']$/g, '')]);
|
||||
const dotenvNames = () => [...new Set(dotenv().map(([name]) => name))].sort();
|
||||
const envNames = () =>
|
||||
[
|
||||
...new Set([
|
||||
...dotenvNames(),
|
||||
...Object.keys(process.env).filter((name) => KEY_LIKE.test(name)),
|
||||
]),
|
||||
].sort();
|
||||
// Credentials only (model names and URLs are settings the code may also
|
||||
// carry as defaults).
|
||||
const envValues = () =>
|
||||
dotenv()
|
||||
.filter(([name, value]) => KEY_LIKE.test(name) && value.length >= 8)
|
||||
.map(([, value]) => value);
|
||||
const scrubbed = () => ({
|
||||
...process.env,
|
||||
...Object.fromEntries(envNames().map((name) => [name, ''])),
|
||||
});
|
||||
|
||||
// The report names no local directory: the output, the served checkout and
|
||||
// this repository become placeholders (logs carry stack traces).
|
||||
const machinePaths = (text) => {
|
||||
let out = String(text);
|
||||
for (const [path, name] of [
|
||||
[served?.scratch, '<scratch>'],
|
||||
[output, '<output>'],
|
||||
[root.replace(/\/$/, ''), '<repo>'],
|
||||
])
|
||||
if (path) out = out.split(path).join(name);
|
||||
return out;
|
||||
};
|
||||
let served = null;
|
||||
let preview = null;
|
||||
const cleanup = () => {
|
||||
if (preview && !preview.killed) preview.kill('SIGTERM');
|
||||
if (served?.worktree)
|
||||
try {
|
||||
git('worktree', 'remove', '--force', served.worktree);
|
||||
served.worktree = null;
|
||||
} catch (error) {
|
||||
console.error('Could not remove', served.worktree, error.message);
|
||||
}
|
||||
};
|
||||
process.on('exit', cleanup);
|
||||
process.on('SIGINT', () => process.exit(130));
|
||||
|
||||
if (flag('serve')) {
|
||||
const commit = git(
|
||||
'rev-parse',
|
||||
flag('serve') === true ? 'HEAD' : String(flag('serve')),
|
||||
).trim();
|
||||
// --scratch=DIR keeps the checkout and build out of the system temp.
|
||||
const scratchRoot = flag('scratch')
|
||||
? resolve(String(flag('scratch')))
|
||||
: tmpdir();
|
||||
mkdirSync(scratchRoot, { recursive: true });
|
||||
const scratch = mkdtempSync(join(scratchRoot, 'gev-acceptance-build-'));
|
||||
const worktree = join(scratch, 'checkout');
|
||||
const dist = join(scratch, 'dist');
|
||||
git('worktree', 'add', '--detach', worktree, commit);
|
||||
symlinkSync(join(root, 'node_modules'), join(worktree, 'node_modules'));
|
||||
served = {
|
||||
scratch,
|
||||
commit,
|
||||
worktree,
|
||||
dist,
|
||||
// Names from .env files only; other key-like variables of this shell
|
||||
// are blanked too but only counted.
|
||||
blankedEnv: dotenvNames(),
|
||||
otherKeyLikeVariablesBlanked: envNames().filter(
|
||||
(name) => !dotenvNames().includes(name),
|
||||
).length,
|
||||
envFilesInCheckout: ['.env', '.env.local'].filter((f) =>
|
||||
existsSync(join(worktree, f)),
|
||||
),
|
||||
};
|
||||
console.log('Building', commit, 'keyless into', dist);
|
||||
const build = spawnSync(
|
||||
process.execPath,
|
||||
[join(root, 'node_modules/vite/bin/vite.js'), 'build', '--outDir', dist],
|
||||
{ cwd: worktree, env: scrubbed(), encoding: 'utf8', maxBuffer: 1 << 26 },
|
||||
);
|
||||
if (build.status !== 0) {
|
||||
console.error(build.stdout + build.stderr);
|
||||
process.exit(2);
|
||||
}
|
||||
// No .env credential value may reach the built files.
|
||||
const values = envValues();
|
||||
let leaks = 0;
|
||||
const walk = (dir) => {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const file = join(dir, name);
|
||||
if (statSync(file).isDirectory()) walk(file);
|
||||
else if (/\.(js|html|json|css|webmanifest)$/.test(name)) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
for (const value of values) if (text.includes(value)) leaks++;
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(dist);
|
||||
served.distCredentialValuesFound = leaks;
|
||||
served.mainChunk = JSON.parse(
|
||||
spawnSync(
|
||||
process.execPath,
|
||||
[join(root, 'scripts/check-main-chunk.mjs'), `--dist=${dist}`, '--json'],
|
||||
{ cwd: root, encoding: 'utf8' },
|
||||
).stdout || 'null',
|
||||
);
|
||||
preview = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
join(root, 'node_modules/vite/bin/vite.js'),
|
||||
'preview',
|
||||
'--outDir',
|
||||
dist,
|
||||
'--host',
|
||||
base.hostname,
|
||||
'--port',
|
||||
base.port,
|
||||
'--strictPort',
|
||||
],
|
||||
{ cwd: worktree, env: scrubbed(), stdio: 'ignore' },
|
||||
);
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try {
|
||||
if ((await fetch(base.origin + '/')).ok) break;
|
||||
} catch {
|
||||
// Not up yet.
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(base.origin + '/', {
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!response.ok) throw Error('HTTP ' + response.status);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'No preview answers at ' + base.origin + ' (' + error.message + ').',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// The preview reports no paid key: its own keyless status routes.
|
||||
const probe = async (path) => {
|
||||
try {
|
||||
const r = await fetch(base.origin + path, {
|
||||
headers: { 'Sec-Fetch-Site': 'same-origin' },
|
||||
});
|
||||
return { status: r.status, body: await r.json().catch(() => null) };
|
||||
} catch (error) {
|
||||
return { error: error.message };
|
||||
}
|
||||
};
|
||||
const keys = {
|
||||
openaiHudSummary: await probe('/api/openai/hud-summary/status'),
|
||||
firms: await probe('/api/firms/status'),
|
||||
tomtom: await probe('/api/tomtom/status'),
|
||||
mapillary: await probe('/api/mapillary/status'),
|
||||
};
|
||||
const paidKeysConfigured = [
|
||||
keys.openaiHudSummary.body?.configured !== false && 'OpenAI',
|
||||
keys.firms.body?.hasKey !== false && 'FIRMS',
|
||||
keys.tomtom.body?.hasKey !== false && 'TomTom',
|
||||
keys.mapillary.body?.configured !== false && 'Mapillary',
|
||||
].filter(Boolean);
|
||||
|
||||
const dirtyFiles = dirty();
|
||||
const soakRuns = quick
|
||||
? ['--runs=2', '--swaps=10']
|
||||
: ['--runs=6', '--swaps=50'];
|
||||
const longRun = quick ? '--swaps=100' : '--swaps=500';
|
||||
// label, script, layers, extra arguments, timeout in seconds.
|
||||
const runs = [
|
||||
['engine-switch', 'engine-switch', ['earthquakes']],
|
||||
['director-engine-switch', 'director-engine-switch', []],
|
||||
['layer-handoff', 'layer-handoff', ['flights']],
|
||||
['layer-handoff--military', 'layer-handoff', ['military'], ['--military']],
|
||||
[
|
||||
'ground-layer-switch',
|
||||
'ground-layer-switch',
|
||||
['ais-live-vessels', 'air-quality', 'gnss-integrity', 'fire-perimeters'],
|
||||
],
|
||||
[
|
||||
'contact-switch',
|
||||
'contact-switch',
|
||||
['military-awareness', 'military-installations'],
|
||||
],
|
||||
['local-adsb-engine-switch', 'local-adsb-engine-switch', ['local-adsb']],
|
||||
['ghostmaps-switch', 'ghostmaps-switch', ['s2-ghostmaps']],
|
||||
[
|
||||
'open-layers-switch',
|
||||
'open-layers-switch',
|
||||
['drone-airspace', 'osm-places', 'sea-marks', 'ski-pistes'],
|
||||
],
|
||||
[
|
||||
'weather-engine-switch',
|
||||
'weather-engine-switch',
|
||||
[
|
||||
'weather-radar',
|
||||
'weather-satellite',
|
||||
'weather-lightning',
|
||||
'weather-cyclones',
|
||||
],
|
||||
],
|
||||
['wind-engine-switch', 'wind-engine-switch', ['wind']],
|
||||
[
|
||||
'infrastructure-engine-switch',
|
||||
'infrastructure-engine-switch',
|
||||
['local-dams', 'local-datacenters'],
|
||||
],
|
||||
[
|
||||
'submarine-cables-engine-switch',
|
||||
'submarine-cables-engine-switch',
|
||||
['telegeography-submarine-cables'],
|
||||
],
|
||||
['directions-engine-switch', 'directions-engine-switch', ['directions']],
|
||||
['nepal-locator-switch', 'nepal-locator-switch', ['bhote-koshi-locator']],
|
||||
['nepal-event-switch', 'nepal-event-switch', ['bhote-koshi-2026']],
|
||||
['traffic-engine-switch', 'traffic-engine-switch', ['traffic']],
|
||||
[
|
||||
'traffic-engine-switch--simulation',
|
||||
'traffic-engine-switch',
|
||||
['traffic'],
|
||||
['--simulation'],
|
||||
],
|
||||
[
|
||||
'street-level-engine-switch',
|
||||
'street-level-engine-switch',
|
||||
['street-level'],
|
||||
],
|
||||
[
|
||||
'street-level-engine-switch--panoramax',
|
||||
'street-level-engine-switch',
|
||||
['street-level'],
|
||||
['--panoramax'],
|
||||
],
|
||||
['radio-engine-switch', 'radio-engine-switch', ['radio']],
|
||||
['w2-imagery', 'w2-imagery', ['recent-imagery']],
|
||||
['fire-camera-switch', 'fire-camera-switch', ['alpr-cameras']],
|
||||
[
|
||||
'fire-camera-switch--fires',
|
||||
'fire-camera-switch',
|
||||
['local-firms'],
|
||||
['--fires'],
|
||||
],
|
||||
['urban-engine-switch', 'urban-engine-switch', ['bikeshare']],
|
||||
[
|
||||
'urban-engine-switch--transit',
|
||||
'urban-engine-switch',
|
||||
['transit'],
|
||||
['--transit'],
|
||||
],
|
||||
['cctv-engine-switch', 'cctv-engine-switch', ['cctv']],
|
||||
['space-engine-switch', 'space-engine-switch', ['satellites']],
|
||||
[
|
||||
'space-engine-switch--missions',
|
||||
'space-engine-switch',
|
||||
['rocket-launches'],
|
||||
['--missions'],
|
||||
],
|
||||
// One application per page, and voice across a switch: these block paid
|
||||
// routes themselves but do not run under the strict harness, so they add
|
||||
// no assertion manifest and no network ledger.
|
||||
['app-once', 'app-once', [], [], 600, true],
|
||||
['voice-engine-switch', 'voice-engine-switch', [], [], 300, true],
|
||||
['strict-rapid-switch', 'strict-rapid-switch', []],
|
||||
['strict-hidden-switch', 'strict-hidden-switch', []],
|
||||
['strict-context-loss', 'strict-context-loss', []],
|
||||
['strict-parked-release', 'strict-parked-release', []],
|
||||
['strict-navara-hd', 'strict-navara-hd', []],
|
||||
['strict-failure-matrix', 'strict-failure-matrix', [], [], 600],
|
||||
['strict-cold-chunks', 'strict-cold-chunks', []],
|
||||
['strict-prod-smoke', 'strict-prod-smoke', []],
|
||||
// The app's visual styles, bloom, contact sonar, cockpit clouds, and the
|
||||
// annotations, Draw, Import and the scope mask with the celestial ring on
|
||||
// Navara (those are tools and display overlays, not catalog layers:
|
||||
// TOOL_IDS).
|
||||
['navara-visual-styles', 'navara-visual-styles', []],
|
||||
['navara-bloom', 'navara-bloom', []],
|
||||
['navara-sonar', 'navara-sonar', []],
|
||||
['navara-cockpit-clouds', 'navara-cockpit-clouds', []],
|
||||
['navara-annotations', 'navara-annotations', ['annotations']],
|
||||
['navara-draw', 'navara-draw', ['draw']],
|
||||
['navara-geo-import', 'navara-geo-import', ['geo-import']],
|
||||
['navara-scope', 'navara-scope', ['scope-mask', 'celestial-ring']],
|
||||
['strict-soak', 'strict-soak', [], soakRuns, 1200],
|
||||
[
|
||||
'strict-soak--long',
|
||||
'strict-soak',
|
||||
[],
|
||||
['--runs=1', longRun, '--assert'],
|
||||
1800,
|
||||
],
|
||||
];
|
||||
// Ids a scenario covers that are tools, not catalog layers: their
|
||||
// assertions are recorded, but the catalog comparison leaves them out.
|
||||
const TOOL_IDS = new Set([
|
||||
'annotations',
|
||||
'draw',
|
||||
'geo-import',
|
||||
'scope-mask',
|
||||
'celestial-ring',
|
||||
]);
|
||||
const scenarioIds = [
|
||||
...new Set(
|
||||
runs.flatMap(([, , ids]) => ids).filter((id) => !TOOL_IDS.has(id)),
|
||||
),
|
||||
].sort();
|
||||
const directoryIds = APPLICATION_LAYER_DIRECTORY.map((l) => l.id).sort();
|
||||
|
||||
// The catalog as the live page has it, on both engines.
|
||||
const liveCatalog = {};
|
||||
{
|
||||
const strict = createStrict({
|
||||
origin: base.origin,
|
||||
layers: [],
|
||||
name: 'catalog',
|
||||
});
|
||||
const browser = await strict.launch();
|
||||
try {
|
||||
for (const engine of ENGINES) {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(
|
||||
`${base.origin}/?engine=${engine}&welcome=0#v=2&lat=30.27&lon=-97.74&alt=12000&pitch=-90&map=osm&l=`,
|
||||
);
|
||||
await waitForEngine(page, engine);
|
||||
liveCatalog[engine] = await page.evaluate(() =>
|
||||
[...window.__godsEyeView.dataManager.layers.keys()].sort(),
|
||||
);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
liveCatalog.violations = strict.ledger.violations;
|
||||
}
|
||||
|
||||
const scenarios = [];
|
||||
const report = {
|
||||
commit: git('rev-parse', 'HEAD').trim(),
|
||||
dirty: dirtyFiles.length > 0,
|
||||
dirtyFiles,
|
||||
dirtyFilesAtEnd: null,
|
||||
chrome,
|
||||
puppeteerExecutable: process.env.PUPPETEER_EXECUTABLE_PATH || null,
|
||||
node: process.version,
|
||||
platform: process.platform + ' ' + process.arch,
|
||||
baseUrl: base.origin,
|
||||
preview: served
|
||||
? {
|
||||
servedByRunner: true,
|
||||
appCommit: served.commit,
|
||||
appCheckoutClean: true,
|
||||
scenariosFrom: 'the same clean checkout',
|
||||
envFilesInCheckout: served.envFilesInCheckout,
|
||||
blankedEnv: served.blankedEnv,
|
||||
otherKeyLikeVariablesBlanked: served.otherKeyLikeVariablesBlanked,
|
||||
distCredentialValuesFound: served.distCredentialValuesFound,
|
||||
}
|
||||
: { servedByRunner: false },
|
||||
keyProbes: keys,
|
||||
paidKeysConfigured,
|
||||
mainChunk: served?.mainChunk ?? null,
|
||||
networkPolicy: {
|
||||
blockedFirst:
|
||||
'Google (*.googleapis.com, *.google.com, maps.google.*, *.gstatic.com), Cesium ion (*.cesium.com), preview /api/openai/*, /api/realtime/*, /api/google/*',
|
||||
allowed:
|
||||
'the preview origin, each scenario’s declared fixture URLs, keyless probes answered by the harness, and the free tile hosts answered from the disk cache',
|
||||
keylessProbes: (await import('./qa-map-policy.mjs')).KEYLESS_PROBES.map(
|
||||
(p) => p.pattern,
|
||||
),
|
||||
tileHosts: (await import('./qa-map-policy.mjs')).STRICT_TILE_HOSTS,
|
||||
},
|
||||
catalog: {
|
||||
directory: directoryIds,
|
||||
live: liveCatalog,
|
||||
scenarios: scenarioIds,
|
||||
},
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: null,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
networkViolations: 0,
|
||||
coverage: null,
|
||||
uncovered: null,
|
||||
scenarios,
|
||||
};
|
||||
const save = () =>
|
||||
writeFileSync(
|
||||
join(output, 'acceptance.json'),
|
||||
machinePaths(JSON.stringify(report, null, 2)) + '\n',
|
||||
);
|
||||
save();
|
||||
|
||||
for (const [label, name, layers, extra = [], seconds = 300, external] of runs) {
|
||||
if (only && !only.includes(label)) continue;
|
||||
const script = 'scripts/qa-' + name + '.mjs';
|
||||
const evidence = join(output, 'evidence', label + '.json');
|
||||
rmSync(evidence, { force: true });
|
||||
const args = [base.origin, ...extra];
|
||||
if (external) args.splice(0, 1, base.origin + '/');
|
||||
else args.push(`--layers=${layers.join(',')}`, `--evidence=${evidence}`);
|
||||
const start = Date.now();
|
||||
// With --serve the scenarios run from the same clean checkout the app was
|
||||
// built from, so other work in this tree cannot change them.
|
||||
const scripts = served?.worktree ?? root;
|
||||
const run = spawnSync(process.execPath, [join(scripts, script), ...args], {
|
||||
encoding: 'utf8',
|
||||
timeout: seconds * 1000,
|
||||
maxBuffer: 1 << 26,
|
||||
env: scrubbed(),
|
||||
});
|
||||
const status =
|
||||
run.status === 0
|
||||
? 'pass'
|
||||
: run.error?.code === 'ETIMEDOUT'
|
||||
? 'timeout'
|
||||
: 'fail';
|
||||
const log = machinePaths(
|
||||
(run.stdout || '') + (run.stderr || '') + (run.error?.message || ''),
|
||||
);
|
||||
writeFileSync(join(output, label + '.log'), log);
|
||||
const manifest = existsSync(evidence)
|
||||
? JSON.parse(machinePaths(readFileSync(evidence, 'utf8')))
|
||||
: null;
|
||||
if (manifest)
|
||||
writeFileSync(evidence, JSON.stringify(manifest, null, 2) + '\n');
|
||||
scenarios.push({
|
||||
label,
|
||||
script,
|
||||
args: args.map((arg) =>
|
||||
arg.includes(output) ? arg.replace(output, '<output>') : arg,
|
||||
),
|
||||
layers,
|
||||
external: Boolean(external),
|
||||
status,
|
||||
exitCode: run.status,
|
||||
seconds: Math.round((Date.now() - start) / 100) / 10,
|
||||
log: label + '.log',
|
||||
logSha256: createHash('sha256').update(log).digest('hex'),
|
||||
evidence: manifest ? 'evidence/' + label + '.json' : null,
|
||||
assertions: manifest?.assertions.length ?? 0,
|
||||
coverage: manifest?.coverage ?? null,
|
||||
picks: manifest?.picks ?? [],
|
||||
network: manifest
|
||||
? {
|
||||
requests: manifest.network.requests,
|
||||
violations: manifest.network.violations,
|
||||
external: manifest.network.external,
|
||||
tileCache: manifest.network.tileCache,
|
||||
probes: manifest.network.probes,
|
||||
routes: manifest.network.routes,
|
||||
previewApi: manifest.network.previewApi,
|
||||
fixtures: manifest.network.fixtures,
|
||||
}
|
||||
: null,
|
||||
contracts: manifest?.contracts ?? null,
|
||||
error: manifest?.error ?? null,
|
||||
});
|
||||
if (manifest?.soak) scenarios.at(-1).soak = manifest.soak;
|
||||
report.passed = scenarios.filter((row) => row.status === 'pass').length;
|
||||
report.failed = scenarios.length - report.passed;
|
||||
report.networkViolations =
|
||||
scenarios.reduce(
|
||||
(sum, row) => sum + (row.network?.violations.length ?? 0),
|
||||
0,
|
||||
) + liveCatalog.violations.length;
|
||||
save();
|
||||
console.log(status.toUpperCase(), label, scenarios.at(-1).seconds);
|
||||
}
|
||||
|
||||
// Coverage that cannot be faked: every catalog id needs an assertion on each
|
||||
// engine from some scenario's manifest.
|
||||
const catalogIds = [
|
||||
...new Set([...directoryIds, ...(liveCatalog.navara ?? [])]),
|
||||
].sort();
|
||||
report.coverage = Object.fromEntries(
|
||||
catalogIds.map((id) => [
|
||||
id,
|
||||
Object.fromEntries(
|
||||
ENGINES.map((engine) => [
|
||||
engine,
|
||||
scenarios.reduce(
|
||||
(sum, row) => sum + (row.coverage?.[id]?.[engine] ?? 0),
|
||||
0,
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
report.uncovered = Object.entries(report.coverage).flatMap(([id, counts]) =>
|
||||
ENGINES.filter((engine) => !counts[engine]).map(
|
||||
(engine) => `${id}@${engine}`,
|
||||
),
|
||||
);
|
||||
report.catalogMatches =
|
||||
JSON.stringify(liveCatalog.navara) === JSON.stringify(directoryIds) &&
|
||||
JSON.stringify(liveCatalog.cesium) === JSON.stringify(directoryIds) &&
|
||||
JSON.stringify(scenarioIds) === JSON.stringify(directoryIds);
|
||||
report.finishedAt = new Date().toISOString();
|
||||
report.dirtyFilesAtEnd = dirty();
|
||||
save();
|
||||
console.log(
|
||||
`${catalogIds.length} catalog layers, ${report.uncovered.length} engine slots without an assertion, ` +
|
||||
`${report.networkViolations} network violations, ${report.passed}/${scenarios.length} scenarios, ` +
|
||||
`paid keys configured: ${paidKeysConfigured.join(', ') || 'none'}. ${join(output, 'acceptance.json')}`,
|
||||
);
|
||||
if (
|
||||
report.failed ||
|
||||
(!only && report.uncovered.length) ||
|
||||
report.networkViolations ||
|
||||
paidKeysConfigured.length ||
|
||||
!report.catalogMatches
|
||||
)
|
||||
process.exitCode = 1;
|
||||
// The preview child would keep this process alive: stop it and drop the
|
||||
// checkout now.
|
||||
cleanup();
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* qa-alpr-journey.mjs — ALPR cameras on a realistic Austin camera journey.
|
||||
*
|
||||
* Run: node scripts/qa-alpr-journey.mjs http://localhost:4173 [--headful]
|
||||
* Run: node scripts/qa-alpr-journey.mjs <dev-server-url> [--fixture]
|
||||
* [--passes off,on,on] [--out qa-shots/alpr-journey] [--no-assert]
|
||||
*
|
||||
* Drives the same eased camera path several times: a neighbourhood by Camp
|
||||
@@ -14,7 +14,7 @@
|
||||
*
|
||||
* Per stop it records: time from the camera stopping to cameras shown (or an
|
||||
* explicit "zoom in" state), the camera count and row status, rAF interval and
|
||||
* Cesium render CPU time (mean / p50 / p95) while moving and while holding, and
|
||||
* engine render CPU time (mean / p50 / p95) while moving and while holding, and
|
||||
* marker stability while the camera is still (camera-set changes after the stop
|
||||
* has settled). Asserts, unless --no-assert: neighbourhood and downtown show
|
||||
* cameras within 2 s on the warm pass; the whole-city stop shows cameras or a
|
||||
@@ -39,7 +39,10 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { blockPaidQaMaps } from './qa-map-policy.mjs';
|
||||
import sharp from 'sharp';
|
||||
import { installAlprFixture } from './fixtures/w2/alpr.mjs';
|
||||
import { redact, isExpectedPreviewSetupError } from './qa-browserEvidence.mjs';
|
||||
import {
|
||||
createScreencast,
|
||||
frameStats,
|
||||
@@ -60,12 +63,20 @@ const option = (name, fallback) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
|
||||
};
|
||||
const appUrl =
|
||||
argv.find((arg) => /^https?:/.test(arg)) || 'http://localhost:4173';
|
||||
const appUrl = argv.find((arg) => /^https?:/.test(arg));
|
||||
if (!appUrl) throw new Error('An explicit dev-server URL is required');
|
||||
if (
|
||||
['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
|
||||
new URL(appUrl).port,
|
||||
)
|
||||
)
|
||||
throw new Error('ALPR QA may not use a reserved port');
|
||||
const outDir = path.resolve(option('--out', 'qa-shots/alpr-journey'));
|
||||
const passes = option('--passes', 'off,on,on').split(',');
|
||||
const assertOn = !argv.includes('--no-assert');
|
||||
const dropRenders = argv.includes('--self-test-drop-renders');
|
||||
if (dropRenders && new URL(appUrl).searchParams.get('engine') === 'navara')
|
||||
throw new Error('The legacy render-drop self-test requires Cesium');
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const cyanCount = async (file) => {
|
||||
@@ -170,6 +181,8 @@ const result = {
|
||||
startedAt: new Date().toISOString(),
|
||||
passes: [],
|
||||
consoleErrors: [],
|
||||
expectedPreviewErrors: [],
|
||||
failedRequests: [],
|
||||
failures: [],
|
||||
};
|
||||
const fail = (message) => {
|
||||
@@ -179,14 +192,44 @@ const fail = (message) => {
|
||||
let page;
|
||||
try {
|
||||
page = await browser.newPage();
|
||||
await blockPaidQaMaps(page);
|
||||
if (argv.includes('--fixture')) await installAlprFixture(page);
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.evaluateOnNewDocument(() =>
|
||||
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'),
|
||||
);
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') result.consoleErrors.push(message.text());
|
||||
if (
|
||||
isExpectedPreviewSetupError(message, {
|
||||
preview: argv.includes('--preview'),
|
||||
baseUrl: appUrl,
|
||||
})
|
||||
) {
|
||||
result.expectedPreviewErrors.push('/api/setup/status: 404');
|
||||
return;
|
||||
}
|
||||
if (message.type() === 'error') {
|
||||
let resource = '(location unavailable)';
|
||||
try {
|
||||
const url = new URL(message.location()?.url);
|
||||
resource = url.origin + url.pathname;
|
||||
} catch {
|
||||
/* no resource location */
|
||||
}
|
||||
result.consoleErrors.push(redact(`${message.text()} — ${resource}`));
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) =>
|
||||
result.consoleErrors.push(redact(error.message)),
|
||||
);
|
||||
page.on('requestfailed', (request) => {
|
||||
if (result.failedRequests.length >= 200) return;
|
||||
const url = new URL(request.url());
|
||||
result.failedRequests.push({
|
||||
resource: redact(url.origin + url.pathname),
|
||||
error: request.failure()?.errorText,
|
||||
});
|
||||
});
|
||||
page.on('pageerror', (error) => result.consoleErrors.push(error.message));
|
||||
const url = new URL(appUrl);
|
||||
url.searchParams.set('welcome', '0');
|
||||
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 90_000 });
|
||||
@@ -252,6 +295,8 @@ try {
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
if (window.__godsEyeView.engine.kind === 'navara')
|
||||
return window.__godsEyeView.engine.surface.tilesLoaded();
|
||||
const { scene } = window.__godsEyeView.viewer;
|
||||
if (scene.globe.show) return scene.globe.tilesLoaded;
|
||||
for (let i = 0; i < scene.primitives.length; i++) {
|
||||
@@ -308,8 +353,31 @@ try {
|
||||
// changed state without asking for a render.
|
||||
let staleFrame = null;
|
||||
if (alpr) {
|
||||
const shown = await cyanCount(await page.screenshot({ type: 'png' }));
|
||||
const shownImage = await page.screenshot({ type: 'png' });
|
||||
const shown = await cyanCount(shownImage);
|
||||
await page.evaluate(async () => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
if (engine.kind === 'navara') {
|
||||
await new Promise((resolve, reject) => {
|
||||
let count = 4;
|
||||
const timeout = setTimeout(() => {
|
||||
remove();
|
||||
reject(new Error('ALPR forced-frame deadline'));
|
||||
}, 10000);
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
if (--count === 0) {
|
||||
remove();
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
} else
|
||||
requestAnimationFrame(() =>
|
||||
engine.render.request('alpr-qa-frame'),
|
||||
);
|
||||
});
|
||||
engine.render.request('alpr-qa-frame');
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { scene } = window.__godsEyeView.viewer;
|
||||
const mode = scene.requestRenderMode;
|
||||
scene.requestRenderMode = false;
|
||||
@@ -317,12 +385,23 @@ try {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
scene.requestRenderMode = mode;
|
||||
});
|
||||
const forced = await cyanCount(await page.screenshot({ type: 'png' }));
|
||||
const forcedImage = await page.screenshot({ type: 'png' });
|
||||
const forced = await cyanCount(forcedImage);
|
||||
staleFrame = {
|
||||
shown,
|
||||
forced,
|
||||
stale: Math.abs(forced - shown) > Math.max(8, forced * 0.15),
|
||||
};
|
||||
if (staleFrame.stale) {
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, `${passName}-${stop.name}-shown.png`),
|
||||
shownImage,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, `${passName}-${stop.name}-forced.png`),
|
||||
forcedImage,
|
||||
);
|
||||
}
|
||||
}
|
||||
const moving = frameStats(await readFrames(page, moveStart, holdStart));
|
||||
const holding = frameStats(await readFrames(page, holdStart, holdEnd));
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
// The application is created once per page: switching engines detaches and
|
||||
// attaches only the engine-bound parts. This gate runs three Cesium→Navara→
|
||||
// Cesium round trips and checks that every session object keeps its identity,
|
||||
// an open panel, typed text and the focused field survive, Escape still does
|
||||
// what it did before the switches, window/document listeners do not grow,
|
||||
// and a failed candidate rolls back onto the same session objects.
|
||||
//
|
||||
// node scripts/qa-app-once.mjs http://127.0.0.1:<port>/ [--report]
|
||||
// `--report` prints the measurements without asserting them.
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
idDifference,
|
||||
launch,
|
||||
listenerCounts,
|
||||
listenerDifference,
|
||||
openSessionPage,
|
||||
previewBase,
|
||||
readSession,
|
||||
requestEngine,
|
||||
sessionAddress,
|
||||
switchEngine,
|
||||
waitReady,
|
||||
} from './qa/appSessionHarness.mjs';
|
||||
|
||||
const base = previewBase(process.argv[2]);
|
||||
const reportOnly = process.argv.includes('--report');
|
||||
const report = { switches: [], failures: [] };
|
||||
const check = (condition, message, detail) => {
|
||||
if (condition) return;
|
||||
report.failures.push({ message, detail });
|
||||
if (!reportOnly)
|
||||
assert.fail(`${message}${detail ? ` ${JSON.stringify(detail)}` : ''}`);
|
||||
};
|
||||
|
||||
const FIELD = '#location-search';
|
||||
const PANEL = 'data-panel';
|
||||
|
||||
// The page's visible UI state the switches must leave alone.
|
||||
const readUi = (page) =>
|
||||
page.evaluate(
|
||||
(field, panel) => ({
|
||||
value: document.querySelector(field)?.value ?? null,
|
||||
focused: document.activeElement?.id || null,
|
||||
panelOpen: !document
|
||||
.getElementById(panel)
|
||||
?.classList.contains('collapsed'),
|
||||
}),
|
||||
FIELD,
|
||||
PANEL,
|
||||
);
|
||||
|
||||
// Focus the field, press Escape and read what changed; then put the state
|
||||
// back. The same press before and after the switches must have the same
|
||||
// effect.
|
||||
async function escapeEffect(page) {
|
||||
await page.focus(FIELD);
|
||||
const before = await readUi(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
const after = await readUi(page);
|
||||
return { before, after };
|
||||
}
|
||||
|
||||
async function prepareUi(page) {
|
||||
await page.evaluate((panel) => {
|
||||
const style = window.__godsEyeView.styleManager;
|
||||
style.setPanelCollapsed(panel, false, { explicit: true });
|
||||
style.setPanelCollapsed('location-bar', false, { explicit: true });
|
||||
}, PANEL);
|
||||
await page.waitForFunction(
|
||||
(field) => document.querySelector(field)?.offsetParent !== null,
|
||||
{},
|
||||
FIELD,
|
||||
);
|
||||
await page.evaluate((field) => {
|
||||
const input = document.querySelector(field);
|
||||
input.focus();
|
||||
input.value = 'Reykjav';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}, FIELD);
|
||||
}
|
||||
|
||||
// The session's tools: a mark on the annotation board, an imported file and
|
||||
// the Draw shape, set on Cesium before the switches.
|
||||
async function prepareTools(page) {
|
||||
return page.evaluate(async () => {
|
||||
const annotated = await window.__gevAnnotations.annotate([
|
||||
{ type: 'pin', latitude: 42.3554, longitude: -71.0605, label: 'QA mark' },
|
||||
]);
|
||||
const file = new File(
|
||||
[
|
||||
JSON.stringify({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'QA area' },
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[-71.07, 42.35],
|
||||
[-71.05, 42.35],
|
||||
[-71.05, 42.36],
|
||||
[-71.07, 42.35],
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
'qa-area.geojson',
|
||||
{ type: 'application/geo+json' },
|
||||
);
|
||||
const imported = await window.__gevGeoImport.importFiles([file]);
|
||||
window.__gevDrawTool.setShape('line');
|
||||
return { annotated: annotated.drawn, imported: imported[0]?.ok };
|
||||
});
|
||||
}
|
||||
|
||||
const readTools = (page) =>
|
||||
page.evaluate(() => ({
|
||||
marks: window.__gevAnnotations?.count() ?? null,
|
||||
marksDrawn: window.__gevAnnotations?.attached ?? null,
|
||||
files: window.__gevGeoImport?.list().length ?? null,
|
||||
filesDrawn: window.__gevGeoImport?.diagnostics().drawn ?? null,
|
||||
drawShape: window.__gevDrawTool?.shape ?? null,
|
||||
}));
|
||||
|
||||
// Listeners whose difference is the parked renderer's: the Navara engine
|
||||
// keeps its own input, resize and attribution listeners while it is parked
|
||||
// for a switch back (scripts that are not the session's).
|
||||
const PARKED_RENDERER = /@navara-engine\.js$/;
|
||||
|
||||
const browser = await launch();
|
||||
try {
|
||||
const { page, errors } = await openSessionPage(
|
||||
browser,
|
||||
sessionAddress(base, { engine: 'cesium' }),
|
||||
);
|
||||
await waitReady(page, 'cesium');
|
||||
// Let the first boot's deferred listeners land before counting.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
const boot = await readSession(page);
|
||||
const bootListeners = await listenerCounts(page, { bySource: true });
|
||||
report.boot = boot;
|
||||
report.toolsPrepared = await prepareTools(page);
|
||||
check(
|
||||
report.toolsPrepared.annotated === 1 && report.toolsPrepared.imported,
|
||||
'the tools could not be prepared',
|
||||
report.toolsPrepared,
|
||||
);
|
||||
// Let the imported file finish drawing.
|
||||
await page.waitForFunction(
|
||||
() => window.__gevGeoImport.diagnostics().drawn === 1,
|
||||
);
|
||||
const toolsBefore = await readTools(page);
|
||||
report.toolsBefore = toolsBefore;
|
||||
|
||||
await prepareUi(page);
|
||||
const escapeBefore = await escapeEffect(page);
|
||||
report.escapeBefore = escapeBefore;
|
||||
await prepareUi(page);
|
||||
const uiBefore = await readUi(page);
|
||||
report.uiBefore = uiBefore;
|
||||
|
||||
const sequence = ['navara', 'cesium', 'navara', 'cesium', 'navara', 'cesium'];
|
||||
// Dev guard: once an engine is parked, nothing may move its camera or
|
||||
// ask it to draw until it is attached again (its own resume aside).
|
||||
await page.evaluate(() => {
|
||||
window.__qaParkedCalls = [];
|
||||
window.__qaWatchParked = (engine) => {
|
||||
if (!engine || engine.__qaWatched) return;
|
||||
engine.__qaWatched = true;
|
||||
const watch = (owner, names, label) => {
|
||||
for (const name of names) {
|
||||
const original = owner[name];
|
||||
if (typeof original !== 'function') continue;
|
||||
owner[name] = function (...args) {
|
||||
const parked = window.__godsEyeView?.engine !== engine;
|
||||
const resume = name === 'request' && args[0] === 'engine-resume';
|
||||
if (parked && !resume && !window.__qaSwitching)
|
||||
window.__qaParkedCalls.push({
|
||||
call: `${engine.kind}.${label}.${name}`,
|
||||
reason: typeof args[0] === 'string' ? args[0] : null,
|
||||
stack: new Error().stack.split('\n').slice(2, 6).join(' | '),
|
||||
});
|
||||
return original.apply(this, args);
|
||||
};
|
||||
}
|
||||
};
|
||||
watch(
|
||||
engine.camera,
|
||||
['setView', 'flyTo', 'lookAt', 'flyToBoundingSphere', 'release'],
|
||||
'camera',
|
||||
);
|
||||
watch(engine.render, ['request', 'setContinuous'], 'render');
|
||||
};
|
||||
window.__qaWatchParked(window.__godsEyeView.engine);
|
||||
});
|
||||
for (const kind of sequence) {
|
||||
const started = Date.now();
|
||||
// The switch itself parks and resumes engines; only the time an engine
|
||||
// sits parked is watched.
|
||||
await page.evaluate(() => {
|
||||
window.__qaSwitching = true;
|
||||
});
|
||||
await switchEngine(page, kind);
|
||||
await page.evaluate(() => {
|
||||
window.__qaSwitching = false;
|
||||
window.__qaWatchParked(window.__godsEyeView.engine);
|
||||
});
|
||||
const state = await readSession(page);
|
||||
const ui = await readUi(page);
|
||||
if (kind === 'navara')
|
||||
await page.evaluate(() => {
|
||||
window.__qaParkedNavara = window.__godsEyeView.engine;
|
||||
});
|
||||
if (report.switches.length === 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
report.firstRoundTrip = await listenerCounts(page, { bySource: true });
|
||||
}
|
||||
// Both engines draw the session's marks and files (Navara included).
|
||||
await page.waitForFunction(
|
||||
() => window.__gevGeoImport.diagnostics().drawn === 1,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
const tools = await readTools(page);
|
||||
check(
|
||||
tools.marks === toolsBefore.marks &&
|
||||
tools.files === toolsBefore.files &&
|
||||
tools.drawShape === toolsBefore.drawShape &&
|
||||
tools.marksDrawn === true &&
|
||||
tools.filesDrawn === 1,
|
||||
`the session tools changed on the switch to ${kind}`,
|
||||
{ toolsBefore, tools },
|
||||
);
|
||||
report.switches.push({
|
||||
kind,
|
||||
ms: Date.now() - started,
|
||||
ids: idDifference(boot.ids, state.ids),
|
||||
counters: state.counters,
|
||||
ui,
|
||||
tools,
|
||||
});
|
||||
check(
|
||||
Object.keys(idDifference(boot.ids, state.ids)).length === 0,
|
||||
`session ids changed on the switch to ${kind}`,
|
||||
idDifference(boot.ids, state.ids),
|
||||
);
|
||||
check(
|
||||
ui.value === uiBefore.value,
|
||||
`typed text lost on the switch to ${kind}`,
|
||||
ui,
|
||||
);
|
||||
check(ui.panelOpen, `open panel closed on the switch to ${kind}`, ui);
|
||||
check(
|
||||
ui.focused === uiBefore.focused,
|
||||
`focus moved on the switch to ${kind}`,
|
||||
ui,
|
||||
);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
const after = await readSession(page);
|
||||
const afterListeners = await listenerCounts(page, { bySource: true });
|
||||
report.after = after;
|
||||
// After three round trips the page holds exactly the listeners it held
|
||||
// after the first boot, apart from the parked Navara renderer's own (it
|
||||
// stays alive for the switch back), which the first round trip added and
|
||||
// the later ones did not grow.
|
||||
report.listeners = listenerDifference(bootListeners, afterListeners);
|
||||
report.listenersSinceFirstRoundTrip = listenerDifference(
|
||||
report.firstRoundTrip,
|
||||
afterListeners,
|
||||
);
|
||||
const sessionListeners = Object.keys(report.listeners).filter(
|
||||
(key) => !PARKED_RENDERER.test(key),
|
||||
);
|
||||
check(
|
||||
sessionListeners.length === 0,
|
||||
'session listeners on window/document changed after three round trips',
|
||||
report.listeners,
|
||||
);
|
||||
check(
|
||||
Object.keys(report.listenersSinceFirstRoundTrip).length === 0,
|
||||
'listeners on window/document grew after the first round trip',
|
||||
report.listenersSinceFirstRoundTrip,
|
||||
);
|
||||
check(
|
||||
after.counters?.attachments === 1 + sequence.length &&
|
||||
after.counters?.detaches === sequence.length,
|
||||
'each switch detaches once and attaches once',
|
||||
after.counters,
|
||||
);
|
||||
report.parkedEngineCalls = await page.evaluate(() => window.__qaParkedCalls);
|
||||
check(
|
||||
report.parkedEngineCalls.length === 0,
|
||||
'a parked engine had its camera moved or a frame requested',
|
||||
report.parkedEngineCalls,
|
||||
);
|
||||
check(
|
||||
after.counters?.applications === 1,
|
||||
'the application must be created once',
|
||||
after.counters,
|
||||
);
|
||||
|
||||
const escapeAfter = await escapeEffect(page);
|
||||
report.escapeAfter = escapeAfter;
|
||||
check(
|
||||
JSON.stringify(escapeAfter) === JSON.stringify(escapeBefore),
|
||||
'Escape behaves differently after the switches',
|
||||
{ escapeBefore, escapeAfter },
|
||||
);
|
||||
|
||||
// A failed candidate: the parked Navara renderer refuses to resume (as in
|
||||
// qa-layer-handoff.mjs). The working Cesium attaches again on the same
|
||||
// session objects.
|
||||
await prepareUi(page);
|
||||
const beforeRollback = await readSession(page);
|
||||
const historyLength = await page.evaluate(() => history.length);
|
||||
await page.evaluate(() => {
|
||||
window.__qaRollback = false;
|
||||
const parked = window.__qaParkedNavara;
|
||||
const request = parked.render.request;
|
||||
parked.render.request = function (...args) {
|
||||
parked.render.request = request;
|
||||
window.__qaRollback = true;
|
||||
throw new Error('QA parked renderer failure');
|
||||
};
|
||||
});
|
||||
const sabotaged = true;
|
||||
if (sabotaged) {
|
||||
await requestEngine(page, 'navara');
|
||||
await page.waitForFunction(() => window.__qaRollback === true, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await waitReady(page, 'cesium');
|
||||
const rolledBack = await readSession(page);
|
||||
const ui = await readUi(page);
|
||||
report.rollback = {
|
||||
ids: idDifference(beforeRollback.ids, rolledBack.ids),
|
||||
counters: rolledBack.counters,
|
||||
ui,
|
||||
history: (await page.evaluate(() => history.length)) - historyLength,
|
||||
};
|
||||
check(
|
||||
Object.keys(report.rollback.ids).length === 0,
|
||||
'a rollback changed session ids',
|
||||
report.rollback,
|
||||
);
|
||||
check(
|
||||
report.rollback.history === 0,
|
||||
'a rollback added a history entry',
|
||||
report.rollback,
|
||||
);
|
||||
check(
|
||||
ui.value === uiBefore.value && ui.panelOpen,
|
||||
'a rollback lost the UI state',
|
||||
ui,
|
||||
);
|
||||
// The page still switches after the rollback.
|
||||
await switchEngine(page, 'navara');
|
||||
await switchEngine(page, 'cesium');
|
||||
const final = await readSession(page);
|
||||
check(
|
||||
Object.keys(idDifference(boot.ids, final.ids)).length === 0,
|
||||
'ids changed after the rollback',
|
||||
idDifference(boot.ids, final.ids),
|
||||
);
|
||||
|
||||
// A candidate failing after the shell attached to it: a layer's
|
||||
// drawing refuses to attach on Navara. The shell lets go of the failed
|
||||
// globe and Cesium attaches again; the next switch works.
|
||||
await page.evaluate(() => {
|
||||
window.__qaDataRefused = false;
|
||||
const module =
|
||||
window.__godsEyeView.dataManager.layers.get('earthquakes').module;
|
||||
const attach = module.attachRenderer;
|
||||
module.attachRenderer = function () {
|
||||
module.attachRenderer = attach;
|
||||
window.__qaDataRefused = true;
|
||||
return Promise.reject(new Error('QA data attach failure'));
|
||||
};
|
||||
});
|
||||
const beforeDataFailure = await page.evaluate(() => history.length);
|
||||
await requestEngine(page, 'navara');
|
||||
await page.waitForFunction(() => window.__qaDataRefused === true, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await waitReady(page, 'cesium');
|
||||
const afterDataFailure = await readSession(page);
|
||||
report.dataFailure = {
|
||||
ids: idDifference(boot.ids, afterDataFailure.ids),
|
||||
counters: afterDataFailure.counters,
|
||||
history: (await page.evaluate(() => history.length)) - beforeDataFailure,
|
||||
};
|
||||
check(
|
||||
Object.keys(report.dataFailure.ids).length === 0 &&
|
||||
report.dataFailure.history === 0,
|
||||
'a candidate failing in the data part changed the session',
|
||||
report.dataFailure,
|
||||
);
|
||||
await switchEngine(page, 'navara');
|
||||
await switchEngine(page, 'cesium');
|
||||
check(
|
||||
Object.keys(idDifference(boot.ids, (await readSession(page)).ids))
|
||||
.length === 0,
|
||||
'ids changed after the data-part failure',
|
||||
);
|
||||
}
|
||||
report.errors = errors;
|
||||
check(errors.length === 0, 'page errors', errors);
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!reportOnly) console.log('PASS application created once; session kept');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -23,6 +23,70 @@ export function redact(text) {
|
||||
.replace(/AIza[\w-]{30,}/g, 'AIza…REDACTED');
|
||||
}
|
||||
|
||||
/** The standalone Vite preview has no setup API; opt in explicitly. */
|
||||
export function isExpectedPreviewSetupError(
|
||||
message,
|
||||
{ preview = false, baseUrl } = {},
|
||||
) {
|
||||
if (
|
||||
!preview ||
|
||||
message.type() !== 'error' ||
|
||||
!/^Failed to load resource: .*\b404\b/.test(message.text())
|
||||
)
|
||||
return false;
|
||||
try {
|
||||
const url = new URL(message.location()?.url);
|
||||
return (
|
||||
url.origin === new URL(baseUrl).origin &&
|
||||
url.pathname === '/api/setup/status'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// The runtime evidence gate shares one strict runtime verdict across its main page and
|
||||
// every share-link page. Response diagnostics never retain query credentials.
|
||||
export function watchW1Page(page, report, { name, preview = false, baseUrl }) {
|
||||
const errors = (report.browserErrors ??= []);
|
||||
report.expectedPreviewErrors ??= [];
|
||||
report.failedResponses ??= [];
|
||||
(report.runtimePages ??= []).push(name);
|
||||
const resourcePath = (value) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return redact(url.origin + url.pathname);
|
||||
} catch {
|
||||
return '(location unavailable)';
|
||||
}
|
||||
};
|
||||
page.on('console', (message) => {
|
||||
if (isExpectedPreviewSetupError(message, { preview, baseUrl })) {
|
||||
report.expectedPreviewErrors.push('/api/setup/status: 404');
|
||||
return;
|
||||
}
|
||||
if (message.type() === 'error')
|
||||
errors.push(
|
||||
`[${name}] ${redact(message.text())} — ${resourcePath(message.location()?.url)}`,
|
||||
);
|
||||
});
|
||||
for (const event of ['pageerror', 'error'])
|
||||
page.on(event, (error) =>
|
||||
errors.push(`[${name}] ${redact(error.message)}`),
|
||||
);
|
||||
page.on('response', (response) => {
|
||||
if (
|
||||
response.status() >= 400 &&
|
||||
report.failedResponses.length < EVIDENCE_LIMIT
|
||||
)
|
||||
report.failedResponses.push({
|
||||
page: name,
|
||||
status: response.status(),
|
||||
resource: resourcePath(response.url()),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function push(list, entry) {
|
||||
list.push(entry);
|
||||
if (list.length > EVIDENCE_LIMIT)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { promisify } from 'node:util';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { installCctvFixture } from './fixtures/w2/cctv.mjs';
|
||||
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||
const url = localPreview(process.argv[2]);
|
||||
url.search = '?engine=navara&welcome=0';
|
||||
url.hash =
|
||||
'v=2&lat=30.2718&lon=-97.744&alt=900&pitch=-70&heading=0&map=sentinel2&l=';
|
||||
const { stdout: video } = await promisify(execFile)(
|
||||
'ffmpeg',
|
||||
[
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-f',
|
||||
'lavfi',
|
||||
'-i',
|
||||
'testsrc2=size=320x180:rate=12',
|
||||
'-t',
|
||||
'60',
|
||||
'-an',
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-preset',
|
||||
'ultrafast',
|
||||
'-threads',
|
||||
'1',
|
||||
'-movflags',
|
||||
'frag_keyframe+empty_moov',
|
||||
'-f',
|
||||
'mp4',
|
||||
'pipe:1',
|
||||
],
|
||||
{ encoding: 'buffer', maxBuffer: 16000000 },
|
||||
);
|
||||
const strict = createStrict({ origin: url.origin, layers: ['cctv'] });
|
||||
await strict.run({}, async (b) => {
|
||||
const p = await b.newPage();
|
||||
const errors = [];
|
||||
p.on('pageerror', (e) => {
|
||||
errors.push(e.message);
|
||||
console.log('error', e.message);
|
||||
});
|
||||
await installCctvFixture(p, { video });
|
||||
let catalogs = 0;
|
||||
p.on('request', (req) => {
|
||||
if (new URL(req.url()).pathname === '/api/cctv/sources') catalogs++;
|
||||
});
|
||||
await p.goto(url.href);
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.engine?.kind === 'navara' &&
|
||||
document.querySelector('#loading-screen.hidden'),
|
||||
);
|
||||
await p.evaluate(async () => {
|
||||
const { dataManager, styleManager, tileset } = window.__godsEyeView;
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Expected keyless preview');
|
||||
await styleManager.initialRestorePromise;
|
||||
await dataManager.setEnabled('cctv', true, { origin: 'user' });
|
||||
window.qaCctv = dataManager.layers.get('cctv').module;
|
||||
window.qaCctv.setParams({
|
||||
selectedCameraId: 'w2-cctv-video',
|
||||
showProjection: true,
|
||||
coverageMode: 'viewshed',
|
||||
calibration: { patch: { heightM: 31, pitchDeg: 20 } },
|
||||
});
|
||||
});
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.qaCctv.getActiveVideoElement()?.videoWidth === 320 &&
|
||||
window.qaCctv.getActiveVideoElement().currentTime > 0.1,
|
||||
{ timeout: 40000 },
|
||||
);
|
||||
const first = await p.evaluate(() => {
|
||||
window.qaVideo = window.qaCctv.getActiveVideoElement();
|
||||
return window.qaVideo.getVideoPlaybackQuality().totalVideoFrames;
|
||||
});
|
||||
let previous = first;
|
||||
const count = catalogs;
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
await strict.switchTo(p, kind);
|
||||
await p.waitForFunction(
|
||||
(n) =>
|
||||
window.qaCctv.getActiveVideoElement()?.getVideoPlaybackQuality()
|
||||
.totalVideoFrames > n,
|
||||
{},
|
||||
previous,
|
||||
);
|
||||
const v = await p.evaluate(() => ({
|
||||
same:
|
||||
window.qaCctv ===
|
||||
window.__godsEyeView.dataManager.layers.get('cctv').module,
|
||||
media: window.qaCctv.getActiveVideoElement() === window.qaVideo,
|
||||
frames: window.qaVideo.getVideoPlaybackQuality().totalVideoFrames,
|
||||
paused: window.qaVideo.paused,
|
||||
params: window.qaCctv.getParams(),
|
||||
plane: !!window.qaCctv
|
||||
._cctvRecordsForTest()
|
||||
.find((r) => r.camera.id === 'w2-cctv-video').projection?.planeEntity,
|
||||
projection: (() => {
|
||||
const r = window.qaCctv
|
||||
._cctvRecordsForTest()
|
||||
.find((r) => r.camera.id === 'w2-cctv-video');
|
||||
return {
|
||||
show: r.projection?.planeEntity?.show === true,
|
||||
visible: r.projection?.planeVisible === true,
|
||||
calibration: r.camera.calibration,
|
||||
};
|
||||
})(),
|
||||
}));
|
||||
console.log(
|
||||
kind,
|
||||
v.same,
|
||||
v.media,
|
||||
v.frames,
|
||||
v.plane,
|
||||
v.params.selectedCameraId,
|
||||
v.params.coverageMode,
|
||||
);
|
||||
assert.ok(v.same && v.media && v.plane && !v.paused);
|
||||
assert.equal(v.params.selectedCameraId, 'w2-cctv-video');
|
||||
assert.equal(v.params.coverageMode, 'viewshed');
|
||||
assert.equal(catalogs, count);
|
||||
await strict.passed(
|
||||
p,
|
||||
'cctv',
|
||||
'same model and video element decoding new frames; textured projection plane; no catalog read',
|
||||
);
|
||||
// The calibration patch (31 m mount, 20° pitch) and the plane's
|
||||
// visibility are kept on the new engine.
|
||||
const { calibration } = v.projection;
|
||||
await strict.check(
|
||||
p,
|
||||
'cctv',
|
||||
'calibration patch kept (31 m, 20° pitch); monitor plane visible',
|
||||
v.projection.show &&
|
||||
v.projection.visible &&
|
||||
calibration?.heightM === 31 &&
|
||||
calibration?.pitchDeg === 20 &&
|
||||
v.params.calibration?.values?.heightM === 31 &&
|
||||
v.params.calibration?.values?.pitchDeg === 20,
|
||||
v.projection,
|
||||
);
|
||||
previous = v.frames;
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS retained CCTV catalog, decoder and textured projection');
|
||||
});
|
||||
+222
-64
@@ -40,12 +40,10 @@
|
||||
* issuing zero transient samples, without touching the save-gated store;
|
||||
* camera-controller inputs restore and parts hide on mode off.
|
||||
*
|
||||
* SwiftShader caveat (proven in Task 3): under headless software GL, plane
|
||||
* image materials do not visually FILL — screenshotting for a pixel-based
|
||||
* pass/fail is not viable here. All four assertion groups therefore check
|
||||
* entity/counter/localStorage/network STATE (GL-independent), never rendered
|
||||
* pixels. Screenshots are still captured for human visual review but do not
|
||||
* gate the exit code.
|
||||
* This legacy harness is the geometry, lifecycle and real-input oracle.
|
||||
* Migration runs use headless Metal and strict runtime error observation.
|
||||
* Screenshots remain visual-review evidence here; a separate pixel gate must
|
||||
* independently prove actual marker, image-plane and video pixels.
|
||||
*
|
||||
* No `cesium` package import for in-page math: the app does not expose a
|
||||
* `window.Cesium` global, so every in-page evaluate() either (a) reads plain
|
||||
@@ -71,12 +69,17 @@
|
||||
* so the coincide check is deterministic whichever camera activates, but it
|
||||
* is no longer needed to avoid a plane/wireframe disagreement.
|
||||
*
|
||||
* Run: node scripts/qa-cctv-v2.mjs --url http://localhost:4173
|
||||
* Run: node scripts/qa-cctv-v2.mjs --url <explicit-server-url> --out <evidence-directory> [--fixture] [--preview]
|
||||
* Routine keyless builds also pass --terrain-globe: assert zero scene floor
|
||||
* samples instead of claiming mesh-floor coverage on a globe-only map.
|
||||
*
|
||||
* Exit 0 = all asserts passed. Non-zero = a hard failure.
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import { watchW1Page, redact } from './qa-browserEvidence.mjs';
|
||||
import { blockPaidQaMaps } from './qa-map-policy.mjs';
|
||||
import { installCctvFixture } from './fixtures/w2/cctv.mjs';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -95,12 +98,33 @@ const getOpt = (name, dflt) => {
|
||||
};
|
||||
const getFlag = (name) => argv.includes(name);
|
||||
|
||||
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const CCTV_URL = new URL(getOpt('--url', BASE_URL));
|
||||
const explicitUrl = getOpt('--url', process.env.QA_BASE_URL);
|
||||
const output = getOpt('--out');
|
||||
if (!explicitUrl || !output)
|
||||
throw new Error('Explicit --url and --out are required');
|
||||
const mapPolicy = { allowPaidMaps: getFlag('--allow-paid-maps') };
|
||||
const terrainGlobe = getFlag('--terrain-globe');
|
||||
if (terrainGlobe && mapPolicy.allowPaidMaps)
|
||||
throw new Error('Terrain-globe QA must keep paid maps blocked');
|
||||
const CCTV_URL = new URL(explicitUrl);
|
||||
if (
|
||||
['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
|
||||
CCTV_URL.port,
|
||||
)
|
||||
)
|
||||
throw new Error('CCTV QA may not use a reserved port');
|
||||
if (getFlag('--headful')) throw new Error('CCTV migration QA is headless only');
|
||||
CCTV_URL.searchParams.set('welcome', '0');
|
||||
const APP_URL = CCTV_URL.href;
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots', 'cctv-v2');
|
||||
const SHOTS_DIR = path.resolve(output);
|
||||
const evidence = { checks: [], browserErrors: [], pass: false };
|
||||
const saveEvidence = () => {
|
||||
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(SHOTS_DIR, 'result.json'),
|
||||
JSON.stringify(evidence, null, 2) + '\n',
|
||||
);
|
||||
};
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
@@ -127,8 +151,9 @@ function findChromeExecutable() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const results = evidence.checks;
|
||||
function record(name, ok, detail) {
|
||||
detail = redact(detail);
|
||||
results.push({ name, ok, detail });
|
||||
const tag =
|
||||
ok === null
|
||||
@@ -425,24 +450,24 @@ function findSafeCalibrationPatch(basePose, groundAltM) {
|
||||
|
||||
async function main() {
|
||||
console.log(`\nCCTV v2 Subsystem Proof`);
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Mode : ${HEADFUL ? 'headful' : 'headless'}\n`);
|
||||
console.log(` App URL : ${redact(APP_URL)}`);
|
||||
console.log(' Mode : headless Metal\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`,
|
||||
redact(`Dev server not reachable: ${e.message}`),
|
||||
);
|
||||
process.exit(2);
|
||||
throw new Error('CCTV prerequisite unavailable');
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||
|
||||
const chromeExecutable = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
headless: true,
|
||||
protocolTimeout: PROTOCOL_TIMEOUT_MS,
|
||||
// Talk CDP over a pipe, so no debugging port stays open on localhost for
|
||||
// the length of a headful run.
|
||||
@@ -451,32 +476,46 @@ async function main() {
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
'--use-angle=metal',
|
||||
'--enable-gpu',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
],
|
||||
});
|
||||
|
||||
const consoleErrors = [];
|
||||
const consoleErrors = evidence.browserErrors;
|
||||
let exitCode = 0;
|
||||
let fixture = null;
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await blockPaidQaMaps(page, mapPolicy);
|
||||
const terrainRequests = new Set();
|
||||
let terrainActivityAt = 0;
|
||||
page.on('request', (request) => {
|
||||
if (new URL(request.url()).pathname !== '/api/terrain/heights') return;
|
||||
terrainRequests.add(request);
|
||||
terrainActivityAt = Date.now();
|
||||
});
|
||||
for (const event of ['requestfinished', 'requestfailed'])
|
||||
page.on(event, (request) => {
|
||||
if (terrainRequests.delete(request)) terrainActivityAt = Date.now();
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
watchW1Page(page, evidence, {
|
||||
name: 'cctv-legacy',
|
||||
baseUrl: APP_URL,
|
||||
preview: getFlag('--preview'),
|
||||
});
|
||||
fixture = getFlag('--fixture')
|
||||
? await installCctvFixture(page)
|
||||
: null;
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed');
|
||||
});
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
@@ -490,8 +529,35 @@ async function main() {
|
||||
),
|
||||
{ timeoutMs: 60000 },
|
||||
);
|
||||
if (!appReady) throw new Error('app did not initialise within 60s');
|
||||
await installRenderFramesHelper(page);
|
||||
await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
await app.styleManager.initialRestorePromise;
|
||||
app.styleManager.orbitController.stop();
|
||||
for (const [id, layer] of app.dataManager.layers)
|
||||
if (layer.enabled) await app.dataManager.setEnabled(id, false);
|
||||
app.styleManager._setDetectionMode('OFF');
|
||||
});
|
||||
evidence.surface = await page.evaluate(() => {
|
||||
const app = window.__godsEyeView;
|
||||
return {
|
||||
map: app.mapStackController.getActiveId(),
|
||||
globeVisible: app.viewer.scene.globe.show,
|
||||
tileset: Boolean(app.tileset),
|
||||
googleKeyPresent: Boolean(window.__GOOGLE_MAPS_API_KEY__),
|
||||
photorealAvailable:
|
||||
app.mapStackController.isStackAvailable('photoreal'),
|
||||
};
|
||||
});
|
||||
evidence.surfaceMode = terrainGlobe ? 'terrain-globe' : 'mesh-floor';
|
||||
if (terrainGlobe) {
|
||||
const keyless =
|
||||
evidence.surface.globeVisible === true &&
|
||||
!evidence.surface.tileset &&
|
||||
!evidence.surface.googleKeyPresent &&
|
||||
!evidence.surface.photorealAvailable;
|
||||
record('keyless terrain-globe startup', keyless);
|
||||
if (!keyless) throw new Error('Expected a keyless globe before CCTV QA');
|
||||
}
|
||||
// Let the initial fly-to Austin and first tiles settle.
|
||||
await sleep(4000);
|
||||
|
||||
@@ -529,7 +595,7 @@ async function main() {
|
||||
console.error(
|
||||
'\x1b[31mCould not wrap scene.pickFromRay/sampleHeight before layer enable — aborting.\x1b[0m',
|
||||
);
|
||||
process.exit(2);
|
||||
throw new Error('CCTV prerequisite unavailable');
|
||||
}
|
||||
|
||||
const readCounters = () =>
|
||||
@@ -557,9 +623,20 @@ async function main() {
|
||||
// ~800ms per real ground sample measured under SwiftShader (each
|
||||
// scene.sampleHeight forces tile loads at the probe point) — real GPU is
|
||||
// far faster, so this is a headless-CI ceiling, not an app expectation.
|
||||
const drainBudgetMs = HEADFUL
|
||||
? Math.max(30000, camCount * 800 + 30000)
|
||||
: Math.min(120000, Math.max(30000, camCount * 800 + 30000));
|
||||
evidence.cameraIds = await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('cctv')
|
||||
.module.getUIState()
|
||||
.cameras.map(({ id }) => id)
|
||||
.sort(),
|
||||
);
|
||||
if (fixture)
|
||||
record(
|
||||
'real catalog parser retains every fixture camera ID',
|
||||
JSON.stringify(evidence.cameraIds) ===
|
||||
JSON.stringify(fixture.cameras.map(({ id }) => id).sort()),
|
||||
);
|
||||
const drainBudgetMs = Math.max(30000, camCount * 800 + 30000);
|
||||
console.log(
|
||||
`Waiting for geometry-load queue to drain (N=${camCount}, budget ${Math.round(drainBudgetMs / 1000)}s)...`,
|
||||
);
|
||||
@@ -574,12 +651,10 @@ async function main() {
|
||||
);
|
||||
record(
|
||||
`geometry-load queue drains within ${Math.round(drainBudgetMs / 1000)}s (N=${camCount})`,
|
||||
drained ? true : HEADFUL ? false : null,
|
||||
drained,
|
||||
drained
|
||||
? 'loading.active === false'
|
||||
: HEADFUL
|
||||
? 'timed out waiting for drain on the real-GPU sign-off path'
|
||||
: 'headless GL geometry sampling did not settle; current headful evidence is required',
|
||||
: 'timed out waiting for the real-GPU geometry drain',
|
||||
);
|
||||
|
||||
const cAfterDrain = await readCounters();
|
||||
@@ -617,6 +692,7 @@ async function main() {
|
||||
.activeCameraId,
|
||||
);
|
||||
|
||||
evidence.activeCameraId = activeId;
|
||||
const pickDeltaActivation =
|
||||
cAfterActivate.pickFromRay - cBeforeActivate.pickFromRay;
|
||||
record(
|
||||
@@ -660,9 +736,32 @@ async function main() {
|
||||
// (existence, not just ceiling). The wait below tolerates the update-tick
|
||||
// latency of the completion pass (update interval is 10s).
|
||||
// -----------------------------------------------------------------------
|
||||
const globeMode = await page.evaluate(
|
||||
() => window.__godsEyeView.viewer.scene.globe?.show !== false,
|
||||
console.log('Waiting for tiles + the one-shot completion pass...');
|
||||
const tilesSeenLoaded = await waitForTilesLoaded(page, 45000);
|
||||
let sampleFloorOk = false;
|
||||
if (tilesSeenLoaded && !terrainGlobe) {
|
||||
sampleFloorOk = await page
|
||||
.waitForFunction(
|
||||
(base) => window.__qaCounters.sampleHeight - base >= 1,
|
||||
{ timeout: 30000 },
|
||||
c0.sampleHeight,
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
const cFloor = await readCounters();
|
||||
record(
|
||||
terrainGlobe
|
||||
? 'terrain-globe resolves from priors with zero scene ground samples'
|
||||
: '>=1 real ground sample once tilesLoaded observed (one-shot snap completed)',
|
||||
terrainGlobe
|
||||
? cFloor.sampleHeight - c0.sampleHeight === 0
|
||||
: tilesSeenLoaded && sampleFloorOk,
|
||||
tilesSeenLoaded
|
||||
? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}`
|
||||
: 'tilesLoaded never observed within 45s — floor not assessable this run',
|
||||
);
|
||||
const globeMode = await page.evaluate(() => window.__godsEyeView.viewer.scene.globe?.show !== false);
|
||||
if (globeMode) {
|
||||
// Globe stacks intentionally perform no scene.sampleHeight query.
|
||||
// Require independently fetched DEM heights and the actual rendered
|
||||
@@ -1034,8 +1133,27 @@ async function main() {
|
||||
return serializeGeom(camId);
|
||||
})(${JSON.stringify(camId)})`);
|
||||
const geomBefore30s = await serializeGeom(activeId);
|
||||
await sleep(30000);
|
||||
await sleep(28000);
|
||||
evidence.staticIdle = await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
const { getRenderGovernorDiagnostics } = await import('/src/renderGovernor.js');
|
||||
let frames = 0;
|
||||
const remove = app.engine.frames.postRender.addEventListener(() => frames++);
|
||||
const started = performance.now();
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
remove();
|
||||
return { frames, durationMs: performance.now() - started,
|
||||
governor: getRenderGovernorDiagnostics() };
|
||||
});
|
||||
const geomAfter30s = await serializeGeom(activeId);
|
||||
evidence.geometry = {
|
||||
initial: geomInfo,
|
||||
beforeIdle: geomBefore30s,
|
||||
afterIdle: geomAfter30s,
|
||||
};
|
||||
evidence.countersAfterIdle = await readCounters();
|
||||
record('static image projection settles to zero frames over 2s',
|
||||
evidence.staticIdle.frames === 0, JSON.stringify(evidence.staticIdle));
|
||||
const geomStable =
|
||||
JSON.stringify(geomBefore30s) === JSON.stringify(geomAfter30s);
|
||||
record(
|
||||
@@ -1207,7 +1325,7 @@ async function main() {
|
||||
);
|
||||
|
||||
// =========================================================================
|
||||
// Group 4: frame-loop health (STATE, not pixels — SwiftShader caveat)
|
||||
// Group 4: frame-loop health (state oracle; media pixels have a separate gate)
|
||||
// =========================================================================
|
||||
console.log(
|
||||
'Checking frame-loop health (placeholder -> frame swap, URL family)...',
|
||||
@@ -1464,25 +1582,18 @@ async function main() {
|
||||
const gev = window.__godsEyeView;
|
||||
const viewer = gev.viewer;
|
||||
const scene = viewer.scene;
|
||||
const Cesium = await import('/node_modules/cesium/Build/Cesium/index.js');
|
||||
const canvasPoint = new Cesium.Cartesian2(
|
||||
emptyPoint.canvasX,
|
||||
emptyPoint.canvasY,
|
||||
);
|
||||
const canvasPoint = { x: emptyPoint.canvasX, y: emptyPoint.canvasY };
|
||||
const ray = viewer.camera.getPickRay(canvasPoint);
|
||||
if (!ray) return null;
|
||||
let position = null;
|
||||
let projected = null;
|
||||
for (const distance of [1_000, 10_000, 100_000, 1_000_000]) {
|
||||
const candidate = Cesium.Ray.getPoint(
|
||||
const candidate = ray.constructor.getPoint(
|
||||
ray,
|
||||
distance,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
candidate,
|
||||
new ray.origin.constructor(),
|
||||
);
|
||||
const screen = scene.cartesianToCanvasCoordinates(candidate);
|
||||
if (
|
||||
screen &&
|
||||
Math.hypot(screen.x - canvasPoint.x, screen.y - canvasPoint.y) < 2
|
||||
@@ -1498,7 +1609,7 @@ async function main() {
|
||||
position,
|
||||
point: {
|
||||
pixelSize: 64,
|
||||
color: Cesium.Color.MAGENTA,
|
||||
color: new scene.backgroundColor.constructor(1, 0, 1, 1),
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
});
|
||||
@@ -1537,7 +1648,7 @@ async function main() {
|
||||
!siblingTarget
|
||||
? null
|
||||
: siblingOwnsPick ||
|
||||
(siblingRendererStalled && !HEADFUL ? null : false),
|
||||
(siblingRendererStalled ? null : false),
|
||||
siblingTarget
|
||||
? `${siblingRendererStalled ? 'renderer stalled: ' : ''}frames=${siblingTarget.framesRendered}/3 picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta}`
|
||||
: 'no previously verified empty canvas ray was available',
|
||||
@@ -1675,6 +1786,24 @@ async function main() {
|
||||
});
|
||||
|
||||
const cBeforeViewshed = await readCounters();
|
||||
await page.evaluate(() => {
|
||||
const primitives = window.__godsEyeView.viewer.scene.primitives;
|
||||
const events = [];
|
||||
window.__qaViewshedTrace = events;
|
||||
for (const method of ['add', 'remove']) {
|
||||
const original = primitives[method].bind(primitives);
|
||||
primitives[method] = (primitive, ...args) => {
|
||||
if (primitive?._gevViewshed)
|
||||
events.push({
|
||||
method,
|
||||
camera: primitive._gevViewshed,
|
||||
at: performance.now(),
|
||||
stack: new Error().stack,
|
||||
});
|
||||
return original(primitive, ...args);
|
||||
};
|
||||
}
|
||||
});
|
||||
const modeAfterSet = await page.evaluate(() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
mod.setParams({ coverageMode: 'viewshed' });
|
||||
@@ -1725,6 +1854,18 @@ async function main() {
|
||||
mod.setParams({ coverageMode: 'viewshed' });
|
||||
});
|
||||
await sleep(400);
|
||||
// NEXT starts an asynchronous footprint-height lookup. Its completion is
|
||||
// allowed to update geometry; the unchanged 8s idle window starts after it.
|
||||
const terrainDeadline = Date.now() + 35000;
|
||||
while (
|
||||
(terrainRequests.size || Date.now() - terrainActivityAt < 250) &&
|
||||
Date.now() < terrainDeadline
|
||||
)
|
||||
await sleep(50);
|
||||
const terrainSettled =
|
||||
terrainRequests.size === 0 && Date.now() - terrainActivityAt >= 250;
|
||||
record('footprint requests settle before viewshed idle', terrainSettled);
|
||||
if (!terrainSettled) throw new Error('Footprint requests did not settle');
|
||||
await page.evaluate(() => {
|
||||
const prims = window.__godsEyeView.viewer.scene.primitives;
|
||||
window.__qaViewshedRefs = [];
|
||||
@@ -1746,6 +1887,9 @@ async function main() {
|
||||
return { sameCount, sameIdentity, count: now.length };
|
||||
});
|
||||
const cAfterViewshed = await readCounters();
|
||||
evidence.viewshedTrace = await page.evaluate(
|
||||
() => window.__qaViewshedTrace,
|
||||
);
|
||||
const lateFloorSamples =
|
||||
cAfterViewshed.sampleHeight - cBeforeViewshed.sampleHeight;
|
||||
record(
|
||||
@@ -1772,6 +1916,11 @@ async function main() {
|
||||
// Group 7: calibration gizmo (v3 design §3c — ADJUST mode + drag)
|
||||
// =========================================================================
|
||||
console.log('Checking calibration gizmo (ADJUST mode + synthetic drag)...');
|
||||
// NEXT above selects a different catalog camera without moving the view.
|
||||
// Frame that actual camera before requiring an on-screen physical drag.
|
||||
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv')
|
||||
.module.setParams({ focusSelected: true, focusDurationSec: 0.2 }));
|
||||
await sleep(800);
|
||||
const GIZMO_PARTS = [
|
||||
'ring-heading',
|
||||
'ring-pitch',
|
||||
@@ -2020,7 +2169,7 @@ async function main() {
|
||||
record(
|
||||
'east-arrow drag changes the east offset (live, unsaved)',
|
||||
null,
|
||||
`${missDetail} — verify on a current headful GPU surface`,
|
||||
`${missDetail} — required physical drag evidence is missing from this headless Metal run`,
|
||||
);
|
||||
record(
|
||||
'east-arrow drag keeps mount elevation frozen until mouse-up',
|
||||
@@ -2044,7 +2193,7 @@ async function main() {
|
||||
);
|
||||
} else {
|
||||
const unavailable =
|
||||
'no on-screen east-arrow shaft found under this GL stack — verify on real GPU';
|
||||
'no on-screen east-arrow shaft found in the headless Metal view';
|
||||
record(
|
||||
'east-arrow drag changes the east offset (live, unsaved)',
|
||||
null,
|
||||
@@ -2095,8 +2244,8 @@ async function main() {
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Screenshots for human visual review (NOT a pass/fail signal — SwiftShader
|
||||
// cannot fill plane image materials under headless software GL).
|
||||
// Screenshots supplement this state/geometry oracle. Positive rendered
|
||||
// media pixels are a separate mandatory pixel gate.
|
||||
// -----------------------------------------------------------------------
|
||||
await sleep(1000);
|
||||
const shot = path.join(SHOTS_DIR, 'cctv-v2-active.png');
|
||||
@@ -2110,10 +2259,16 @@ async function main() {
|
||||
);
|
||||
|
||||
for (const r of results) {
|
||||
if (r.ok === false) exitCode = 1;
|
||||
if (r.ok !== true) exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
await fixture?.dispose();
|
||||
await browser.close();
|
||||
evidence.pass =
|
||||
results.length > 0 &&
|
||||
results.every((r) => r.ok === true) &&
|
||||
!consoleErrors.length;
|
||||
saveEvidence();
|
||||
}
|
||||
|
||||
console.log('\n' + '─'.repeat(60));
|
||||
@@ -2128,6 +2283,9 @@ async function main() {
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('\x1b[31mHarness error:\x1b[0m', e);
|
||||
evidence.pass = false;
|
||||
evidence.error = redact(e?.stack || e);
|
||||
saveEvidence();
|
||||
console.error('Harness error:', evidence.error);
|
||||
process.exit(3);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { blockPaidQaMaps } from './qa-map-policy.mjs';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const shotsDir = path.join(repoRoot, 'qa-shots', 'cockpit-utility');
|
||||
@@ -29,6 +30,7 @@ const browser = await puppeteer.launch({
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await blockPaidQaMaps(page);
|
||||
const failures = [];
|
||||
const consoleErrors = [];
|
||||
const localHttpErrors = [];
|
||||
@@ -827,6 +829,7 @@ try {
|
||||
const readinessPage = await browser.newPage();
|
||||
let deferredInstallationReadiness;
|
||||
try {
|
||||
await blockPaidQaMaps(readinessPage);
|
||||
await readinessPage.setViewport({ width: 1440, height: 900 });
|
||||
readinessPage.on('pageerror', (error) => consoleErrors.push(error.message));
|
||||
await readinessPage.setRequestInterception(true);
|
||||
@@ -1150,6 +1153,14 @@ try {
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
const startsClear = await page.evaluate(() => {
|
||||
const hidden = ['left-panel-stack', 'intel-hud', 'corner-actions',
|
||||
'cockpit-context', 'cockpit-signal-stream', 'cockpit-utility-controls'];
|
||||
return hidden.every((id) => document.getElementById(id)?.getClientRects().length === 0)
|
||||
&& document.getElementById('cockpit-speed-value').getClientRects().length > 0;
|
||||
});
|
||||
check('Cockpit starts with flight instruments and hides the map panels', startsClear);
|
||||
await page.click('#cockpit-panels-toggle');
|
||||
const firstCockpitContact = await page.evaluate(() => {
|
||||
const awareness = window.__godsEyeView.dataManager.layers
|
||||
.get('military-awareness')?.module;
|
||||
@@ -1511,6 +1522,9 @@ try {
|
||||
const before = read();
|
||||
document.getElementById('cockpit-entry')?.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
const startsClear = document.getElementById('cockpit-panels-toggle').getAttribute('aria-pressed') === 'false';
|
||||
document.getElementById('cockpit-panels-toggle').click();
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const during = read();
|
||||
manager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false });
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
@@ -1531,6 +1545,7 @@ try {
|
||||
before,
|
||||
during,
|
||||
after: read(),
|
||||
startsClear,
|
||||
exited,
|
||||
clearancePx,
|
||||
contactAutoCollapsed,
|
||||
@@ -1540,7 +1555,7 @@ try {
|
||||
});
|
||||
check(
|
||||
'Cockpit temporarily collapses standard panels and restores the exact map layout on exit',
|
||||
cockpitPanelRoundTrip.exited
|
||||
cockpitPanelRoundTrip.exited && cockpitPanelRoundTrip.startsClear
|
||||
&& Object.values(cockpitPanelRoundTrip.during).every(Boolean)
|
||||
&& JSON.stringify(cockpitPanelRoundTrip.after)
|
||||
=== JSON.stringify(cockpitPanelRoundTrip.before)
|
||||
@@ -1605,6 +1620,7 @@ try {
|
||||
const states = [read()];
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
current.click();
|
||||
await Promise.resolve();
|
||||
states.push(read());
|
||||
}
|
||||
const originalStyle = manager.activeStyle;
|
||||
@@ -1616,8 +1632,10 @@ try {
|
||||
const enteredOnNoir = await reenter();
|
||||
const entryNoir = read();
|
||||
cockpit.setVisionMode('optical');
|
||||
await Promise.resolve();
|
||||
const normalInsideNoir = read();
|
||||
cockpit.setVisionMode('crt');
|
||||
await Promise.resolve();
|
||||
const cockpitOnlyCrt = read();
|
||||
const exitedNoir = cockpit.exit({ restoreTracking: true }) === true;
|
||||
await settle();
|
||||
@@ -1692,6 +1710,9 @@ try {
|
||||
await page.screenshot({ path: path.join(shotsDir, 'vision-noir.png') });
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.cockpitView.setVisionMode('optical'));
|
||||
|
||||
await page.$eval('#cockpit-panels-toggle', button => {
|
||||
if (button.getAttribute('aria-pressed') === 'false') button.click();
|
||||
});
|
||||
const desktopState = async (variant, openKind) => page.evaluate(async ({ variantName, kind }) => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const hud = document.getElementById('cockpit-hud');
|
||||
@@ -1877,7 +1898,7 @@ try {
|
||||
// the wall Cockpit actually solves against — not by injecting an anchor.
|
||||
// The card is bottom-anchored, so an explicit height moves its top edge.
|
||||
const minTop = Math.max(96, window.innerHeight * 0.12);
|
||||
const moveSignalTopTo = (wantTop) => {
|
||||
const moveSignalTopTo = async (wantTop) => {
|
||||
for (let pass = 0; pass < 8; pass += 1) {
|
||||
const rect = signal.getBoundingClientRect();
|
||||
const delta = wantTop - rect.top;
|
||||
@@ -1890,9 +1911,11 @@ try {
|
||||
// the now-shorter strip, so idempotence is asserted where the strip's
|
||||
// composition is unchanged.)
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
await Promise.resolve();
|
||||
const firstPassTop = hud.style.getPropertyValue('--cockpit-utility-top');
|
||||
const firstPassPrimaryOnly = utility.classList.contains('layout-primary-only');
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
await Promise.resolve();
|
||||
return {
|
||||
firstPassTop,
|
||||
firstPassPrimaryOnly,
|
||||
@@ -1903,6 +1926,7 @@ try {
|
||||
signal.style.maxHeight = 'none';
|
||||
signal.style.height = '150px';
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
await Promise.resolve();
|
||||
const expandedHeight = Math.max(displayControl.scrollHeight, displayControl.getBoundingClientRect().height);
|
||||
const collapsedHeight = Math.max(50, radioControl.scrollHeight);
|
||||
const stripHeight = expandedHeight + collapsedHeight + 7;
|
||||
@@ -1927,11 +1951,11 @@ try {
|
||||
// because 150 px of card leaves a corridor sized for the OLD strip. Seed from
|
||||
// the measured requirement instead, which holds for any future panel height,
|
||||
// and assert the precondition rather than assuming it.
|
||||
const roomySeed = moveSignalTopTo(exactSignalTop + 80);
|
||||
const roomySeed = await moveSignalTopTo(exactSignalTop + 80);
|
||||
const roomySeedExpanded = !utility.classList.contains('layout-primary-only')
|
||||
&& roomySeed.firstPassPrimaryOnly === false;
|
||||
const exact = {
|
||||
...moveSignalTopTo(exactSignalTop),
|
||||
...await moveSignalTopTo(exactSignalTop),
|
||||
utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'),
|
||||
primaryOnly: utility.classList.contains('layout-primary-only'),
|
||||
siblingVisible: getComputedStyle(radioControl).display !== 'none',
|
||||
@@ -1941,7 +1965,7 @@ try {
|
||||
const constrained = {
|
||||
// One pixel on the far side of the same fit (`exactSignalTop` already sits
|
||||
// one pixel inside it), so this is still the one-pixel constraint it claims.
|
||||
...moveSignalTopTo(exactSignalTop - 2 * BOUNDARY_EPSILON_PX),
|
||||
...await moveSignalTopTo(exactSignalTop - 2 * BOUNDARY_EPSILON_PX),
|
||||
utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'),
|
||||
primaryOnly: utility.classList.contains('layout-primary-only'),
|
||||
siblingDisplay: getComputedStyle(radioControl).display,
|
||||
@@ -1952,7 +1976,7 @@ try {
|
||||
<= signal.getBoundingClientRect().top - 7,
|
||||
};
|
||||
const restored = {
|
||||
...moveSignalTopTo(exactSignalTop + 80),
|
||||
...await moveSignalTopTo(exactSignalTop + 80),
|
||||
utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'),
|
||||
primaryOnly: utility.classList.contains('layout-primary-only'),
|
||||
siblingVisible: getComputedStyle(radioControl).display !== 'none',
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
openPage,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0&look=cinematic';
|
||||
base.hash =
|
||||
'v=2&lat=43.6500&lon=-79.3800&alt=46000&heading=0&pitch=-90&roll=0&map=sentinel2&l=4';
|
||||
const strict = createStrict({
|
||||
origin: base.origin,
|
||||
layers: ['military-awareness', 'military-installations'],
|
||||
});
|
||||
await strict.run({}, async (b) => {
|
||||
const { page: p, errors } = await openPage(b);
|
||||
await p.evaluateOnNewDocument(() => {
|
||||
const fetch = window.fetch.bind(window);
|
||||
window.fetch = (input, options) => {
|
||||
const { pathname } = new URL(input.url ?? input, document.baseURI);
|
||||
if (pathname === '/api/vessels')
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
status: 'live',
|
||||
rows: ['123456789', '223456789'].map((mmsi, i) => ({
|
||||
mmsi,
|
||||
name: 'Toronto ' + i,
|
||||
lat: 43.65,
|
||||
lon: -79.38 + i * 0.05,
|
||||
last_position_epoch: Date.now() / 1000,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
// Contacts reads air quality near the subject; no station here.
|
||||
if (
|
||||
new URL(input.url ?? input, document.baseURI).hostname ===
|
||||
'api.weather.gc.ca'
|
||||
)
|
||||
return Promise.resolve(
|
||||
Response.json({ type: 'FeatureCollection', features: [] }),
|
||||
);
|
||||
if (pathname === '/api/vessels/track')
|
||||
return Promise.resolve(Response.json({ samples: [] }));
|
||||
if (pathname === '/api/flights')
|
||||
return Promise.resolve(
|
||||
Response.json({ time: Date.now() / 1000, states: [] }),
|
||||
);
|
||||
if (pathname === '/api/military')
|
||||
return Promise.resolve(Response.json({ now: Date.now(), ac: [] }));
|
||||
// One mapped station next to the Contacts subject, with a footprint.
|
||||
if (pathname === '/api/military-installations') {
|
||||
window.qaInstallationReads = (window.qaInstallationReads ?? 0) + 1;
|
||||
const ring = [
|
||||
[43.665, -79.345],
|
||||
[43.665, -79.315],
|
||||
[43.68, -79.315],
|
||||
[43.68, -79.345],
|
||||
[43.665, -79.345],
|
||||
];
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
status: 'ok',
|
||||
elements: [
|
||||
{
|
||||
type: 'way',
|
||||
id: 424242,
|
||||
tags: { military: 'naval_base', name: 'QA Harbour Station' },
|
||||
center: { lat: 43.6725, lon: -79.33 },
|
||||
geometry: ring.map(([lat, lon]) => ({ lat, lon })),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return fetch(input, options);
|
||||
};
|
||||
window.qaSelected = [];
|
||||
for (const type of ['gev:entity-selected', 'gev:entity-selection-cleared'])
|
||||
window.addEventListener(type, ({ detail }) =>
|
||||
window.qaSelected.push({
|
||||
type,
|
||||
id: detail?.id,
|
||||
layerId: detail?.layerId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(async () => {
|
||||
const { dataManager, styleManager, tileset } = window.__godsEyeView;
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Require keyless preview');
|
||||
await dataManager.setEnabled('ais-live-vessels', true, { origin: 'user' });
|
||||
window.qaBefore = [...dataManager.getEnabledLayerIds()].sort();
|
||||
const outcome = await styleManager.setContextMode('contacts');
|
||||
if (!outcome.ok) throw Error(JSON.stringify(outcome));
|
||||
window.qaContext = dataManager.layers.get('military-awareness').module;
|
||||
window.qaSites = dataManager.layers.get('military-installations').module;
|
||||
const vessels = dataManager.layers.get('ais-live-vessels').module;
|
||||
if (!vessels.selectById('123456789')) throw Error('Missing first vessel');
|
||||
if (!window.qaContext.navigateNext({ targetLayer: 'ais-live-vessels' }))
|
||||
throw Error('Cannot navigate to the second vessel');
|
||||
});
|
||||
const sample = () =>
|
||||
p.evaluate(() => {
|
||||
const { dataManager, styleManager, engine } = window.__godsEyeView;
|
||||
return {
|
||||
context:
|
||||
window.qaContext ===
|
||||
dataManager.layers.get('military-awareness').module,
|
||||
sites:
|
||||
window.qaSites ===
|
||||
dataManager.layers.get('military-installations').module,
|
||||
mode: styleManager.getContextModeState().mode,
|
||||
subject: window.qaContext.getContextSnapshot()?.subject?.id,
|
||||
history:
|
||||
window.qaContext._getAwarenessNavigationStateForTest().historyKeys,
|
||||
enabled: [...dataManager.getEnabledLayerIds()].sort(),
|
||||
ring:
|
||||
engine.kind === 'navara'
|
||||
? engine.lines
|
||||
.list()
|
||||
.filter((line) => line.id === 'gev-awareness-rim').length
|
||||
: null,
|
||||
};
|
||||
});
|
||||
// The station zone: the fixture's installation is the one mapped site
|
||||
// within the Contacts radius of the subject, and the engine draws it where
|
||||
// a pointer can reach it (the engine's own picker returns it at an
|
||||
// uncovered canvas point at its marker or inside its footprint).
|
||||
const inside = [fromDegrees(-79.33, 43.6725)];
|
||||
for (let i = 1; i < 6; i++)
|
||||
for (let k = 1; k < 6; k++)
|
||||
inside.push(fromDegrees(-79.345 + 0.005 * k, 43.665 + 0.0025 * i));
|
||||
const zone = async () => {
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
!window.qaSites.getStats().loading &&
|
||||
window.qaSites
|
||||
.getAnalystRecords()
|
||||
.some((r) => r.name === 'QA Harbour Station'),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
const drawn = await p
|
||||
.waitForFunction(
|
||||
(points) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const canvas = engine.host.canvas;
|
||||
const box = canvas.getBoundingClientRect();
|
||||
for (const world of points) {
|
||||
const centre = engine.projector.worldToWindow(world, {});
|
||||
if (!centre) continue;
|
||||
for (const [dx, dy] of [
|
||||
[0, 0],
|
||||
[0, -8],
|
||||
[8, 0],
|
||||
[-8, 0],
|
||||
[0, 8],
|
||||
]) {
|
||||
const at = { x: centre.x + dx, y: centre.y + dy };
|
||||
if (
|
||||
!canvas.contains(
|
||||
document.elementFromPoint(box.left + at.x, box.top + at.y),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
const id = engine.picker.pick(at)?.id;
|
||||
if (String(id?.id ?? id) === 'osm:way:424242')
|
||||
return [Math.round(at.x), Math.round(at.y)];
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
inside,
|
||||
)
|
||||
.then((handle) => handle.jsonValue())
|
||||
.catch(() => null);
|
||||
return p.evaluate(
|
||||
(drawn) => ({
|
||||
drawn,
|
||||
records: window.qaSites.getAnalystRecords().map((r) => r.name),
|
||||
coverage: window.qaSites.getStats().coverage,
|
||||
count: window.qaSites.getStats().count,
|
||||
reads: window.qaInstallationReads,
|
||||
subject: window.qaContext.getContextSnapshot()?.subject?.id,
|
||||
}),
|
||||
drawn,
|
||||
);
|
||||
};
|
||||
const checkZone = async () => {
|
||||
const station = await zone();
|
||||
await strict.check(
|
||||
p,
|
||||
'military-installations',
|
||||
'the station zone holds the fixture station, drawn and pickable',
|
||||
station.count === 1 &&
|
||||
station.records.length === 1 &&
|
||||
station.records[0] === 'QA Harbour Station' &&
|
||||
station.coverage?.kind === 'subject' &&
|
||||
Array.isArray(station.drawn),
|
||||
station,
|
||||
);
|
||||
return station;
|
||||
};
|
||||
const firstZone = await checkZone();
|
||||
const baseline = await sample();
|
||||
console.log('baseline', JSON.stringify(baseline));
|
||||
assert.equal(baseline.mode, 'flights');
|
||||
assert.equal(baseline.subject, '223456789');
|
||||
assert.ok(baseline.history.includes('ais-live-vessels:123456789'));
|
||||
assert.ok(baseline.ring >= 1);
|
||||
await strict.passed(
|
||||
p,
|
||||
'military-awareness',
|
||||
'Contacts subject, navigation history and awareness rim',
|
||||
baseline,
|
||||
);
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
await strict.switchTo(p, kind);
|
||||
const current = await sample();
|
||||
console.log('handoff', kind, JSON.stringify(current));
|
||||
assert.deepEqual(current, {
|
||||
...baseline,
|
||||
ring: kind === 'navara' ? baseline.ring : null,
|
||||
});
|
||||
await strict.passed(
|
||||
p,
|
||||
'military-awareness',
|
||||
'same awareness model, Contacts mode, subject, history and enabled layers',
|
||||
);
|
||||
await strict.passed(
|
||||
p,
|
||||
'military-installations',
|
||||
'same installations model and enabled through the switch',
|
||||
);
|
||||
const station = await checkZone();
|
||||
await strict.check(
|
||||
p,
|
||||
'military-installations',
|
||||
'the switch reads no installation snapshot',
|
||||
station.reads === firstZone.reads,
|
||||
{ before: firstZone.reads, after: station.reads },
|
||||
);
|
||||
}
|
||||
await checkZone();
|
||||
const restored = await p.evaluate(async () => {
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
const result = await styleManager.setContextMode('off');
|
||||
return (
|
||||
result.ok &&
|
||||
JSON.stringify([...dataManager.getEnabledLayerIds()].sort()) ===
|
||||
JSON.stringify(window.qaBefore)
|
||||
);
|
||||
});
|
||||
assert.ok(
|
||||
restored,
|
||||
'Restore layers enabled by the user before entering Contacts',
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS Contacts history, dependency ownership and exit restore across both globes',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
openPage,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0&look=cinematic';
|
||||
base.hash =
|
||||
'v=2&lat=55.3241&lon=10.7995&alt=25000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
|
||||
const strict = createStrict({ origin: base.origin, layers: ['directions'] });
|
||||
await strict.run({}, async (b) => {
|
||||
const { page: p, errors } = await openPage(b);
|
||||
let routeReads = 0,
|
||||
release;
|
||||
await p.setRequestInterception(true);
|
||||
p.on('request', async (req) => {
|
||||
const u = new URL(req.url());
|
||||
const json = (v) =>
|
||||
req.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(v),
|
||||
});
|
||||
if (u.pathname === '/api/terrain/heights') {
|
||||
const cells = (u.searchParams.get('points') ?? '').split(';');
|
||||
return json({ results: cells.map(() => ({ ellipsoid: 34 })) });
|
||||
}
|
||||
if (u.pathname !== '/api/route') return req.continue();
|
||||
routeReads++;
|
||||
const [a, b] = u.searchParams
|
||||
.get('coords')
|
||||
.split(';')
|
||||
.map((p) => p.split(',').map(Number));
|
||||
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||||
if (routeReads === 1)
|
||||
await new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return json({
|
||||
ok: true,
|
||||
geometry: [a, mid, b],
|
||||
distanceM: 9000,
|
||||
durationS: 800,
|
||||
steps: [a, mid, b].map(([lon, lat], i) => ({
|
||||
lon,
|
||||
lat,
|
||||
instruction: 'Route step ' + i,
|
||||
distanceM: 3000,
|
||||
durationS: 120,
|
||||
})),
|
||||
});
|
||||
});
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(async () => {
|
||||
const { tileset, dataManager } = window.__godsEyeView;
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Keyless preview required');
|
||||
window.qaRoute = dataManager.layers.get('directions').module;
|
||||
await dataManager.setEnabled('directions', true, { origin: 'user' });
|
||||
});
|
||||
for (const [letter, x] of [
|
||||
['a', 480],
|
||||
['b', 645],
|
||||
]) {
|
||||
await p.evaluate((l) => window.qaRoute.setParams({ arm: l }), letter);
|
||||
await p.mouse.click(x, 440);
|
||||
await new Promise((resolve) => setTimeout(resolve, 450));
|
||||
}
|
||||
assert.equal(routeReads, 1);
|
||||
await strict.passed(
|
||||
p,
|
||||
'directions',
|
||||
'A and B set by clicks on the globe; one route request',
|
||||
);
|
||||
await strict.switchTo(p, 'cesium');
|
||||
release();
|
||||
await p.waitForFunction(() => window.qaRoute.getStats().count === 3);
|
||||
await p.waitForFunction(() => {
|
||||
window.qaRoute.setParams({ step: 1 });
|
||||
return window.qaRoute.getRowControls().list.items[1]?.active;
|
||||
});
|
||||
await strict.passed(
|
||||
p,
|
||||
'directions',
|
||||
'the route asked for on Navara arrives on Cesium; a step is selectable',
|
||||
);
|
||||
const transfer = async (kind) => {
|
||||
await strict.switchTo(p, kind);
|
||||
assert.equal(
|
||||
await p.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers.get('directions').module ===
|
||||
window.qaRoute,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(routeReads, 1);
|
||||
await strict.passed(
|
||||
p,
|
||||
'directions',
|
||||
'same model; the switch requests no route',
|
||||
);
|
||||
};
|
||||
await transfer('navara');
|
||||
assert.equal(
|
||||
await p.evaluate(
|
||||
() => window.qaRoute.getRowControls().list.items[1].active,
|
||||
),
|
||||
true,
|
||||
);
|
||||
await p.evaluate(() => window.qaRoute.setParams({ fly: true }));
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')?.active,
|
||||
);
|
||||
const pos = () =>
|
||||
p.evaluate(() => window.__godsEyeView.engine.camera.pose().position);
|
||||
const d = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1800));
|
||||
let progress = await p.evaluate(() => window.qaRoute.getFlightProgress());
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
const before = await pos();
|
||||
await transfer(kind);
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')
|
||||
?.active,
|
||||
);
|
||||
const at = await pos();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const next = await pos();
|
||||
const resumed = await p.evaluate(() => window.qaRoute.getFlightProgress());
|
||||
assert.equal(resumed.motionId, progress.motionId);
|
||||
assert.ok(resumed.traveledM > progress.traveledM);
|
||||
progress = resumed;
|
||||
console.log(kind, 'moved', d(at, next), 'handoff', d(at, before));
|
||||
assert.ok(d(at, next) > 1, 'dolly advances after switch to ' + kind);
|
||||
await strict.passed(
|
||||
p,
|
||||
'directions',
|
||||
'FLY continues with the same motion and advances',
|
||||
{ moved: d(at, next) },
|
||||
);
|
||||
}
|
||||
await p.evaluate(() => window.qaRoute.setParams({ clear: true }));
|
||||
assert.equal(await p.evaluate(() => window.qaRoute.getStats().count), 0);
|
||||
// CLEAR releases the camera: the route's motion ends, the camera stays
|
||||
// where it is, and a wheel zoom by the person moves it and is kept.
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const released = await p.evaluate(() => ({
|
||||
motion: window.qaRoute.getFlightProgress(),
|
||||
fly: window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')
|
||||
?.active,
|
||||
}));
|
||||
const still = await pos();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const later = await pos();
|
||||
const height = () =>
|
||||
p.evaluate(() => window.__godsEyeView.engine.camera.height());
|
||||
const beforeZoom = await height();
|
||||
await p.mouse.move(512, 300);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await p.mouse.wheel({ deltaY: -240 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const zoomed = await height();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const kept = await height();
|
||||
await strict.check(
|
||||
p,
|
||||
'directions',
|
||||
'CLEAR removes the route and releases the camera: no route motion, the camera stays put, a wheel zoom moves it and is kept',
|
||||
released.motion === null &&
|
||||
!released.fly &&
|
||||
d(still, later) < 1 &&
|
||||
zoomed < beforeZoom * 0.95 &&
|
||||
Math.abs(kept - zoomed) < 0.02 * zoomed,
|
||||
{ released, drift: d(still, later), beforeZoom, zoomed, kept },
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS route delivery during handoff, A/B clicks, step selection, ongoing FLY, clear and single route request',
|
||||
);
|
||||
});
|
||||
@@ -35,8 +35,9 @@ const check = (name, passed, detail = '') => {
|
||||
};
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: false,
|
||||
executablePath: chromeExecutablePath(),
|
||||
headless: process.env.QA_HEADFUL === '1' ? false : true,
|
||||
executablePath:
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-background-timer-throttling',
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import sharp from 'sharp';
|
||||
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||
const url = localPreview(process.argv[2]);
|
||||
url.search = '?engine=navara&welcome=0';
|
||||
url.hash =
|
||||
'v=2&lat=30.2672&lon=-97.742&alt=4000&pitch=-90&heading=0&map=sentinel2&l=';
|
||||
const png = await sharp({
|
||||
create: { width: 64, height: 64, channels: 4, background: '#fd2233' },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const features = [
|
||||
{
|
||||
id: 'point',
|
||||
geometry: { type: 'Point', coordinates: [-97.742, 30.27, 450] },
|
||||
},
|
||||
{
|
||||
id: 'line',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[-97.745, 30.268, 400],
|
||||
[-97.735, 30.268, 400],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'polygon',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[-97.747, 30.264, 450],
|
||||
[-97.74, 30.264, 450],
|
||||
[-97.74, 30.267, 450],
|
||||
[-97.747, 30.264, 450],
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
].map((f) => ({ ...f, type: 'Feature' }));
|
||||
const wav = Buffer.alloc(44 + 16000);
|
||||
wav.write('RIFF');
|
||||
wav.writeUInt32LE(wav.length - 8, 4);
|
||||
wav.write('WAVEfmt ', 8);
|
||||
wav.writeUInt32LE(16, 16);
|
||||
wav.writeUInt16LE(1, 20);
|
||||
wav.writeUInt16LE(1, 22);
|
||||
wav.writeUInt32LE(8000, 24);
|
||||
wav.writeUInt32LE(16000, 28);
|
||||
wav.writeUInt16LE(2, 32);
|
||||
wav.writeUInt16LE(16, 34);
|
||||
wav.write('data', 36);
|
||||
wav.writeUInt32LE(16000, 40);
|
||||
const pack = (id, format, path, placement) => ({
|
||||
id,
|
||||
version: 1,
|
||||
format,
|
||||
source: { adapter: 'assets', path },
|
||||
attribution: { text: 'Local fixture', license: 'CC0-1.0' },
|
||||
placement,
|
||||
});
|
||||
const camera = {
|
||||
lat: 30.2672,
|
||||
lon: -97.742,
|
||||
alt: 4000,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
altitudeReference: 'ellipsoid',
|
||||
};
|
||||
const shot = {
|
||||
id: 'one',
|
||||
title: 'Pack view',
|
||||
camera,
|
||||
durationSec: 5,
|
||||
holdSec: 3,
|
||||
visual: { style: 'normal', mapStack: 'sentinel2' },
|
||||
layers: { earthquakes: true },
|
||||
dataPackIds: ['data', 'image', 'audio'],
|
||||
interactions: features.map((f) => ({
|
||||
id: f.id,
|
||||
label: 'Show ' + f.id,
|
||||
target: { packId: 'data', featureId: f.id },
|
||||
action: { type: 'card', text: f.id },
|
||||
})),
|
||||
};
|
||||
const project = {
|
||||
version: 6,
|
||||
scenes: [
|
||||
{
|
||||
id: 'parity',
|
||||
title: 'Test project',
|
||||
anchors: [
|
||||
{
|
||||
id: 'station',
|
||||
lat: 30.271,
|
||||
lon: -97.735,
|
||||
alt: 500,
|
||||
altitudeReference: 'ellipsoid',
|
||||
},
|
||||
],
|
||||
dataPacks: [
|
||||
pack('data', 'geojson', 'data.json', {
|
||||
altitudeReference: 'ellipsoid',
|
||||
}),
|
||||
pack('image', 'image', 'image.png', {
|
||||
altitudeReference: 'ellipsoid',
|
||||
bounds: [-97.738, 30.264, -97.732, 30.267],
|
||||
height: 500,
|
||||
}),
|
||||
pack('audio', 'media', 'audio.wav', { anchorId: 'station' }),
|
||||
],
|
||||
shots: [
|
||||
shot,
|
||||
{
|
||||
...shot,
|
||||
id: 'two',
|
||||
title: 'Next shot',
|
||||
camera: { ...camera, heading: 15 },
|
||||
move: { from: camera, easing: 'linear' },
|
||||
dataPackIds: [],
|
||||
interactions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
// Scene Director is not a catalog layer; the scenario answers the scene's
|
||||
// earthquake feed itself.
|
||||
const strict = createStrict({
|
||||
origin: url.origin,
|
||||
layers: [],
|
||||
fixtures: ['https://earthquake.usgs.gov/*'],
|
||||
});
|
||||
await strict.run({}, async (b) => {
|
||||
const p = await b.newPage();
|
||||
await p.setViewport({ width: 1280, height: 900 });
|
||||
const errors = [];
|
||||
p.on('pageerror', (e) => {
|
||||
errors.push(e.message);
|
||||
console.log('error', e.message);
|
||||
});
|
||||
const reads = [];
|
||||
await p.setRequestInterception(true);
|
||||
p.on('request', (req) => {
|
||||
const path = new URL(req.url()).pathname;
|
||||
if (path.startsWith('/scene-assets/')) {
|
||||
reads.push(path);
|
||||
const file = path.split('/').pop();
|
||||
return req.respond({
|
||||
status: 200,
|
||||
contentType:
|
||||
file === 'data.json'
|
||||
? 'application/geo+json'
|
||||
: file === 'audio.wav'
|
||||
? 'audio/wav'
|
||||
: 'image/png',
|
||||
body:
|
||||
file === 'data.json'
|
||||
? JSON.stringify({ type: 'FeatureCollection', features })
|
||||
: file === 'audio.wav'
|
||||
? wav
|
||||
: png,
|
||||
});
|
||||
}
|
||||
if (new URL(req.url()).hostname === 'earthquake.usgs.gov')
|
||||
return req.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
body: '{"type":"FeatureCollection","features":[]}',
|
||||
});
|
||||
return req.continue();
|
||||
});
|
||||
await p.goto(url.href);
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.sceneDirector &&
|
||||
document.querySelector('#loading-screen.hidden'),
|
||||
);
|
||||
await p.evaluate(async (project) => {
|
||||
const a = window.__godsEyeView;
|
||||
if (window.__GOOGLE_MAPS_API_KEY__ || a.tileset)
|
||||
throw Error('Expected keyless preview');
|
||||
await a.styleManager.initialRestorePromise;
|
||||
window.director = a.sceneDirector;
|
||||
await window.director.importProjectFile(
|
||||
new File([JSON.stringify(project)], 'qa.json'),
|
||||
);
|
||||
const out = await window.director.loadShot('parity', 'one', {
|
||||
flyDuration: 0.2,
|
||||
});
|
||||
if (!out.started)
|
||||
throw Error(
|
||||
'Pack load failed: ' +
|
||||
JSON.stringify(out) +
|
||||
' ' +
|
||||
window.director._presentation.status,
|
||||
);
|
||||
}, project);
|
||||
await p.waitForSelector('[data-director-pack="audio"] audio');
|
||||
await p.evaluate(() => {
|
||||
window.qaAudio = document.querySelector(
|
||||
'[data-director-pack="audio"] audio',
|
||||
);
|
||||
});
|
||||
const at = (world) =>
|
||||
p.evaluate((world) => {
|
||||
const a = window.__godsEyeView;
|
||||
const xy = a.engine.projector.worldToWindow(world),
|
||||
rect = a.engine.host.canvas.getBoundingClientRect();
|
||||
return { x: xy.x + rect.x, y: xy.y + rect.y };
|
||||
}, world);
|
||||
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind)
|
||||
await strict.switchTo(p, kind);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const st = await p.evaluate(() => ({
|
||||
same: window.director === window.__godsEyeView.sceneDirector,
|
||||
media:
|
||||
window.qaAudio ===
|
||||
document.querySelector('[data-director-pack="audio"] audio'),
|
||||
packs: window.director.getDataPackState(),
|
||||
actions: window.director.getInteractionState(),
|
||||
}));
|
||||
assert.ok(
|
||||
st.same && st.media && st.packs.count === 3 && st.actions.count === 3,
|
||||
);
|
||||
const xy = await at(fromDegrees(-97.742, 30.27, 450));
|
||||
await p.mouse.click(xy.x, xy.y);
|
||||
await p.waitForFunction(
|
||||
() => document.activeElement?.dataset.directorAction === 'point',
|
||||
);
|
||||
await p.keyboard.press('Enter');
|
||||
await p.waitForFunction(
|
||||
() => window.director.getInteractionState().selected === 'point',
|
||||
);
|
||||
for (const [id, coords] of [
|
||||
['line', [-97.74, 30.268, 400]],
|
||||
['polygon', [-97.742, 30.265, 450]],
|
||||
]) {
|
||||
const hit = await at(fromDegrees(...coords));
|
||||
await p.mouse.click(hit.x, hit.y);
|
||||
await p.waitForFunction(
|
||||
(id) => document.activeElement?.dataset.directorAction === id,
|
||||
{ timeout: 10000 },
|
||||
id,
|
||||
);
|
||||
}
|
||||
const imageAt = await at(fromDegrees(-97.735, 30.2655, 500));
|
||||
const sample = await sharp(await p.screenshot())
|
||||
.extract({
|
||||
left: Math.round(imageAt.x),
|
||||
top: Math.round(imageAt.y),
|
||||
width: 1,
|
||||
height: 1,
|
||||
})
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
console.log(kind, st.packs.count, st.actions.count, Array.from(sample));
|
||||
assert.ok(
|
||||
sample[0] > 100 && sample[0] > sample[1] * 2,
|
||||
'authored image really draws',
|
||||
);
|
||||
assert.equal(
|
||||
reads.length,
|
||||
3,
|
||||
'packs are not acquired again on a globe change',
|
||||
);
|
||||
}
|
||||
await p.evaluate(() => {
|
||||
window.qaRun = window.director.startScene('parity', {
|
||||
single: true,
|
||||
preview: false,
|
||||
});
|
||||
});
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.director.running &&
|
||||
window.director.getPlaybackTimingState().snapshot?.shotId === 'one',
|
||||
);
|
||||
await strict.switchTo(p, 'cesium');
|
||||
assert.ok(
|
||||
await p.evaluate(
|
||||
() =>
|
||||
window.director.running &&
|
||||
window.director === window.__godsEyeView.sceneDirector,
|
||||
),
|
||||
);
|
||||
await p.waitForFunction(
|
||||
() => window.director.getPlaybackTimingState().snapshot?.shotId === 'two',
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
await strict.switchTo(p, 'navara');
|
||||
const complete = await p.evaluate(() => window.qaRun);
|
||||
assert.ok(complete.started && complete.shots === 2);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS authored packs, geographic picks, same player, original multi-shot timeline',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,468 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import sharp from 'sharp';
|
||||
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?welcome=0&look=cinematic';
|
||||
base.hash =
|
||||
'v=2&lat=42.3554&lon=-71.0605&alt=6000&heading=0&pitch=-90&map=osm&l=e';
|
||||
const strict = createStrict({ origin: base.origin, layers: ['earthquakes'] });
|
||||
await strict.run({}, async (browser) => {
|
||||
const page = await browser.newPage();
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
const fetch = window.fetch.bind(window);
|
||||
// Two Boston vessels for the Contacts session; empty air and site feeds.
|
||||
const fixtures = {
|
||||
'/api/vessels': () => ({
|
||||
status: 'live',
|
||||
rows: ['123456789', '223456789'].map((mmsi, i) => ({
|
||||
mmsi,
|
||||
name: 'Boston ' + i,
|
||||
lat: 42.3554,
|
||||
lon: -71.0605 + i * 0.05,
|
||||
last_position_epoch: Date.now() / 1000,
|
||||
})),
|
||||
}),
|
||||
'/api/vessels/track': () => ({ samples: [] }),
|
||||
'/api/flights': () => ({ time: Date.now() / 1000, states: [] }),
|
||||
'/api/military': () => ({ now: Date.now(), ac: [] }),
|
||||
'/api/military-installations': () => ({ elements: [], status: 'ok' }),
|
||||
};
|
||||
window.fetch = (url, options) => {
|
||||
if (
|
||||
String(url).startsWith('https://earthquake.usgs.gov/earthquakes/feed/')
|
||||
)
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
id: 'qa-boston',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [-71.0605, 42.3554, 8],
|
||||
},
|
||||
properties: { mag: 4, time: Date.now(), place: 'QA Boston' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const fixture =
|
||||
fixtures[new URL(url.url ?? url, document.baseURI).pathname];
|
||||
return fixture
|
||||
? Promise.resolve(Response.json(fixture()))
|
||||
: fetch(url, options);
|
||||
};
|
||||
});
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
async function ready(kind) {
|
||||
await page.waitForFunction(
|
||||
(kind) =>
|
||||
window.__godsEyeView?.engine?.kind === kind &&
|
||||
document.querySelector('#globe-engine')?.value === kind &&
|
||||
!document.querySelector('#globe-engine').disabled &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 90000 },
|
||||
kind,
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
await window.__godsEyeView.styleManager.initialRestorePromise;
|
||||
if (window.__GOOGLE_MAPS_API_KEY__ || window.__godsEyeView.tileset)
|
||||
throw new Error('Keyless preview required');
|
||||
});
|
||||
assert.equal(await page.$eval('#globe-engine', (el) => el.value), kind);
|
||||
}
|
||||
async function verifyQuakeSurface(kind) {
|
||||
const meanRed = async (enabled) => {
|
||||
await page.evaluate(async (enabled) => {
|
||||
const a = window.__godsEyeView;
|
||||
await a.dataManager.setEnabled('earthquakes', enabled, {
|
||||
origin: 'user',
|
||||
});
|
||||
a.engine.render.request('qa-quake-surface');
|
||||
}, enabled);
|
||||
if (enabled)
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('earthquakes')
|
||||
.module.getAnalystRecords().length === 1,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1800));
|
||||
const bytes = await page.screenshot({
|
||||
clip: { x: 675, y: 403, width: 20, height: 20 },
|
||||
});
|
||||
const { channels } = await sharp(bytes).stats();
|
||||
return channels[0].mean - channels[1].mean;
|
||||
};
|
||||
const off = await meanRed(false),
|
||||
on = await meanRed(true);
|
||||
assert.ok(
|
||||
on > off + 8,
|
||||
kind +
|
||||
' must draw the shallow red earthquake footprint: ' +
|
||||
off +
|
||||
' -> ' +
|
||||
on,
|
||||
);
|
||||
await strict.passed(page, 'earthquakes', 'shallow red footprint drawn', {
|
||||
off,
|
||||
on,
|
||||
});
|
||||
console.log(
|
||||
'PASS',
|
||||
kind,
|
||||
'earthquake footprint red minus green:',
|
||||
off,
|
||||
'->',
|
||||
on,
|
||||
);
|
||||
}
|
||||
const stored = () => page.evaluate(() => localStorage.getItem('gev.engine'));
|
||||
async function switchTo(kind) {
|
||||
if (
|
||||
await page.$eval(
|
||||
'#control-panel-toggle',
|
||||
(el) => el.getAttribute('aria-expanded') !== 'true',
|
||||
)
|
||||
) {
|
||||
await page.focus('#control-panel-toggle');
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
await page.waitForSelector('#globe-engine', { visible: true });
|
||||
await page.evaluate(() => {
|
||||
window.__switchDocument = document;
|
||||
window.__quakeEntry =
|
||||
window.__godsEyeView.dataManager.layers.get('earthquakes');
|
||||
window.__quakeTimer = window.__quakeEntry.intervalId;
|
||||
window.__renderers ??= new Map();
|
||||
window.__renderers.set(
|
||||
window.__godsEyeView.engine.kind,
|
||||
window.__godsEyeView.engine,
|
||||
);
|
||||
window.__switchSplash = false;
|
||||
window.__switchObserver = new MutationObserver(() => {
|
||||
if (!document.querySelector('#loading-screen.hidden'))
|
||||
window.__switchSplash = true;
|
||||
});
|
||||
window.__switchObserver.observe(
|
||||
document.querySelector('#loading-screen'),
|
||||
{ attributes: true },
|
||||
);
|
||||
window.__outgoingEngine = window.__godsEyeView.engine;
|
||||
});
|
||||
await strict.pick(page, kind);
|
||||
await ready(kind);
|
||||
assert.ok(await page.evaluate(() => window.__switchDocument === document));
|
||||
assert.ok(
|
||||
await page.evaluate(() => {
|
||||
window.__switchObserver.disconnect();
|
||||
const { engine } = window.__godsEyeView;
|
||||
const previous = window.__renderers.get(engine.kind);
|
||||
return (
|
||||
!window.__switchSplash &&
|
||||
window.__quakeEntry ===
|
||||
window.__godsEyeView.dataManager.layers.get('earthquakes') &&
|
||||
window.__quakeTimer === window.__quakeEntry.intervalId &&
|
||||
(!previous || previous === engine) &&
|
||||
!window.__outgoingEngine.isDestroyed() &&
|
||||
!window.__outgoingEngine.host.canvas.isConnected &&
|
||||
engine.host.canvas.isConnected &&
|
||||
window.__godsEyeView.dataManager
|
||||
.getEnabledLayerIds()
|
||||
.has('earthquakes')
|
||||
);
|
||||
}),
|
||||
);
|
||||
await strict.passed(
|
||||
page,
|
||||
'earthquakes',
|
||||
'same layer entry, model and poll timer; no splash; outgoing canvas detached',
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('.engine-switch-frame'),
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(async () => {
|
||||
const parked = window.__outgoingEngine;
|
||||
let frames = 0;
|
||||
const remove = parked.frames.postRender.addEventListener(() => {
|
||||
frames++;
|
||||
});
|
||||
parked.render.request('qa-parked');
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
remove();
|
||||
return frames;
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const url = new URL(page.url());
|
||||
assert.equal(url.searchParams.get('engine'), kind);
|
||||
assert.equal(url.searchParams.get('look'), 'cinematic');
|
||||
const hash = new URLSearchParams(url.hash.slice(1));
|
||||
assert.equal(hash.get('map'), 'osm');
|
||||
assert.ok(Math.abs(Number(hash.get('lat')) - 42.3554) < 0.01);
|
||||
assert.ok(Math.abs(Number(hash.get('lon')) + 71.0605) < 0.01);
|
||||
assert.ok(Math.abs(Number(hash.get('alt')) - 6000) < 30);
|
||||
const view = await page.evaluate(() => {
|
||||
const { engine, mapStackController } = window.__godsEyeView;
|
||||
const { latitude, longitude, height } = engine.camera.pose().cartographic;
|
||||
return {
|
||||
latitude: latitude * (180 / Math.PI),
|
||||
longitude: longitude * (180 / Math.PI),
|
||||
height,
|
||||
map: mapStackController.getActiveId(),
|
||||
};
|
||||
});
|
||||
assert.equal(view.map, 'osm');
|
||||
assert.ok(Math.abs(view.latitude - 42.3554) < 0.01);
|
||||
assert.ok(Math.abs(view.longitude + 71.0605) < 0.01);
|
||||
assert.ok(Math.abs(view.height - 6000) < 30);
|
||||
await strict.passed(
|
||||
page,
|
||||
'earthquakes',
|
||||
'parked engine draws no frame; pose, map and layers carried over',
|
||||
);
|
||||
}
|
||||
await page.goto(base.href, { waitUntil: 'domcontentloaded' });
|
||||
await ready('cesium');
|
||||
await verifyQuakeSurface('cesium');
|
||||
const sharpen = () =>
|
||||
page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.getControlState().sharpen,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const style = window.__godsEyeView.styleManager;
|
||||
style.setStyle('retro');
|
||||
style.setStyle('normal');
|
||||
});
|
||||
assert.deepEqual(await sharpen(), { enabled: true, intensityPct: 49 });
|
||||
await switchTo('navara');
|
||||
await verifyQuakeSurface('navara');
|
||||
assert.deepEqual(await sharpen(), { enabled: true, intensityPct: 80 });
|
||||
assert.ok(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
Math.abs(window.__godsEyeView.engine.render.sharpen().amount - 1.7) <
|
||||
1e-6,
|
||||
),
|
||||
);
|
||||
assert.equal(await stored(), 'navara');
|
||||
await page.goBack({ waitUntil: 'domcontentloaded' });
|
||||
await ready('cesium');
|
||||
assert.equal((await sharpen()).intensityPct, 49);
|
||||
await page.goForward();
|
||||
await ready('navara');
|
||||
assert.equal((await sharpen()).intensityPct, 80);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setSharpen({
|
||||
enabled: false,
|
||||
intensityPct: 49,
|
||||
}),
|
||||
);
|
||||
await switchTo('cesium');
|
||||
await switchTo('navara');
|
||||
assert.deepEqual(await sharpen(), { enabled: false, intensityPct: 49 });
|
||||
// The address carries the running layer set once its debounced write lands.
|
||||
const settledAddress = async () => {
|
||||
let previous;
|
||||
for (;;) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
const current = await page.evaluate(() =>
|
||||
new URLSearchParams(location.hash.slice(1)).get('l'),
|
||||
);
|
||||
if (current === previous) return current;
|
||||
previous = current;
|
||||
}
|
||||
};
|
||||
const pick = async (kind) => {
|
||||
await strict.pick(page, kind);
|
||||
await ready(kind);
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('.engine-switch-frame'),
|
||||
);
|
||||
};
|
||||
const visit = async (direction, kind) => {
|
||||
await (direction === 'back'
|
||||
? page.goBack({ waitUntil: 'domcontentloaded' })
|
||||
: page.goForward());
|
||||
await ready(kind);
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('.engine-switch-frame'),
|
||||
);
|
||||
};
|
||||
const quakes = () =>
|
||||
page.evaluate(() => {
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
return {
|
||||
model: window.__quakeModel === dataManager.layers.get('earthquakes'),
|
||||
enabled: dataManager.getEnabledLayerIds().has('earthquakes'),
|
||||
};
|
||||
});
|
||||
// Like a same-engine Back, each entry brings back its own layer set, on
|
||||
// the same layer models.
|
||||
await page.evaluate(() => {
|
||||
window.__quakeModel =
|
||||
window.__godsEyeView.dataManager.layers.get('earthquakes');
|
||||
});
|
||||
const navaraLayers = await settledAddress();
|
||||
await pick('cesium');
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('earthquakes', false, {
|
||||
origin: 'user',
|
||||
}),
|
||||
);
|
||||
const cesiumLayers = await settledAddress();
|
||||
assert.notEqual(cesiumLayers, navaraLayers);
|
||||
await visit('back', 'navara');
|
||||
assert.deepEqual(await quakes(), { model: true, enabled: true });
|
||||
await strict.passed(
|
||||
page,
|
||||
'earthquakes',
|
||||
'Back restores the enabled layer on the same model',
|
||||
);
|
||||
assert.equal(await settledAddress(), navaraLayers, 'Back keeps its entry');
|
||||
await visit('forward', 'cesium');
|
||||
assert.deepEqual(await quakes(), { model: true, enabled: false });
|
||||
await strict.passed(
|
||||
page,
|
||||
'earthquakes',
|
||||
'Forward restores the disabled layer on the same model',
|
||||
);
|
||||
assert.equal(await settledAddress(), cesiumLayers, 'Forward keeps its entry');
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('earthquakes', true, {
|
||||
origin: 'user',
|
||||
}),
|
||||
);
|
||||
await pick('navara');
|
||||
console.log(
|
||||
"PASS Back/Forward restore each entry's layers on the same models",
|
||||
);
|
||||
// Back/Forward hand the session off like the picker: the Contacts subject
|
||||
// and its navigation history, its layers and the Director survive.
|
||||
await page.evaluate(async () => {
|
||||
const { dataManager, styleManager, sceneDirector } = window.__godsEyeView;
|
||||
await dataManager.setEnabled('ais-live-vessels', true, {
|
||||
origin: 'user',
|
||||
});
|
||||
const outcome = await styleManager.setContextMode('contacts');
|
||||
if (!outcome.ok) throw Error(JSON.stringify(outcome));
|
||||
window.qaSession = {
|
||||
director: sceneDirector,
|
||||
context: dataManager.layers.get('military-awareness').module,
|
||||
vessels: dataManager.layers.get('ais-live-vessels'),
|
||||
};
|
||||
if (!window.qaSession.vessels.module.selectById('123456789'))
|
||||
throw Error('Missing first vessel');
|
||||
if (
|
||||
!window.qaSession.context.navigateNext({
|
||||
targetLayer: 'ais-live-vessels',
|
||||
})
|
||||
)
|
||||
throw Error('Cannot navigate to the second vessel');
|
||||
});
|
||||
const session = () =>
|
||||
page.evaluate(() => {
|
||||
const { dataManager, styleManager, sceneDirector } = window.__godsEyeView;
|
||||
const { director, context, vessels } = window.qaSession;
|
||||
return {
|
||||
director: director === sceneDirector && !director._destroyed,
|
||||
owners:
|
||||
context === dataManager.layers.get('military-awareness').module &&
|
||||
vessels === dataManager.layers.get('ais-live-vessels'),
|
||||
mode: styleManager.getContextModeState().mode,
|
||||
subject: context.getContextSnapshot()?.subject?.id,
|
||||
history: context._getAwarenessNavigationStateForTest().historyKeys,
|
||||
enabled: [...dataManager.getEnabledLayerIds()].sort(),
|
||||
};
|
||||
});
|
||||
const live = await session();
|
||||
assert.equal(live.mode, 'flights');
|
||||
assert.equal(live.subject, '223456789');
|
||||
assert.ok(live.history.includes('ais-live-vessels:123456789'));
|
||||
await settledAddress();
|
||||
const historyLength = await page.evaluate(() => history.length);
|
||||
await pick('cesium');
|
||||
assert.deepEqual(await session(), live, 'a pick keeps the session');
|
||||
await settledAddress();
|
||||
await visit('back', 'navara');
|
||||
assert.deepEqual(await session(), live, 'Back keeps the session');
|
||||
await visit('forward', 'cesium');
|
||||
assert.deepEqual(await session(), live, 'Forward keeps the session');
|
||||
assert.equal(await page.evaluate(() => history.length), historyLength + 1);
|
||||
assert.ok(
|
||||
await page.evaluate(async () => {
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
const result = await styleManager.setContextMode('off');
|
||||
await dataManager.setEnabled('ais-live-vessels', false, {
|
||||
origin: 'user',
|
||||
});
|
||||
return result.ok;
|
||||
}),
|
||||
);
|
||||
await pick('navara');
|
||||
console.log(
|
||||
'PASS Back/Forward keep Contacts subject, history, layers and Director',
|
||||
);
|
||||
await page.goto(base.href, { waitUntil: 'domcontentloaded' });
|
||||
await ready('navara');
|
||||
await switchTo('cesium');
|
||||
assert.equal(await stored(), 'cesium');
|
||||
await page.evaluate(() => {
|
||||
const write = Storage.prototype.setItem;
|
||||
Storage.prototype.setItem = function (key, value) {
|
||||
if (key === 'gev.engine')
|
||||
throw new DOMException('Blocked', 'SecurityError');
|
||||
return write.call(this, key, value);
|
||||
};
|
||||
});
|
||||
await switchTo('navara');
|
||||
assert.equal(await stored(), 'cesium');
|
||||
// The real Map Source chips move into daylight inspection and back to live lighting.
|
||||
await page.$eval('[data-stack-id="sentinel2"]', (el) => el.click());
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.mapStackController.getActiveId() === 'sentinel2' &&
|
||||
window.__godsEyeView.engine.host.container.hasAttribute(
|
||||
'data-terrain-daylight',
|
||||
),
|
||||
);
|
||||
// Zooming out on the satellite map must keep the same inspection sun.
|
||||
assert.ok(
|
||||
await page.evaluate(async () => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
engine.camera.zoomOut(2_000_000);
|
||||
await new Promise((resolve) => {
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('qa-orbital-daylight');
|
||||
});
|
||||
return (
|
||||
engine.camera.height() > 1_500_000 &&
|
||||
engine.host.container.hasAttribute('data-terrain-daylight')
|
||||
);
|
||||
}),
|
||||
'the orbital frame keeps daylight on the inspected globe',
|
||||
);
|
||||
await page.$eval('[data-stack-id="osm"]', (el) => el.click());
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.mapStackController.getActiveId() === 'osm' &&
|
||||
!window.__godsEyeView.engine.host.container.hasAttribute(
|
||||
'data-terrain-daylight',
|
||||
),
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS Cesium default, both switches, same document, warm engine identity, parked canvas, no startup splash, share pose/map/layers, Back/Forward, remembered engine, blocked preference storage',
|
||||
);
|
||||
});
|
||||
@@ -28,15 +28,15 @@
|
||||
* 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))
|
||||
* E5 glyphs : exact classified silhouette at its registered 64/192 px raster
|
||||
* 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)
|
||||
* stop at the real 300-token initial ceiling; the QA seam
|
||||
* shortens only the refill window (a real 5-min window
|
||||
* cannot 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
|
||||
@@ -66,9 +66,14 @@ const getOpt = (name, dflt) => {
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const BASE_URL = getOpt('--url', 'http://localhost:4173');
|
||||
// --engine=cesium|navara: Navara reads the layer through the QA
|
||||
// probe (window.__godsEyeView.layers.drawn) and moves the engine camera.
|
||||
const ENGINE = (argv.find((arg) => arg.startsWith('--engine=')) ?? '--engine=cesium').slice(9);
|
||||
const APP_URL =
|
||||
ENGINE === 'cesium' ? BASE_URL : `${BASE_URL.replace(/\/$/, '')}/?engine=${ENGINE}`;
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const SHOT_DIR = path.resolve('qa-shots/enrich-ambient');
|
||||
const SHOT_DIR = path.resolve(ENGINE === 'cesium' ? 'qa-shots/enrich-ambient' : `qa-shots/enrich-ambient-${ENGINE}`);
|
||||
|
||||
// Client-side bounds under test (flights.js constants):
|
||||
const MAX_INFLIGHT = 4; // ENRICH_MAX_INFLIGHT
|
||||
@@ -76,13 +81,13 @@ 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
|
||||
// Rolling-budget QA seam: exercise the real initial ceiling and shorten
|
||||
// only the refill window so the exhaust→refill cycle is observable. 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 };
|
||||
// "passed" and the bucket refills (clamped at ceil). The initial production
|
||||
// ceiling of 300 is exercised without assuming the override resets state.
|
||||
const BUDGET_QA = { ceil: SESSION_CAP, refillTokens: 6, windowMs: 3600000 };
|
||||
const RESUME_WINDOW_MS = 1000; // E11 swaps windowMs to this to unlock refills
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
@@ -160,6 +165,9 @@ 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
|
||||
// Adaptive raster resolution preserves the classified silhouette. Compare
|
||||
// exact registered assets at either supported resolution, never another class.
|
||||
const matchesClassIcon = (image, klass) => image === aircraftIcon(klass) || image === aircraftIcon(klass, 192);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
async function main() {
|
||||
@@ -185,7 +193,7 @@ async function main() {
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
`--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`,
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
@@ -208,6 +216,7 @@ async function main() {
|
||||
|
||||
// ---- Fetch shim: synthetic fleet + instrumented, holdable adsbdb -------
|
||||
await page.evaluateOnNewDocument((spec) => {
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
window.__ENR = spec;
|
||||
window.__ENR.epochMs = Date.now();
|
||||
// Rolling-budget QA seam — must exist BEFORE flights.js initializes so
|
||||
@@ -289,15 +298,24 @@ async function main() {
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
|
||||
() => window.__godsEyeView && window.__godsEyeView.engine && window.__godsEyeView.dataManager && window.__godsEyeView.layers,
|
||||
{ timeout: 60000, polling: 200 }
|
||||
);
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.initialRestorePromise);
|
||||
await page.waitForFunction(() => document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 180000 });
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.orbitController.stop());
|
||||
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;
|
||||
// Without a viewer (Navara): what the flights layer draws, through
|
||||
// the engine-neutral probe.
|
||||
if (!v)
|
||||
return (window.__godsEyeView.layers.drawn('flights') ?? [])
|
||||
.filter((row) => !row.trackedModel)
|
||||
.map(({ id, image, scale, show }) => ({ id, image, scale, show }));
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
@@ -321,12 +339,12 @@ async function main() {
|
||||
// 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),
|
||||
await page.evaluate(async ({ lat, lon, height }) => {
|
||||
const camera = window.__godsEyeView.engine.camera;
|
||||
camera.cancelFlight(); // the boot fly-to-Austin otherwise stomps setView
|
||||
const geo = await import('/src/geo/index.js');
|
||||
camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(lon, lat, height),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
}, { lat: ROW_LAT, lon: centerLon, height: 20000 });
|
||||
@@ -334,8 +352,9 @@ async function main() {
|
||||
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;
|
||||
const v = window.__godsEyeView.viewer ?? null;
|
||||
window.__ENR.timeOffsetSec = -32;
|
||||
dm.setLayerParams('flights', { models3d: false }); // ambient budget excludes model-priority enrichment
|
||||
await dm.setEnabled('flights', true);
|
||||
const fl = dm.layers.get('flights').module;
|
||||
for (const off of [-24, -16, -8, 0]) {
|
||||
@@ -344,11 +363,11 @@ async function main() {
|
||||
}
|
||||
window.__ENR.timeOffsetSec = 0;
|
||||
window.__ENR_DRIVER = setInterval(() => { fl.update(v); }, 15000);
|
||||
return { count: fl.getStats().count, starts: window.__ENRICH_LOG.starts.length };
|
||||
return { count: fl.getStats().count, starts: window.__ENRICH_LOG.starts.length, models3d: fl.getParams().models3d };
|
||||
});
|
||||
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}`);
|
||||
primed.count === SPEC.planes.length && primed.models3d === false, `count=${primed.count}/${SPEC.planes.length}, ambient-only models3d=${primed.models3d}`);
|
||||
if (primed.count === 0) { finish(); return; }
|
||||
|
||||
// ========================================================================
|
||||
@@ -414,12 +433,11 @@ async function main() {
|
||||
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 || !matchesClassIcon(bb.image, klass)) 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))',
|
||||
record('E5 glyphs: every billboard matches its classified 64/192 px asset',
|
||||
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)');
|
||||
@@ -456,7 +474,7 @@ async function main() {
|
||||
console.log('\nE8 — next poll after enrichment');
|
||||
await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = window.__godsEyeView.viewer ?? null;
|
||||
await dm.layers.get('flights').module.update(v);
|
||||
});
|
||||
await sleep(500);
|
||||
@@ -468,7 +486,7 @@ async function main() {
|
||||
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'}`);
|
||||
if (!bb || !matchesClassIcon(bb.image, 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;
|
||||
@@ -480,20 +498,19 @@ async function main() {
|
||||
// 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
|
||||
// initial ceiling=300 and 12 tokens spent above, 288 remain. More than
|
||||
// 300 total candidates must stall at exactly 300 requests; 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).
|
||||
// no refill can land mid-phase). E10 verifies the initial limit; E11
|
||||
// distinguishes a rolling refill from the old one-shot session cap.
|
||||
// ========================================================================
|
||||
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')}`);
|
||||
console.log('\nE10 — rolling budget exhausts (real 300-request initial bucket)');
|
||||
const BATCH_COUNT = BUDGET_QA.ceil + 26;
|
||||
const batchHexes = Array.from({ length: BATCH_COUNT }, (_, i) => (0xab0000 + i + 1).toString(16).padStart(6, '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;
|
||||
const v = window.__godsEyeView.viewer ?? null;
|
||||
// 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
|
||||
@@ -506,33 +523,47 @@ async function main() {
|
||||
lat0: rowLat + (i < 13 ? 0.02 : 0.04),
|
||||
courseDeg: 0,
|
||||
speedMps: 0,
|
||||
altM: 2600 + i * 15,
|
||||
altM: 2600 + (i % 26) * 15,
|
||||
});
|
||||
});
|
||||
const geo = await import('/src/geo/index.js');
|
||||
const engine = window.__godsEyeView.engine;
|
||||
engine.camera.cancelFlight();
|
||||
engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(rowLon0 + 5.5 * stepDeg, rowLat, 20000),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
for (let frame = 0; frame < 30; frame++) {
|
||||
engine.render.request('qa-enrich-budget');
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
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
|
||||
await fl.update(v); // sweep enqueues up to 150 ambient jobs
|
||||
await fl.update(v); // spend the remaining initial tokens; later polls must stall
|
||||
}, { 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)
|
||||
{ timeout: 90000, polling: 100 }, BUDGET_QA.ceil
|
||||
).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 dm.layers.get('flights').module.update(window.__godsEyeView.viewer ?? null);
|
||||
});
|
||||
await sleep(1000);
|
||||
const exhausted = await page.evaluate(() => ({
|
||||
starts: window.__ENRICH_LOG.starts.length,
|
||||
inflight: window.__ENRICH_LOG.inflight,
|
||||
sourceCount: window.__godsEyeView.dataManager.layers.get('flights').module.getStats().count,
|
||||
fixtureCount: window.__ENR.planes.length,
|
||||
cameraHeightM: window.__godsEyeView.engine.camera.pose().cartographic.height,
|
||||
}));
|
||||
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,
|
||||
exhausted.starts === BUDGET_QA.ceil && exhausted.inflight === 0 && exhausted.sourceCount === startsBeforeBatch + BATCH_COUNT,
|
||||
`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}`);
|
||||
+ `${BATCH_COUNT - (BUDGET_QA.ceil - startsBeforeBatch)} planes left waiting) inflight=${exhausted.inflight}, source=${exhausted.sourceCount}/${exhausted.fixtureCount}, camera=${exhausted.cameraHeightM.toFixed(1)}m`);
|
||||
|
||||
// ========================================================================
|
||||
// E11 — refill window passes → enrichment RESUMES. Shorten windowMs so the
|
||||
@@ -544,7 +575,7 @@ async function main() {
|
||||
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);
|
||||
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer ?? null);
|
||||
});
|
||||
const expectedTotal = startsBeforeBatch + BATCH_COUNT; // every batch plane eventually requested
|
||||
const resumed = await page.waitForFunction(
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { installAlprFixture, cameras } from './fixtures/w2/alpr.mjs';
|
||||
import { firmsFixture, FIRMS_VALID_COUNT } from './fixtures/w2/firms.mjs';
|
||||
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||
const url = localPreview(process.argv[2]),
|
||||
fire = process.argv.includes('--fires'),
|
||||
id = fire ? 'local-firms' : 'alpr-cameras';
|
||||
url.search = '?engine=navara&welcome=0';
|
||||
url.hash =
|
||||
'v=2&' +
|
||||
(fire
|
||||
? 'lat=37.77&lon=-122.42&alt=100000'
|
||||
: 'lat=30.262&lon=-97.7431&alt=5500') +
|
||||
'&pitch=-90&heading=0&map=sentinel2&l=';
|
||||
// Records the fixtures produce: the valid FIRMS rows; the ALPR cameras the
|
||||
// fixture tiles carry around Austin.
|
||||
const COUNT = fire ? FIRMS_VALID_COUNT : cameras.length;
|
||||
const strict = createStrict({
|
||||
origin: url.origin,
|
||||
layers: [id],
|
||||
// installAlprFixture answers the camera tiles.
|
||||
fixtures: ['https://tiles.dontgetflocked.com/*'],
|
||||
});
|
||||
await strict.run({}, async (b) => {
|
||||
const p = await b.newPage();
|
||||
await p.setViewport({ width: 1280, height: 900 });
|
||||
const errors = [];
|
||||
p.on('pageerror', (e) => errors.push(e.message));
|
||||
await installAlprFixture(p);
|
||||
await p.evaluateOnNewDocument((data) => {
|
||||
window.qaFirmsReads = 0;
|
||||
const fetch = window.fetch.bind(window);
|
||||
window.fetch = (input, opt) => {
|
||||
if (
|
||||
new URL(input.url ?? input, location.href).pathname === '/api/firms'
|
||||
) {
|
||||
window.qaFirmsReads++;
|
||||
return Promise.resolve(Response.json(data));
|
||||
}
|
||||
return fetch(input, opt);
|
||||
};
|
||||
}, firmsFixture());
|
||||
await p.goto(url.href);
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.engine?.kind === 'navara' &&
|
||||
document.querySelector('#loading-screen.hidden'),
|
||||
);
|
||||
await p.evaluate(async (id) => {
|
||||
const { dataManager, styleManager, tileset } = window.__godsEyeView;
|
||||
if (window.__GOOGLE_MAPS_API_KEY__ || tileset)
|
||||
throw Error('Keyless preview required');
|
||||
await styleManager.initialRestorePromise;
|
||||
window.qaGround = dataManager.layers.get(id).module;
|
||||
await dataManager.setEnabled(id, true, { origin: 'user' });
|
||||
}, id);
|
||||
await p.waitForFunction(() => window.qaGround?.getStats().count > 0, {
|
||||
timeout: 30000,
|
||||
});
|
||||
// A pickable target in the middle of the view: the camera the engine's
|
||||
// own picker returns, or the hottest fire.
|
||||
const findTarget = () =>
|
||||
p
|
||||
.waitForFunction(
|
||||
(fire) => {
|
||||
const a = window.__godsEyeView,
|
||||
m = window.qaGround;
|
||||
const points = fire
|
||||
? m
|
||||
.getDetectableObjects({ maxCount: 1000 })
|
||||
.filter((v) => v.id === m.getAnalystRecords(1)[0]?.id)
|
||||
: [...window.__gevContextStore.entities.values()]
|
||||
.filter((v) => v.layerId === 'alpr-cameras')
|
||||
.map((v) => ({
|
||||
id: v.id,
|
||||
position:
|
||||
v.entity.gevAlprCanvasPosition ||
|
||||
v.entity.gevDisplayPosition?.(),
|
||||
}));
|
||||
for (const row of points) {
|
||||
if (!row.position) continue;
|
||||
const s = a.engine.projector.worldToWindow(row.position);
|
||||
if (!s || s.x < 430 || s.x > 850 || s.y < 220 || s.y > 600)
|
||||
continue;
|
||||
const canvas = a.engine.host.canvas;
|
||||
const box = canvas.getBoundingClientRect();
|
||||
if (
|
||||
!canvas.contains(
|
||||
document.elementFromPoint(box.left + s.x, box.top + s.y),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
const picked = a.engine.picker.pick(s);
|
||||
if (fire || picked?.id === row.id || picked?.id?.id === row.id)
|
||||
return { x: box.left + s.x, y: box.top + s.y, id: row.id };
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
fire,
|
||||
)
|
||||
.then((handle) => handle.jsonValue());
|
||||
const selected = (id) =>
|
||||
p.waitForFunction(
|
||||
(id, fire) =>
|
||||
fire
|
||||
? window.qaGround.getSelectedInfo()?.frp === 999
|
||||
: window.__gevContextStore?.selectedEntityId === id,
|
||||
{ timeout: 15000 },
|
||||
id,
|
||||
fire,
|
||||
);
|
||||
// A click on empty ground (nothing picked there, nothing covering it).
|
||||
const clickEmpty = async () => {
|
||||
const handle = await p.waitForFunction(
|
||||
() => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const canvas = engine.host.canvas;
|
||||
const box = canvas.getBoundingClientRect();
|
||||
for (let y = 240; y < 600; y += 9)
|
||||
for (let x = 440; x < 840; x += 9) {
|
||||
if (
|
||||
!canvas.contains(
|
||||
document.elementFromPoint(box.left + x, box.top + y),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
let free = true;
|
||||
for (const [dx, dy] of [
|
||||
[0, 0],
|
||||
[6, 0],
|
||||
[-6, 0],
|
||||
[0, 6],
|
||||
[0, -6],
|
||||
])
|
||||
if (engine.picker.pick({ x: x + dx, y: y + dy })) free = false;
|
||||
if (free) return { x: box.left + x, y: box.top + y };
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
const at = await handle.jsonValue();
|
||||
await p.mouse.click(at.x, at.y);
|
||||
await p.waitForFunction(
|
||||
(fire) =>
|
||||
fire
|
||||
? !window.qaGround.getSelectedInfo()
|
||||
: !window.__gevContextStore?.selectedEntityId,
|
||||
{ timeout: 15000 },
|
||||
fire,
|
||||
);
|
||||
};
|
||||
const xy = await findTarget();
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
await p.mouse.click(xy.x, xy.y);
|
||||
await selected(xy.id);
|
||||
await strict.passed(
|
||||
p,
|
||||
id,
|
||||
fire
|
||||
? 'a click on the hottest fire selects it'
|
||||
: 'a click on a camera selects it',
|
||||
xy,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 1600));
|
||||
const expectedId = fire
|
||||
? await p.evaluate(() => window.qaGround.getSelectedInfo().id)
|
||||
: xy.id;
|
||||
const before = await p.evaluate(() => ({
|
||||
stamp: window.qaGround.getStats().lastUpdate,
|
||||
records: window.qaGround.getAnalystRecords(1000).map((v) => v.id),
|
||||
reads: window.qaFirmsReads,
|
||||
}));
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
await strict.switchTo(p, kind);
|
||||
const result = await p.evaluate(
|
||||
(id) => ({
|
||||
same:
|
||||
window.qaGround ===
|
||||
window.__godsEyeView.dataManager.layers.get(id).module,
|
||||
selected: window.__gevContextStore.selectedEntityId,
|
||||
record: window.qaGround.getSelectedInfo?.()?.id,
|
||||
stamp: window.qaGround.getStats().lastUpdate,
|
||||
records: window.qaGround.getAnalystRecords(1000).map((v) => v.id),
|
||||
reads: window.qaFirmsReads,
|
||||
count: window.qaGround.getStats().count,
|
||||
}),
|
||||
id,
|
||||
);
|
||||
console.log(kind, id, result.selected, result.count, result.records.length);
|
||||
assert.ok(result.same && result.count);
|
||||
assert.equal(result.selected, expectedId);
|
||||
if (fire) assert.equal(result.record, expectedId);
|
||||
assert.equal(result.reads, before.reads);
|
||||
assert.deepEqual(result.records, before.records);
|
||||
assert.equal(result.stamp, before.stamp);
|
||||
await strict.check(
|
||||
p,
|
||||
id,
|
||||
`same model, ${COUNT} records, the clicked record still selected, no source read, same receipt time`,
|
||||
result.count === COUNT,
|
||||
{ count: result.count },
|
||||
);
|
||||
if (kind === 'cesium') {
|
||||
// Click-picking on Cesium: clear the selection with a click on empty
|
||||
// ground, then click the same record again.
|
||||
await clickEmpty();
|
||||
const again = await findTarget();
|
||||
assert.equal(again.id, xy.id);
|
||||
await p.mouse.click(again.x, again.y);
|
||||
await selected(again.id);
|
||||
await strict.passed(
|
||||
p,
|
||||
id,
|
||||
fire
|
||||
? 'a click on empty ground clears the fire; a click on it selects it again'
|
||||
: 'a click on empty ground clears the camera; a click on it selects it again',
|
||||
again,
|
||||
);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS retained source, stable clicked record on each planet');
|
||||
});
|
||||
+73
-16
@@ -30,22 +30,36 @@
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import {
|
||||
isExpectedPreviewSetupError,
|
||||
redact,
|
||||
} from './qa-browserEvidence.mjs';
|
||||
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 appUrl = new URL(getOpt('--url', 'http://localhost:4420'));
|
||||
const ENGINE = getOpt('--engine', appUrl.searchParams.get('engine') || 'cesium');
|
||||
if (!['cesium', 'navara'].includes(ENGINE)) throw new Error('Unknown engine');
|
||||
appUrl.searchParams.set('engine', ENGINE);
|
||||
const APP_URL = appUrl.href;
|
||||
const HEADFUL = argv.includes('--headful');
|
||||
const FIXTURES = argv.includes('--fixtures');
|
||||
const PREVIEW = argv.includes('--preview');
|
||||
const ANGLE = getOpt('--angle', 'swiftshader');
|
||||
const OUT = getOpt('--out', null);
|
||||
const SHOTS_DIR = OUT ? path.resolve(OUT).replace(/\.json$/, '') + '-shots' : path.join(REPO_ROOT, 'qa-shots');
|
||||
if (!['metal', 'swiftshader'].includes(ANGLE)) throw new Error('Unknown ANGLE backend');
|
||||
const runtimeErrors = [];
|
||||
const expectedSourceErrors = [];
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
@@ -157,9 +171,11 @@ async function waitForFirmsActionCount(page, expected, { timeoutMs = 12000 } = {
|
||||
async function bootAndEnable(page, { timeoutS = 45 } = {}) {
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
() => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const actualEngine = await page.evaluate(() => window.__godsEyeView.engine.kind);
|
||||
if (actualEngine !== ENGINE) throw new Error('Requested FIRMS engine did not load');
|
||||
await sleep(5000);
|
||||
await page.keyboard.press('Escape');
|
||||
return page.evaluate(async (tS) => {
|
||||
@@ -178,8 +194,15 @@ async function bootAndEnable(page, { timeoutS = 45 } = {}) {
|
||||
|
||||
/** Teleport the camera (duck-typed cartographic — no Cesium global). */
|
||||
async function setView(page, lon, lat, height) {
|
||||
await page.evaluate((lo, la, h) => {
|
||||
await page.evaluate(async (lo, la, h) => {
|
||||
const gev = window.__godsEyeView;
|
||||
if (!gev.viewer) {
|
||||
const geo = await import('/src/geo/index.js');
|
||||
gev.engine.camera.cancelFlight();
|
||||
gev.engine.camera.setView({destination:geo.cartesian3.fromDegrees(lo,la,h),orientation:{heading:0,pitch:-Math.PI/2,roll:0}});
|
||||
gev.engine.render.request('firms-legacy-view');
|
||||
return;
|
||||
}
|
||||
const ell = gev.viewer.scene.globe.ellipsoid;
|
||||
const d2r = Math.PI / 180;
|
||||
// The app's intro flyTo animation clobbers a setView issued mid-flight.
|
||||
@@ -251,7 +274,7 @@ async function main() {
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}),
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader',
|
||||
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', `--use-angle=${ANGLE}`,
|
||||
'--disable-dev-shm-usage', '--disable-web-security',
|
||||
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
||||
'--window-size=1440,900',
|
||||
@@ -265,6 +288,23 @@ async function main() {
|
||||
|
||||
await page.setRequestInterception(true);
|
||||
let interceptMode = FIXTURES ? 'fixture' : 'live';
|
||||
page.on('pageerror', error => runtimeErrors.push(redact(error.message)));
|
||||
page.on('error', error => runtimeErrors.push(redact(error.message)));
|
||||
page.on('console', message => {
|
||||
if (message.type() !== 'error') return;
|
||||
if (isExpectedPreviewSetupError(message, { preview: PREVIEW, baseUrl: APP_URL })) {
|
||||
expectedSourceErrors.push('/api/setup/status: 404');
|
||||
return;
|
||||
}
|
||||
let expected = false;
|
||||
try {
|
||||
const url = new URL(message.location()?.url);
|
||||
expected = interceptMode === 'keyless' && url.origin === new URL(APP_URL).origin
|
||||
&& url.pathname === '/api/firms' && /^Failed to load resource:.*\b503\b/.test(message.text());
|
||||
} catch { /* an unknown location is never waived */ }
|
||||
if (expected) expectedSourceErrors.push('/api/firms: intentional keyless fixture503');
|
||||
else runtimeErrors.push(redact(message.text()));
|
||||
});
|
||||
const stableStalePayload = stalePayload();
|
||||
page.on('request', (req) => {
|
||||
const url = req.url();
|
||||
@@ -386,17 +426,22 @@ async function main() {
|
||||
const target = mod.getDetectableObjects({ maxCount: 1 })[0];
|
||||
if (!target?.position) return null;
|
||||
|
||||
const sentinel = gev.viewer.entities.add({
|
||||
const native = Boolean(gev.viewer);
|
||||
const sentinel = native ? gev.viewer.entities.add({
|
||||
id: 'qa-firms-prior-camera-owner',
|
||||
position: target.position,
|
||||
});
|
||||
gev.viewer.trackedEntity = sentinel;
|
||||
}) : gev.engine.camera.follow({position:()=>target.position,viewFrom:{x:0,y:-5000,z:8000},
|
||||
label:{id:'qa-firms-prior-camera-owner',model:{title:'QA prior camera owner'},position:()=>target.position}});
|
||||
if (native) gev.viewer.trackedEntity = sentinel;
|
||||
|
||||
const camera = gev.viewer.camera;
|
||||
const originalFly = camera.flyToBoundingSphere;
|
||||
const camera = native ? gev.viewer.camera : gev.engine.camera;
|
||||
const flightMethod = native ? 'flyToBoundingSphere' : 'flyToSphere';
|
||||
const originalFly = camera[flightMethod];
|
||||
const proof = {
|
||||
sentinel,
|
||||
originalFly,
|
||||
flightMethod,
|
||||
native,
|
||||
flightCount: 0,
|
||||
trackedAtFlight: 'not-called',
|
||||
request: null,
|
||||
@@ -409,9 +454,9 @@ async function main() {
|
||||
};
|
||||
};
|
||||
window.addEventListener('gev:world-request-focus', proof.onRequest);
|
||||
camera.flyToBoundingSphere = function qaFirmsObservedFlight(...args) {
|
||||
camera[flightMethod] = function qaFirmsObservedFlight(...args) {
|
||||
proof.flightCount += 1;
|
||||
proof.trackedAtFlight = gev.viewer.trackedEntity?.id || null;
|
||||
proof.trackedAtFlight = native ? gev.viewer.trackedEntity?.id || null : gev.engine.camera.following()?.label?.id || null;
|
||||
return originalFly.apply(this, args);
|
||||
};
|
||||
window.__qaFirmsActionProof = proof;
|
||||
@@ -433,7 +478,7 @@ async function main() {
|
||||
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
const settled = await page.evaluate(() => {
|
||||
window.__godsEyeView?.viewer?.scene?.requestRender?.();
|
||||
window.__godsEyeView?.engine?.render?.request?.('firms-legacy');
|
||||
return (window.__qaFirmsActionProof?.flightCount || 0) > 0;
|
||||
});
|
||||
if (settled) break;
|
||||
@@ -451,7 +496,7 @@ async function main() {
|
||||
const result = {
|
||||
flightCount: state?.flightCount || 0,
|
||||
trackedAtFlight: state?.trackedAtFlight,
|
||||
trackingReleased: gev.viewer.trackedEntity == null,
|
||||
trackingReleased: gev.viewer ? gev.viewer.trackedEntity == null : gev.engine.camera.following() == null,
|
||||
generationBefore: state?.generationBefore,
|
||||
generationAfter: gev.styleManager._navigationGeneration,
|
||||
request: state?.request || null,
|
||||
@@ -461,9 +506,12 @@ async function main() {
|
||||
).length,
|
||||
};
|
||||
if (state) {
|
||||
gev.viewer.camera.flyToBoundingSphere = state.originalFly;
|
||||
(state.native ? gev.viewer.camera : gev.engine.camera)[state.flightMethod] = state.originalFly;
|
||||
window.removeEventListener('gev:world-request-focus', state.onRequest);
|
||||
if (state.sentinel) gev.viewer.entities.remove(state.sentinel);
|
||||
if (state.sentinel) {
|
||||
if (state.native) gev.viewer.entities.remove(state.sentinel);
|
||||
else state.sentinel.stop();
|
||||
}
|
||||
}
|
||||
delete window.__qaFirmsActionProof;
|
||||
return result;
|
||||
@@ -513,12 +561,21 @@ async function main() {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
record('RUNTIME: no unexpected browser errors', runtimeErrors.length === 0,
|
||||
runtimeErrors.length ? JSON.stringify(runtimeErrors) : `expected source errors=${expectedSourceErrors.length}`);
|
||||
if (runtimeErrors.length) exitCode = 1;
|
||||
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');
|
||||
if (OUT) {
|
||||
const file = path.resolve(OUT);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, JSON.stringify({ pass:exitCode === 0 && fail === 0, fixture:FIXTURES, angle:ANGLE, engine:ENGINE,
|
||||
shots:SHOTS_DIR, results, browserErrors:runtimeErrors, expectedSourceErrors }, null, 2) + '\n');
|
||||
}
|
||||
process.exit(exitCode || (fail > 0 ? 1 : 0));
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ const SITE = {
|
||||
lon: Number.isFinite(Number(argv.lon)) ? Number(argv.lon) : -97.6660,
|
||||
};
|
||||
const ICAO = 'aa0f01';
|
||||
const OUT_DIR = 'qa-shots/floorhold';
|
||||
const OUT_DIR = new URL(APP_URL).searchParams.get('engine') === 'navara' ? 'qa-shots/floorhold/navara' : 'qa-shots/floorhold';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const results = [];
|
||||
@@ -107,7 +107,7 @@ await page.evaluateOnNewDocument((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,
|
||||
await page.waitForFunction(() => window.__godsEyeView?.layers && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 150000 });
|
||||
await sleep(12000); // let the boot fly-to settle before pinning
|
||||
await page.evaluate(() => {
|
||||
@@ -116,8 +116,19 @@ await page.evaluate(() => {
|
||||
await sleep(600); // let the launcher dismissal finish before taking evidence
|
||||
|
||||
await page.evaluate((site) => { window.__SITE = site; }, SITE);
|
||||
const pin = () => page.evaluate(() => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const pin = () => page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
if (!app.viewer) {
|
||||
const geo = await import('/src/geo/index.js');
|
||||
app.engine.camera.cancelFlight();
|
||||
app.engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(window.__SITE.lon, window.__SITE.lat - 0.008, 900),
|
||||
orientation: { heading: 0, pitch: -0.45, roll: 0 },
|
||||
});
|
||||
app.engine.camera.moveEnd.raiseEvent();
|
||||
return;
|
||||
}
|
||||
const v = app.viewer;
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
v.camera.cancelFlight();
|
||||
v.camera.setView({
|
||||
@@ -142,7 +153,24 @@ record('the billboard floor test is explicitly in 2D aircraft mode', billboardMo
|
||||
|
||||
/** Reads the contact's rendered height and the rendered mesh beneath it. */
|
||||
const measure = () => page.evaluate(async (icao) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const app = window.__godsEyeView;
|
||||
if (!app.viewer) {
|
||||
const geo = await import('/src/geo/index.js');
|
||||
const bb = app.layers.drawn('flights').find((row) => row.id === icao && !row.trackedModel);
|
||||
if (!bb?.position) return { error: 'contact billboard not found' };
|
||||
const carto = geo.cartographic.fromCartesian(bb.position);
|
||||
let mesh = null;
|
||||
for (let attempt = 0; attempt < 10 && mesh == null; attempt++) {
|
||||
app.engine.render.request('floor-hold');
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const height = app.engine.surface.sampleHeight(carto);
|
||||
if (Number.isFinite(height)) mesh = height;
|
||||
else await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
}
|
||||
return { spriteH: carto.height, meshH: mesh, shown: bb.show === true,
|
||||
lat: +(carto.latitude * 180 / Math.PI).toFixed(5), lon: +(carto.longitude * 180 / Math.PI).toFixed(5), terrain: { ...window.__TERRAIN } };
|
||||
}
|
||||
const v = app.viewer;
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
const sprites = []; const models = []; let bb = null;
|
||||
const walk = (coll) => {
|
||||
@@ -201,7 +229,9 @@ async function demTruth(lat, lon) {
|
||||
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)}`);
|
||||
const url = new URL('/api/terrain/heights', APP_URL);
|
||||
url.searchParams.set('points', `${lon.toFixed(5)},${lat.toFixed(5)}`);
|
||||
const res = await fetch(url);
|
||||
if (res.ok) {
|
||||
const v = Number((await res.json())?.results?.[0]?.ellipsoid);
|
||||
if (Number.isFinite(v)) h = v;
|
||||
|
||||
@@ -10,7 +10,12 @@ import fs from 'node:fs';
|
||||
|
||||
// QA_BASE_URL matches the sibling harnesses (qa-height-datum / qa-cctv-v2) so a
|
||||
// secondary checkout can verify against its own dev server instead of the default :4173.
|
||||
const APP_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
// --engine=cesium|navara: on Navara the camera, the mesh probes and
|
||||
// the sprite census go through the engine and the QA probe.
|
||||
const ENGINE = (process.argv.find((arg) => arg.startsWith('--engine=')) ?? '--engine=cesium').slice(9);
|
||||
const APP_URL =
|
||||
ENGINE === 'cesium' ? BASE_URL : `${BASE_URL.replace(/\/$/, '')}/?engine=${ENGINE}`;
|
||||
// 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 = {
|
||||
@@ -32,13 +37,13 @@ const chrome = [
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new', executablePath: chrome,
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist', '--no-sandbox'],
|
||||
args: ['--use-gl=angle', `--use-angle=${process.env.QA_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 page.waitForFunction(() => window.__godsEyeView?.dataManager && window.__godsEyeView?.engine, { timeout: 150000 });
|
||||
await sleep(2000);
|
||||
|
||||
// Let the app's boot fly-to fully settle FIRST (it was overriding an early
|
||||
@@ -46,8 +51,16 @@ await sleep(2000);
|
||||
// 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 camPin = async () => page.evaluate(async () => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
if (!v) {
|
||||
const geo = await import('/src/geo/index.js');
|
||||
const { camera } = window.__godsEyeView.engine;
|
||||
camera.cancelFlight();
|
||||
camera.setView({ destination: geo.cartesian3.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 3000) });
|
||||
const c = camera.pose().cartographic;
|
||||
return { lat: +(c.latitude * 180 / Math.PI).toFixed(3), lon: +(c.longitude * 180 / Math.PI).toFixed(3), h: Math.round(c.height) };
|
||||
}
|
||||
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)) });
|
||||
@@ -63,6 +76,7 @@ await sleep(100000);
|
||||
console.log('camera at probe time:', JSON.stringify(await camPin()));
|
||||
const ready = await page.evaluate(() => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
if (!v) return { tilesLoaded: window.__godsEyeView.engine.surface.tilesLoaded() };
|
||||
const prims = v.scene.primitives;
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
const p = prims.get(i);
|
||||
@@ -73,11 +87,60 @@ const ready = await page.evaluate(() => {
|
||||
console.log('tileset ready:', JSON.stringify(ready));
|
||||
await sleep(8000);
|
||||
|
||||
const report = await page.evaluate(() => {
|
||||
const report = await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
const v = gev.viewer;
|
||||
const layer = gev.dataManager.layers.get('flights')?.module;
|
||||
if (!layer) return { err: 'no flights module' };
|
||||
if (!v) {
|
||||
// The engine path: mesh probes through engine.surface, excluding the
|
||||
// layer's own mover sets; the census from the probe's drawn().
|
||||
const geo = await import('/src/geo/index.js');
|
||||
const { engine } = gev;
|
||||
const center = geo.cartesian3.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 200);
|
||||
const nearby = layer.getNearby(center, 15000, 60);
|
||||
const exclude = engine.movers.list().map(({ id }) => id).filter((id) => id.startsWith('flights'));
|
||||
// The app's own ground floor (the scene's surface services, not a fresh
|
||||
// import of the module).
|
||||
const { cachedGroundFloor } = gev.surfaceServices.groundFloor;
|
||||
const out = [];
|
||||
// A contact a 3D model draws is measured where the model is (its sprite
|
||||
// is hidden and keeps the pre-floor height by design, T7).
|
||||
const drawnRows = new Map((gev.layers.drawn('flights') ?? []).map((row) => [row.id, row]));
|
||||
const drawnAt = (p) => {
|
||||
const model = drawnRows.get(p.icao24 ?? p.id)?.model;
|
||||
return model?.show && model.ready && model.position ? model.position : null;
|
||||
};
|
||||
for (const p of nearby) {
|
||||
const modelAt = drawnAt(p);
|
||||
const c = geo.cartographic.fromCartesian(modelAt ?? p.position);
|
||||
const answer = engine.surface.sampleHeightDetailed(
|
||||
geo.cartographic.fromRadians(c.longitude, c.latitude, 0),
|
||||
{ exclude },
|
||||
);
|
||||
const meshH = answer?.source === 'tiles' && Number.isFinite(answer.height) ? answer.height : null;
|
||||
out.push({
|
||||
id: p.id,
|
||||
renderAltM: +c.height.toFixed(1),
|
||||
drawnBy: modelAt ? 'model' : 'sprite',
|
||||
meshM: meshH != null ? +meshH.toFixed(1) : null,
|
||||
aboveMeshM: meshH != null ? +(c.height - meshH).toFixed(1) : null,
|
||||
// Why a contact sits where it does: the probe's own
|
||||
// answer (source, tile size) and the coarse DEM floor (the prior).
|
||||
probe: answer ? { source: answer.source, tileSizeM: answer.tileSizeM ?? null } : null,
|
||||
demFloorM: cachedGroundFloor(c.latitude * 180 / Math.PI, c.longitude * 180 / Math.PI) ?? null,
|
||||
});
|
||||
}
|
||||
let shown = 0, hidden = 0;
|
||||
for (const row of gev.layers.drawn('flights') ?? []) {
|
||||
if (!row.position) continue;
|
||||
const cc = geo.cartographic.fromCartesian(row.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 (row.show) shown++; else hidden++;
|
||||
}
|
||||
return { ausContacts: out.length, contacts: out.slice(0, 16), spritesShown: shown, spritesHidden: hidden };
|
||||
}
|
||||
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));
|
||||
@@ -101,9 +164,15 @@ const report = await page.evaluate(() => {
|
||||
}
|
||||
};
|
||||
walk(v.scene.primitives);
|
||||
const drawnRows = new Map((gev.layers?.drawn('flights') ?? []).map((row) => [row.id, row]));
|
||||
const drawnAt = (p) => {
|
||||
const model = drawnRows.get(p.icao24 ?? p.id)?.model;
|
||||
return model?.show && model.ready && model.position ? model.position : null;
|
||||
};
|
||||
const out = [];
|
||||
for (const p of nearby) {
|
||||
const c = ell.cartesianToCartographic(p.position);
|
||||
const modelAt = drawnAt(p);
|
||||
const c = ell.cartesianToCartographic(modelAt ?? p.position);
|
||||
const latDeg = c.latitude * 180 / Math.PI, lonDeg = c.longitude * 180 / Math.PI;
|
||||
let meshH = null;
|
||||
try {
|
||||
@@ -113,8 +182,16 @@ const report = await page.evaluate(() => {
|
||||
out.push({
|
||||
id: p.id,
|
||||
renderAltM: +c.height.toFixed(1),
|
||||
drawnBy: modelAt ? 'model' : 'sprite',
|
||||
meshM: meshH != null ? +meshH.toFixed(1) : null,
|
||||
aboveMeshM: meshH != null ? +(c.height - meshH).toFixed(1) : null,
|
||||
// A contact whose sprite hides under a model that is not (yet) drawn.
|
||||
model: drawnRows.get(p.icao24 ?? p.id)?.model
|
||||
? { show: drawnRows.get(p.icao24 ?? p.id).model.show, ready: drawnRows.get(p.icao24 ?? p.id).model.ready }
|
||||
: null,
|
||||
spriteShown: drawnRows.get(p.icao24 ?? p.id)?.show ?? null,
|
||||
// The coarse floor (mesh cell ?? DEM prior) the layer reads here.
|
||||
demFloorM: gev.surfaceServices?.groundFloor.cachedGroundFloor(latDeg, lonDeg) ?? null,
|
||||
});
|
||||
}
|
||||
// Visibility census (round 6): getNearby only returns contacts a sprite or a
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* --headful drops those flags and is the real-GPU visual sign-off path.
|
||||
*/
|
||||
|
||||
import { redact } from './qa-browserEvidence.mjs';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -29,7 +30,13 @@ const getOpt = (name, fallback = null) => {
|
||||
};
|
||||
const hasFlag = (name) => argv.includes(name);
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const appUrl = new URL(getOpt('--url', 'http://localhost:4173'));
|
||||
const requestedEngine = argv.find((value) => value.startsWith('--engine='))?.slice(9) || getOpt('--engine');
|
||||
if (requestedEngine) {
|
||||
if (!['cesium', 'navara'].includes(requestedEngine)) throw new Error('Unknown engine');
|
||||
appUrl.searchParams.set('engine', requestedEngine);
|
||||
}
|
||||
const APP_URL = appUrl.href;
|
||||
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');
|
||||
@@ -125,6 +132,10 @@ async function readTileReadiness(page) {
|
||||
const activeStack = controller?.getActiveId?.() || null;
|
||||
const tileset = controller?.googleTileset || gev.tileset || null;
|
||||
const applicable = activeStack === 'photoreal' && tileset?.show !== false;
|
||||
if (!gev.viewer) return {
|
||||
tilesSettled: null, tileSettleApplicable: false, activeStack,
|
||||
reason: 'Navara exposes view idle, not tile readiness independently of animated layers',
|
||||
};
|
||||
return {
|
||||
tilesSettled: !applicable || tileset?.tilesLoaded === true,
|
||||
tileSettleApplicable: applicable,
|
||||
@@ -142,6 +153,10 @@ async function awaitTilesSettled(page, scenario) {
|
||||
// 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 (!gev.viewer) return {
|
||||
tilesSettled: null, tileSettleApplicable: false, activeStack,
|
||||
reason: 'Navara exposes view idle, not tile readiness independently of animated layers',
|
||||
};
|
||||
if (!applicable) {
|
||||
return {
|
||||
tilesSettled: true,
|
||||
@@ -166,7 +181,8 @@ async function awaitTilesSettled(page, scenario) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
gev.viewer.scene.render(gev.viewer.clock.currentTime);
|
||||
if (gev.viewer) gev.viewer.scene.render(gev.viewer.clock.currentTime);
|
||||
else gev.engine.render.request('focus-evidence');
|
||||
if (tileset.tilesLoaded === true || allTilesLoadedObserved) {
|
||||
return {
|
||||
tilesSettled: true,
|
||||
@@ -203,7 +219,7 @@ async function capture(page, scenario, frame) {
|
||||
// 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')
|
||||
window.__godsEyeView.engine.host.canvas.toDataURL('image/png')
|
||||
));
|
||||
fs.writeFileSync(out, Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64'));
|
||||
return { file: out, ...tileReadiness };
|
||||
@@ -232,6 +248,14 @@ async function advanceEvidenceFrame(page, deltaMs = FRAME_MS) {
|
||||
const viewer = gev.viewer;
|
||||
const seam = gev.dataManager.layers.get('flights').module.__focusEvidence;
|
||||
const nowMs = seam.advanceFrameClock(stepMs);
|
||||
if (!viewer) return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => { remove(); reject(new Error('Evidence frame timeout')); }, 5000);
|
||||
const remove = gev.engine.frames.postRender.addEventListener(() => {
|
||||
remove(); clearTimeout(timer);
|
||||
setTimeout(() => resolve(nowMs), 0);
|
||||
});
|
||||
gev.engine.render.request('focus-evidence');
|
||||
});
|
||||
const JulianDate = viewer.clock.currentTime.constructor;
|
||||
JulianDate.addSeconds(viewer.clock.currentTime, stepMs / 1000, viewer.clock.currentTime);
|
||||
viewer.dataSourceDisplay?.update(viewer.clock.currentTime);
|
||||
@@ -266,6 +290,7 @@ async function installSyntheticFetches(page) {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}));
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
window.fetch = (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input?.url || '';
|
||||
if (url.includes('/api/flights/track')) return json({ path: [] });
|
||||
@@ -281,9 +306,10 @@ async function installSyntheticFetches(page) {
|
||||
async function waitForApp(page) {
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
await page.waitForFunction(
|
||||
() => Boolean(window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager),
|
||||
() => Boolean(window.__godsEyeView?.layers && window.__godsEyeView?.dataManager),
|
||||
{ timeout: 60_000, polling: 200 },
|
||||
);
|
||||
await page.evaluate(async () => { window.__focusGeo = await import('/src/geo/index.js'); });
|
||||
}
|
||||
|
||||
async function setBasemap(page, stackId) {
|
||||
@@ -317,7 +343,18 @@ async function requireEvidenceSeams(page) {
|
||||
|
||||
async function setCamera(page, values) {
|
||||
await page.evaluate(([lon, lat, height, heading, pitch]) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const app = window.__godsEyeView;
|
||||
if (!app.viewer) {
|
||||
const geo = window.__focusGeo;
|
||||
app.engine.camera.following()?.stop();
|
||||
app.engine.camera.cancelFlight();
|
||||
app.engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(lon, lat, height),
|
||||
orientation: { heading, pitch, roll: 0 },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const viewer = app.viewer;
|
||||
const ellipsoid = viewer.scene.globe.ellipsoid;
|
||||
viewer.trackedEntity = undefined;
|
||||
viewer.camera.cancelFlight?.();
|
||||
@@ -345,7 +382,7 @@ async function injectAndTrackTarget(page, extraAircraft = [], target = TARGET) {
|
||||
throw new Error(`Synthetic target setup failed: ${JSON.stringify(result)}`);
|
||||
}
|
||||
await page.waitForFunction(
|
||||
() => Boolean(window.__godsEyeView.viewer.trackedEntity),
|
||||
() => Boolean(window.__godsEyeView.engine.camera.following()),
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await advanceEvidenceDuration(page, 400);
|
||||
@@ -356,7 +393,8 @@ async function readTrackedBasis(page) {
|
||||
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;
|
||||
const pose = gev.engine.camera.pose();
|
||||
const camera = gev.viewer?.camera ?? { positionWC: pose.position, rightWC: pose.right, upWC: pose.up, frustum: { fovy: pose.fovy } };
|
||||
if (!target) throw new Error('Tracked target position unavailable');
|
||||
const toTarget = {
|
||||
x: target.x - camera.positionWC.x,
|
||||
@@ -364,7 +402,7 @@ async function readTrackedBasis(page) {
|
||||
z: target.z - camera.positionWC.z,
|
||||
};
|
||||
const distance = Math.hypot(toTarget.x, toTarget.y, toTarget.z);
|
||||
const canvasHeight = gev.viewer.scene.canvas.clientHeight;
|
||||
const canvasHeight = gev.engine.host.size().height;
|
||||
const metresPerPixel = (2 * distance * Math.tan(camera.frustum.fovy / 2)) / canvasHeight;
|
||||
return {
|
||||
target: [target.x, target.y, target.z],
|
||||
@@ -427,8 +465,8 @@ async function vesselRowsAroundTarget(page) {
|
||||
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 Cartographic = { fromCartesian: window.__focusGeo.cartographic.fromCartesian };
|
||||
const ellipsoid = window.__focusGeo.ellipsoid.WGS84;
|
||||
const rows = [];
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
const angle = (i / 12) * Math.PI * 2;
|
||||
@@ -624,7 +662,9 @@ async function browserContext(page, browser) {
|
||||
},
|
||||
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.',
|
||||
: process.env.QA_ANGLE === 'metal'
|
||||
? 'Headless ANGLE/Metal capture; renderer and DPR recorded above.'
|
||||
: 'SwiftShader/headless output is relative-only.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -641,7 +681,7 @@ async function main() {
|
||||
headless: HEADFUL ? false : 'new',
|
||||
protocolTimeout: 300000,
|
||||
args: [
|
||||
...(HEADFUL ? [] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']),
|
||||
...(HEADFUL ? [] : process.env.QA_ANGLE ? ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE}`] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']),
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
@@ -655,10 +695,10 @@ async function main() {
|
||||
const consoleMessages = [];
|
||||
page.on('console', (message) => {
|
||||
if (['error', 'warning', 'warn'].includes(message.type())) {
|
||||
consoleMessages.push({ type: message.type(), text: message.text() });
|
||||
consoleMessages.push({ type: message.type(), text: redact(message.text()) });
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => consoleMessages.push({ type: 'pageerror', text: error.message }));
|
||||
page.on('pageerror', (error) => consoleMessages.push({ type: 'pageerror', text: redact(error.message) }));
|
||||
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
|
||||
+291
-21
@@ -1,19 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Keyless station-name regression against the real globe and bike-share tool.
|
||||
* Run with a dev or preview server: node scripts/qa-gbfs-names.mjs --url http://localhost:4173
|
||||
* Run with a dev or QA preview server and an explicitly assigned --url.
|
||||
* GBFS documents are synthetic; no operator feed or provider key is required.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import sharp from 'sharp';
|
||||
import { watchW1Page, redact } from './qa-browserEvidence.mjs';
|
||||
|
||||
const urlIndex = process.argv.indexOf('--url');
|
||||
const appUrl =
|
||||
urlIndex < 0 ? 'http://localhost:4173' : process.argv[urlIndex + 1];
|
||||
const shots = path.resolve('qa-shots/gbfs-names');
|
||||
if (urlIndex < 0) throw new Error('An explicit --url is required');
|
||||
const appUrl = process.argv[urlIndex + 1];
|
||||
const engineName = new URL(appUrl).searchParams.get('engine') || 'cesium';
|
||||
const extended = process.argv.includes('--w2');
|
||||
const reportIndex = process.argv.indexOf('--report');
|
||||
const reportPath =
|
||||
reportIndex < 0 ? null : path.resolve(process.argv[reportIndex + 1]);
|
||||
const shots = reportPath
|
||||
? reportPath.replace(/\.json$/, '-shots')
|
||||
: path.resolve(`qa-shots/gbfs-names/${engineName}`);
|
||||
fs.mkdirSync(shots, { recursive: true });
|
||||
const report = { engine: engineName, checks: [], poses: {}, browserErrors: [] };
|
||||
const information = {
|
||||
version: '3.0',
|
||||
data: {
|
||||
@@ -67,6 +77,7 @@ const browser = await puppeteer.launch({
|
||||
});
|
||||
let checks = 0;
|
||||
const check = (name, actual, expected) => {
|
||||
report.checks.push({ name, actual, expected });
|
||||
assert.deepEqual(actual, expected, name);
|
||||
checks++;
|
||||
console.log(`[PASS] ${name}`);
|
||||
@@ -75,6 +86,12 @@ async function open(mode = 'normal') {
|
||||
const page = await browser.newPage();
|
||||
await page.bringToFront();
|
||||
const errors = [];
|
||||
if (extended)
|
||||
watchW1Page(page, report, {
|
||||
name: mode,
|
||||
baseUrl: appUrl,
|
||||
preview: process.argv.includes('--preview'),
|
||||
});
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.evaluateOnNewDocument(
|
||||
@@ -134,15 +151,17 @@ async function open(mode = 'normal') {
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const { dataManager, viewer } = window.__godsEyeView;
|
||||
const { dataManager, engine } = window.__godsEyeView;
|
||||
await window.__godsEyeView.styleManager.initialRestorePromise;
|
||||
window.__godsEyeView.styleManager.orbitController.stop();
|
||||
for (const [id, layer] of dataManager.layers) {
|
||||
if (layer.enabled)
|
||||
await dataManager.setEnabled(id, false, { origin: 'user' });
|
||||
}
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
|
||||
const geo = await import('/src/geo/index.js');
|
||||
engine.camera.cancelFlight();
|
||||
engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
await dataManager.setEnabled('bikeshare', true, { origin: 'user' });
|
||||
@@ -150,6 +169,211 @@ async function open(mode = 'normal') {
|
||||
});
|
||||
return { page, errors };
|
||||
}
|
||||
|
||||
async function nextFrame(page) {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
const engine = window.__godsEyeView.engine;
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('gbfs-raster-qa');
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function settledTiles(page) {
|
||||
await page.evaluate(async () => {
|
||||
const engine = window.__godsEyeView.engine;
|
||||
const start = performance.now();
|
||||
let since = null;
|
||||
while (performance.now() - start < 90000) {
|
||||
if (engine.surface.tilesLoaded()) since ??= performance.now();
|
||||
else since = null;
|
||||
if (since !== null && performance.now() - since > 1500) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error('Map tiles did not settle for raster sampling');
|
||||
});
|
||||
}
|
||||
|
||||
async function w2Checks(page) {
|
||||
await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
const detection = await import('/src/data/detection.js');
|
||||
detection.setMode('OFF');
|
||||
const { appClock } = await import('/src/time/clock.js');
|
||||
appClock.freeze(Date.parse('2026-10-28T19:00:00Z'));
|
||||
const canvas = app.engine.host.canvas;
|
||||
const style = document.createElement('style');
|
||||
style.textContent =
|
||||
'.gbfs-raster-hidden { visibility: hidden !important; }';
|
||||
document.head.append(style);
|
||||
for (const element of document.body.querySelectorAll('*'))
|
||||
if (element !== canvas && !element.contains(canvas))
|
||||
element.classList.add('gbfs-raster-hidden');
|
||||
});
|
||||
for (const [name, height] of [
|
||||
['city', 10000],
|
||||
['close', 3000],
|
||||
['global', 100000],
|
||||
]) {
|
||||
await page.evaluate(async (height) => {
|
||||
const app = window.__godsEyeView;
|
||||
await app.dataManager.setEnabled('bikeshare', false);
|
||||
const geo = await import('/src/geo/index.js');
|
||||
app.engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, height),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
}, height);
|
||||
await settledTiles(page);
|
||||
await nextFrame(page);
|
||||
const off = await page.screenshot({
|
||||
path: path.join(shots, `${name}-off.png`),
|
||||
});
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('bikeshare', true),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
(count) =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getStats().count === count,
|
||||
{},
|
||||
name === 'global' ? 0 : 5,
|
||||
);
|
||||
await nextFrame(page);
|
||||
const controls = await page.evaluate(() => {
|
||||
const app = window.__godsEyeView;
|
||||
const rect = app.engine.host.canvas.getBoundingClientRect();
|
||||
return app.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getDetectableObjects()
|
||||
.map((record) => {
|
||||
const p = app.engine.projector.worldToWindow(record.position);
|
||||
return { id: record.sourceId, x: p.x + rect.left, y: p.y + rect.top };
|
||||
});
|
||||
});
|
||||
const on = await page.screenshot({
|
||||
path: path.join(shots, `${name}-on.png`),
|
||||
});
|
||||
const a = await sharp(off)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const b = await sharp(on).ensureAlpha().raw().toBuffer();
|
||||
for (const control of controls) {
|
||||
let pixels = 0;
|
||||
for (
|
||||
let y = Math.max(0, Math.floor(control.y) - 16);
|
||||
y < Math.min(a.info.height, control.y + 16);
|
||||
y++
|
||||
)
|
||||
for (
|
||||
let x = Math.max(0, Math.floor(control.x) - 16);
|
||||
x < Math.min(a.info.width, control.x + 16);
|
||||
x++
|
||||
) {
|
||||
const i = (y * a.info.width + x) * 4;
|
||||
if (
|
||||
Math.max(
|
||||
...[0, 1, 2].map((c) => Math.abs(a.data[i + c] - b[i + c])),
|
||||
) > 3
|
||||
)
|
||||
pixels++;
|
||||
}
|
||||
control.changedPixels = pixels;
|
||||
}
|
||||
report.poses[name] = controls;
|
||||
check(
|
||||
`${name} expected station population`,
|
||||
controls.length,
|
||||
name === 'global' ? 0 : 5,
|
||||
);
|
||||
if (controls.length)
|
||||
check(
|
||||
`${name} five nonempty marker raster controls`,
|
||||
controls.every((c) => c.changedPixels > 0),
|
||||
true,
|
||||
);
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
for (const element of document.querySelectorAll('.gbfs-raster-hidden'))
|
||||
element.classList.remove('gbfs-raster-hidden');
|
||||
});
|
||||
await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
const geo = await import('/src/geo/index.js');
|
||||
app.engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
await app.dataManager.setEnabled('bikeshare', false);
|
||||
});
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('bikeshare', true),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getStats().count === 5,
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('bikeshare', false),
|
||||
);
|
||||
}
|
||||
check(
|
||||
'ten cycles clear all records',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getStats().count,
|
||||
),
|
||||
0,
|
||||
);
|
||||
const idle = await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
let frames = 0,
|
||||
last = performance.now();
|
||||
const remove = app.engine.frames.postRender.addEventListener(() => {
|
||||
frames++;
|
||||
last = performance.now();
|
||||
});
|
||||
const start = performance.now();
|
||||
while (
|
||||
performance.now() - start < 60000 &&
|
||||
(performance.now() - last < 2000 || !app.engine.surface.tilesLoaded())
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const ready =
|
||||
performance.now() - last >= 2000 && app.engine.surface.tilesLoaded();
|
||||
frames = 0;
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
remove();
|
||||
return { ready, frames, governor: app.getRenderGovernorDiagnostics() };
|
||||
});
|
||||
report.idle = idle;
|
||||
check('camera and tiles settle before idle sample', idle.ready, true);
|
||||
check(
|
||||
'disabled static scene renders zero frames over two seconds',
|
||||
idle.frames,
|
||||
0,
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('bikeshare', true),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers.get('bikeshare').module.getStats()
|
||||
.count === 5,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const { page, errors } = await open();
|
||||
await page.waitForFunction(
|
||||
@@ -158,6 +382,17 @@ try {
|
||||
.count === 5,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const rendered = new Promise((resolve) => {
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
window.__godsEyeView.requestRender('gbfs-name-pick-qa');
|
||||
await rendered;
|
||||
});
|
||||
const records = await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
@@ -198,17 +433,39 @@ try {
|
||||
),
|
||||
true,
|
||||
);
|
||||
if (extended) await w2Checks(page);
|
||||
const clickPoint = await page.evaluate(() => {
|
||||
window.__qaPainted = [];
|
||||
const { viewer } = window.__godsEyeView;
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
const point = viewer.scene.cartesianToCanvasCoordinates(
|
||||
Cartesian3.fromDegrees(-97.7469, 30.2681),
|
||||
);
|
||||
const rect = viewer.scene.canvas.getBoundingClientRect();
|
||||
return { x: rect.left + point.x, y: rect.top + point.y };
|
||||
const { engine } = window.__godsEyeView;
|
||||
const station = window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getDetectableObjects()
|
||||
.find(({ sourceId }) => sourceId === 'austin-capmetro:english');
|
||||
const point = engine.projector.worldToWindow(station.position);
|
||||
const rect = engine.host.canvas.getBoundingClientRect();
|
||||
const picked = engine.picker.pick(point);
|
||||
return {
|
||||
x: rect.left + point.x,
|
||||
y: rect.top + point.y,
|
||||
pickedId: typeof picked?.id === 'string' ? picked.id : null,
|
||||
primitiveId:
|
||||
typeof picked?.primitive?.id === 'string' ? picked.primitive.id : null,
|
||||
};
|
||||
});
|
||||
check(
|
||||
'the scene pick resolves the first stable station key',
|
||||
clickPoint.pickedId || clickPoint.primitiveId,
|
||||
'austin-capmetro:english',
|
||||
);
|
||||
await page.mouse.click(clickPoint.x, clickPoint.y);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getDetectableObjects()
|
||||
.some((record) => record.skipLabel),
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__qaPainted.includes('QA English Square'),
|
||||
{ timeout: 10000 },
|
||||
@@ -263,15 +520,15 @@ try {
|
||||
);
|
||||
}
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
await page.evaluate(() => {
|
||||
await page.evaluate(async () => {
|
||||
window.__qaPainted = [];
|
||||
const app = window.__godsEyeView;
|
||||
// Use the supported clean view to inspect the mobile card clear of panels.
|
||||
app.styleManager.toggleCleanView(true);
|
||||
const Cartesian3 = app.viewer.camera.position.constructor;
|
||||
app.viewer.camera.cancelFlight();
|
||||
app.viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
|
||||
const geo = await import('/src/geo/index.js');
|
||||
app.engine.camera.cancelFlight();
|
||||
app.engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
app.dataManager.layers
|
||||
@@ -330,7 +587,20 @@ try {
|
||||
check(`${mode} produces no page exceptions`, errors, []);
|
||||
await page.close();
|
||||
}
|
||||
if (extended)
|
||||
check(
|
||||
'all pages have no unexpected runtime errors',
|
||||
report.browserErrors,
|
||||
[],
|
||||
);
|
||||
report.pass = true;
|
||||
console.log(`GBFS names: ${checks}/${checks} checks passed; ${appUrl}`);
|
||||
} catch (error) {
|
||||
report.pass = false;
|
||||
report.failure = redact(error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
if (reportPath)
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n');
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
openPage,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0&look=cinematic';
|
||||
base.hash =
|
||||
'v=2&lat=43.6500&lon=-79.3800&alt=46000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
|
||||
const strict = createStrict({ origin: base.origin, layers: ['s2-ghostmaps'] });
|
||||
await strict.run({}, async (b) => {
|
||||
const { page: p, errors } = await openPage(b);
|
||||
const file = await readFile(
|
||||
new URL('./fixtures/ghostmaps-scenes.kmz', import.meta.url),
|
||||
);
|
||||
await p.evaluateOnNewDocument(
|
||||
(bytes) => {
|
||||
const fetch = window.fetch.bind(window);
|
||||
const events = window.EventSource;
|
||||
window.ghostStats = { streams: 0, reads: 0, catalogs: 0, titles: [] };
|
||||
window.EventSource = class extends EventTarget {
|
||||
constructor(url, options) {
|
||||
super();
|
||||
if (!url.includes('/api/ghostmaps/events'))
|
||||
return new events(url, options);
|
||||
window.ghostStats.streams++;
|
||||
this.readyState = 1;
|
||||
}
|
||||
close() {}
|
||||
};
|
||||
window.fetch = (input, options) => {
|
||||
const { pathname } = new URL(input.url ?? input, document.baseURI);
|
||||
if (pathname === '/api/ghostmaps/catalog') {
|
||||
window.ghostStats.catalogs++;
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
commit: 'qa',
|
||||
products: [
|
||||
{ id: 'cip', version: 'qa', title: 'QA', defaultOn: true },
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (pathname.startsWith('/api/ghostmaps/file/')) {
|
||||
window.ghostStats.reads++;
|
||||
return Promise.resolve(new Response(new Uint8Array(bytes)));
|
||||
}
|
||||
return fetch(input, options);
|
||||
};
|
||||
const fill = CanvasRenderingContext2D.prototype.fillText;
|
||||
CanvasRenderingContext2D.prototype.fillText = function (value, ...args) {
|
||||
if (/QA (pin|route|zone)/i.test(value))
|
||||
window.ghostStats.titles.push(value);
|
||||
return fill.call(this, value, ...args);
|
||||
};
|
||||
},
|
||||
[...file],
|
||||
);
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(async () => {
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
await dataManager.setEnabled('s2-ghostmaps', true, { origin: 'user' });
|
||||
window.qaGhost = dataManager.layers.get('s2-ghostmaps').module;
|
||||
});
|
||||
const click = async (lon, lat) => {
|
||||
const { x, y } = await p.evaluate(
|
||||
(pos) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const point = engine.projector.worldToWindow(pos, {});
|
||||
const box = engine.host.canvas.getBoundingClientRect();
|
||||
window.ghostStats.titles = [];
|
||||
return { x: box.left + point.x, y: box.top + point.y };
|
||||
},
|
||||
fromDegrees(lon, lat),
|
||||
);
|
||||
await p.mouse.click(x, y);
|
||||
};
|
||||
const overlay = () =>
|
||||
p.evaluate(() => {
|
||||
const canvas = document.getElementById('world-overlay-canvas');
|
||||
const data = canvas
|
||||
.getContext('2d')
|
||||
.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let pixels = 0;
|
||||
for (let i = 3; i < data.length; i += 4) if (data[i]) pixels++;
|
||||
return pixels;
|
||||
});
|
||||
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
|
||||
await p.evaluate(() => {
|
||||
window.ghostStats.titles = [];
|
||||
});
|
||||
await strict.switchTo(p, kind);
|
||||
await p.waitForFunction(() =>
|
||||
window.ghostStats.titles.some((v) => /QA zone/i.test(v)),
|
||||
);
|
||||
}
|
||||
const inventory = await p.evaluate(() => ({
|
||||
same:
|
||||
window.qaGhost ===
|
||||
window.__godsEyeView.dataManager.layers.get('s2-ghostmaps').module,
|
||||
names: window.qaGhost.getAnalystRecords().map((r) => r.name),
|
||||
streams: window.ghostStats.streams,
|
||||
reads: window.ghostStats.reads,
|
||||
}));
|
||||
assert.deepEqual(inventory, {
|
||||
same: true,
|
||||
names: ['QA pin', 'QA route', 'QA zone'],
|
||||
streams: 1,
|
||||
reads: 1,
|
||||
});
|
||||
await strict.passed(
|
||||
p,
|
||||
's2-ghostmaps',
|
||||
'same model, three features, one event stream, one KMZ read',
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2200));
|
||||
for (const [name, lon, lat] of [
|
||||
['QA pin', -79.38, 43.65],
|
||||
['QA route', -79.375, 43.71],
|
||||
['QA zone', -79.28, 43.61],
|
||||
]) {
|
||||
await click(lon, lat);
|
||||
await p.waitForFunction(
|
||||
(name) =>
|
||||
window.ghostStats.titles.some((v) =>
|
||||
v.toLowerCase().includes(name.toLowerCase()),
|
||||
),
|
||||
{ timeout: 12000 },
|
||||
name,
|
||||
);
|
||||
await strict.passed(
|
||||
p,
|
||||
's2-ghostmaps',
|
||||
`a click on ${name} draws its card text`,
|
||||
);
|
||||
console.log('PASS', kind, name, 'pointer hit');
|
||||
}
|
||||
// Not vacuous: the zone's card is up, so the overlay only clears when
|
||||
// the click in the hole is handled as a click on empty ground. The
|
||||
// overlay without any card is measured with a click far from every
|
||||
// feature.
|
||||
const shown = await overlay();
|
||||
await click(-79.316, 43.61);
|
||||
await p.waitForFunction(
|
||||
(shown) => {
|
||||
const canvas = document.getElementById('world-overlay-canvas');
|
||||
const data = canvas
|
||||
.getContext('2d')
|
||||
.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let pixels = 0;
|
||||
for (let i = 3; i < data.length; i += 4) if (data[i]) pixels++;
|
||||
return pixels < shown - 100;
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
shown,
|
||||
);
|
||||
const hole = await overlay();
|
||||
await click(-79.45, 43.55);
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
const empty = await overlay();
|
||||
console.log(
|
||||
kind,
|
||||
'overlay pixels: zone card',
|
||||
shown,
|
||||
'hole',
|
||||
hole,
|
||||
'empty ground',
|
||||
empty,
|
||||
);
|
||||
await strict.check(
|
||||
p,
|
||||
's2-ghostmaps',
|
||||
'a click in the zone hole clears the selected zone card like empty ground',
|
||||
shown > empty + 100 && Math.abs(hole - empty) <= 0.02 * shown,
|
||||
{ shown, hole, empty },
|
||||
);
|
||||
await click(-79.28, 43.61);
|
||||
await p.waitForFunction(() =>
|
||||
window.ghostStats.titles.some((v) => /QA zone/i.test(v)),
|
||||
);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
@@ -0,0 +1,505 @@
|
||||
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||
import sharp from 'sharp';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
openPage,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0&look=cinematic';
|
||||
base.hash =
|
||||
'v=2&lat=43.6500&lon=-79.3800&alt=46000&heading=0&pitch=-90&roll=0&map=sentinel2&l=4';
|
||||
const strict = createStrict({
|
||||
origin: base.origin,
|
||||
layers: [
|
||||
'ais-live-vessels',
|
||||
'air-quality',
|
||||
'gnss-integrity',
|
||||
'fire-perimeters',
|
||||
],
|
||||
});
|
||||
await strict.run({}, async (b) => {
|
||||
const { page: p, errors } = await openPage(b);
|
||||
await p.evaluateOnNewDocument(() => {
|
||||
const fetch = window.fetch.bind(window);
|
||||
window.fetch = (input, o) => {
|
||||
const url = new URL(input.url ?? input, document.baseURI);
|
||||
if (url.pathname === '/api/vessels/track') {
|
||||
window.qaVesselHistories = (window.qaVesselHistories ?? 0) + 1;
|
||||
return Promise.resolve(
|
||||
Response.json({ samples: [{ lat: 43.63, lon: -79.38 }] }),
|
||||
);
|
||||
}
|
||||
if (url.pathname === '/api/vessels') {
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
status: 'live',
|
||||
rows: [
|
||||
{
|
||||
mmsi: '123456789',
|
||||
name: 'Toronto QA',
|
||||
lat: 43.65,
|
||||
lon: -79.38,
|
||||
last_position_epoch: Date.now() / 1000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (url.pathname.startsWith('/api/fire-perimeters/inciweb')) {
|
||||
return Promise.resolve(Response.json({ rows: [] }));
|
||||
}
|
||||
if (url.pathname === '/api/fire-perimeters') {
|
||||
const square = (dx) => [
|
||||
[-79.38 - dx, 43.65 - dx],
|
||||
[-79.38 + dx, 43.65 - dx],
|
||||
[-79.38 + dx, 43.65 + dx],
|
||||
[-79.38 - dx, 43.65 + dx],
|
||||
[-79.38 - dx, 43.65 - dx],
|
||||
];
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
rows: [
|
||||
{
|
||||
stableId: 'fire:fixture',
|
||||
name: 'QA Fire',
|
||||
acres: 500,
|
||||
containedPct: 40,
|
||||
polygons: [[square(0.09), square(0.018).reverse()]],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (url.pathname === '/api/gnss-integrity') {
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
ageMs: 0,
|
||||
rows: ['a1', 'a2', 'a3'].map((hex) => ({
|
||||
hex,
|
||||
lat: 43.65,
|
||||
lon: -79.38,
|
||||
nic: 0,
|
||||
nacp: 0,
|
||||
degraded: true,
|
||||
gpsLost: false,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (url.hostname === 'api.weather.gc.ca') {
|
||||
const records = url.pathname.includes('aqhi-stations')
|
||||
? [
|
||||
{
|
||||
geometry: { type: 'Point', coordinates: [-79.38, 43.65] },
|
||||
properties: {
|
||||
location_id: 'QA1',
|
||||
location_name_en: 'QA Toronto',
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
properties: {
|
||||
location_id: 'QA1',
|
||||
aqhi: 9,
|
||||
observation_datetime: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
];
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: records.map((f) => ({ type: 'Feature', ...f })),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return fetch(input, o);
|
||||
};
|
||||
});
|
||||
await p.goto(base.href);
|
||||
const capture = async (visible, layerId = 'air-quality') => {
|
||||
await p.evaluate(
|
||||
async (show, id) => {
|
||||
const d = window.__godsEyeView;
|
||||
await d.dataManager.setEnabled(id, show, { origin: 'user' });
|
||||
d.engine.render.request('qa-air-quality');
|
||||
},
|
||||
visible,
|
||||
layerId,
|
||||
);
|
||||
// Ready to compare: the layer's data is in (when shown), the globe's
|
||||
// tiles under the view have loaded and stayed loaded, and a frame has
|
||||
// been drawn since.
|
||||
await p
|
||||
.waitForFunction(
|
||||
(show, id) => {
|
||||
const d = window.__godsEyeView;
|
||||
if (!d?.engine) return false;
|
||||
const module = d.dataManager.layers.get(id).module;
|
||||
const data =
|
||||
!show ||
|
||||
(id === 'air-quality'
|
||||
? module.getAnalystRecords().length === 1
|
||||
: module.getStats().levels?.high === 1);
|
||||
const now = performance.now();
|
||||
// The engines draw on request: keep frames coming so tiles load.
|
||||
d.engine.render.request('qa-ground-settle');
|
||||
if (!data || !d.engine.surface.tilesLoaded()) {
|
||||
window.__qaSettledSince = null;
|
||||
return false;
|
||||
}
|
||||
window.__qaSettledSince ??= now;
|
||||
return now - window.__qaSettledSince > 1200;
|
||||
},
|
||||
{ polling: 100, timeout: 30000 },
|
||||
visible,
|
||||
layerId,
|
||||
)
|
||||
.catch(async (error) => {
|
||||
console.log(
|
||||
'not settled',
|
||||
JSON.stringify(
|
||||
await p.evaluate((id) => {
|
||||
const d = window.__godsEyeView;
|
||||
const module = d?.dataManager.layers.get(id).module;
|
||||
return {
|
||||
engine: d?.engine?.kind,
|
||||
tiles: d?.engine?.surface.tilesLoaded(),
|
||||
records: module?.getAnalystRecords().length,
|
||||
stats: module?.getStats(),
|
||||
};
|
||||
}, layerId),
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
// A frame drawn after the settle (the engine draws on request), then
|
||||
// the animation frame that presents it.
|
||||
await p.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
remove();
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
engine.render.request('qa-ground');
|
||||
}),
|
||||
);
|
||||
await p.evaluate(() => (window.__qaSettledSince = null));
|
||||
return sample();
|
||||
};
|
||||
const sample = async () => {
|
||||
const shot = await p.screenshot();
|
||||
return sharp(shot)
|
||||
.extract({ left: 537, top: 403, width: 22, height: 18 })
|
||||
.removeAlpha()
|
||||
.png()
|
||||
.toBuffer()
|
||||
.then((buf) => sharp(buf).stats());
|
||||
};
|
||||
// The layer shown, sampled until its polygon appears in the patch (the
|
||||
// engines draw a clamped polygon a few frames after its data, with no
|
||||
// signal for it), for at most 20 s; the comparison stays the same.
|
||||
const orangeOf = (v) => v.channels[0].mean - v.channels[2].mean;
|
||||
const drawn = async (off, layerId) => {
|
||||
const deadline = Date.now() + 20000;
|
||||
let on = await capture(true, layerId);
|
||||
while (orangeOf(on) <= orangeOf(off) + 5 && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
on = await sample();
|
||||
}
|
||||
return on;
|
||||
};
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(() => {
|
||||
const d = window.__godsEyeView;
|
||||
if (d.tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Keyless required');
|
||||
window.qaAir = d.dataManager.layers.get('air-quality').module;
|
||||
window.qaGnss = d.dataManager.layers.get('gnss-integrity').module;
|
||||
});
|
||||
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
|
||||
await strict.switchTo(p, kind);
|
||||
const same = await p.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers.get('air-quality').module ===
|
||||
window.qaAir &&
|
||||
window.__godsEyeView.dataManager.layers.get('gnss-integrity')
|
||||
.module === window.qaGnss,
|
||||
);
|
||||
await strict.check(
|
||||
p,
|
||||
['air-quality', 'gnss-integrity'],
|
||||
'same layer models after the switch',
|
||||
same,
|
||||
);
|
||||
}
|
||||
await p.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('gnss-integrity', false),
|
||||
);
|
||||
const off = await capture(false),
|
||||
on = await drawn(off, 'air-quality');
|
||||
const values = await p.evaluate(() => ({
|
||||
records: window.qaAir.getAnalystRecords(),
|
||||
card: window.qaAir.getRowControls().list.items,
|
||||
stats: window.qaAir.getStats(),
|
||||
})); // An interior patch away from the label must take on the high-risk orange.
|
||||
const orange = (v) => v.channels[0].mean - v.channels[2].mean;
|
||||
assert.ok(
|
||||
orange(on) > orange(off) + 5,
|
||||
kind + ' must draw the station footprint',
|
||||
);
|
||||
console.log(
|
||||
'PASS',
|
||||
kind,
|
||||
values.records[0].id,
|
||||
'AQHI',
|
||||
values.records[0].value,
|
||||
);
|
||||
assert.equal(values.records[0].name, 'QA Toronto');
|
||||
assert.equal(values.records[0].value, 9);
|
||||
assert.equal(values.card.length, 1);
|
||||
await strict.passed(
|
||||
p,
|
||||
'air-quality',
|
||||
'AQHI 9 station footprint drawn, one card row',
|
||||
{
|
||||
off: orange(off),
|
||||
on: orange(on),
|
||||
},
|
||||
);
|
||||
await capture(false);
|
||||
const cellOff = await capture(false, 'gnss-integrity');
|
||||
const cellOn = await drawn(cellOff, 'gnss-integrity');
|
||||
assert.ok(
|
||||
orange(cellOn) > orange(cellOff) + 5,
|
||||
kind + ' must draw GNSS high cell',
|
||||
);
|
||||
const gnss = await p.evaluate(() => window.qaGnss.getStats());
|
||||
assert.equal(gnss.levels.high, 1);
|
||||
await strict.passed(p, 'gnss-integrity', 'one high GNSS cell drawn', {
|
||||
off: orange(cellOff),
|
||||
on: orange(cellOn),
|
||||
});
|
||||
console.log(
|
||||
'PASS',
|
||||
kind,
|
||||
'GNSS high cell, view anchor',
|
||||
gnss.coverage.latitude,
|
||||
gnss.coverage.longitude,
|
||||
);
|
||||
await p.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('air-quality', true),
|
||||
);
|
||||
}
|
||||
await p.evaluate(async () => {
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
await dataManager.setEnabled('air-quality', false);
|
||||
await dataManager.setEnabled('fire-perimeters', true, { origin: 'user' });
|
||||
window.qaFire = dataManager.layers.get('fire-perimeters').module;
|
||||
if (
|
||||
!dataManager.isEnabled('fire-perimeters') ||
|
||||
window.qaFire.getStats().count !== 1
|
||||
)
|
||||
throw Error(
|
||||
'Failed to enable fire perimeter fixture: ' +
|
||||
JSON.stringify(window.qaFire.getStats()),
|
||||
);
|
||||
});
|
||||
const card = async (visible) =>
|
||||
p.waitForFunction(
|
||||
(want) => {
|
||||
const canvas = document.getElementById('world-overlay-canvas');
|
||||
const data = canvas
|
||||
.getContext('2d')
|
||||
.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let pixels = 0;
|
||||
for (let i = 3; i < data.length; i += 4) if (data[i]) pixels++;
|
||||
return want ? pixels > 100 : pixels === 0;
|
||||
},
|
||||
{ timeout: 15000 },
|
||||
visible,
|
||||
);
|
||||
const click = async (lon, lat) => {
|
||||
const { x, y } = await p.evaluate(
|
||||
(world) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const point = engine.projector.worldToWindow(world, {});
|
||||
if (!point) throw Error('the point is not in view');
|
||||
const box = engine.host.canvas.getBoundingClientRect();
|
||||
return { x: box.left + point.x, y: box.top + point.y };
|
||||
},
|
||||
fromDegrees(lon, lat),
|
||||
);
|
||||
await p.mouse.click(x, y);
|
||||
};
|
||||
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
|
||||
await strict.switchTo(p, kind);
|
||||
await card(true);
|
||||
await strict.check(
|
||||
p,
|
||||
'fire-perimeters',
|
||||
'same model; the incident card survives the switch',
|
||||
await p.evaluate(
|
||||
() =>
|
||||
window.qaFire ===
|
||||
window.__godsEyeView.dataManager.layers.get('fire-perimeters')
|
||||
.module,
|
||||
),
|
||||
);
|
||||
}
|
||||
await click(-79.51, 43.65);
|
||||
await card(false);
|
||||
await strict.passed(
|
||||
p,
|
||||
'fire-perimeters',
|
||||
'a click outside the perimeter clears the card',
|
||||
);
|
||||
await click(-79.435, 43.65);
|
||||
await card(true);
|
||||
await strict.check(
|
||||
p,
|
||||
'fire-perimeters',
|
||||
'a click on the ring selects the fire',
|
||||
await p.evaluate(() => window.qaFire.getSelectedInfo?.()?.id ?? true),
|
||||
);
|
||||
// Not vacuous: the fire is selected, so the card only goes when the
|
||||
// click in the hole is handled as a click outside the polygon.
|
||||
await click(-79.38, 43.65);
|
||||
await card(false);
|
||||
await strict.passed(
|
||||
p,
|
||||
'fire-perimeters',
|
||||
'a click in the hole deselects the selected fire',
|
||||
);
|
||||
await click(-79.435, 43.65);
|
||||
await card(true);
|
||||
console.log(
|
||||
'PASS',
|
||||
kind,
|
||||
'select fire under GNSS, exclude hole, retain incident card',
|
||||
);
|
||||
}
|
||||
await p.evaluate(async () => {
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
for (const id of ['gnss-integrity', 'fire-perimeters', 'air-quality'])
|
||||
await dataManager.setEnabled(id, false);
|
||||
await dataManager.setEnabled('ais-live-vessels', true, { origin: 'user' });
|
||||
window.qaVesselEntry = dataManager.layers.get('ais-live-vessels');
|
||||
});
|
||||
await p.waitForFunction(
|
||||
() => window.qaVesselEntry.module.qaDrawn()?.chevrons[0]?.show,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
// Click-picking on the running engine: clear the selection with a click on
|
||||
// empty water, then select the vessel with a click on its chevron.
|
||||
const pickVessel = async () => {
|
||||
// Empty ground: a point in the middle of the view where the engine
|
||||
// picks nothing and nothing covers the canvas.
|
||||
const empty = await p
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const canvas = engine.host.canvas;
|
||||
const box = canvas.getBoundingClientRect();
|
||||
for (let y = 220; y < 560; y += 11)
|
||||
for (let x = 300; x < 720; x += 11) {
|
||||
if (
|
||||
!canvas.contains(
|
||||
document.elementFromPoint(box.left + x, box.top + y),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
let free = true;
|
||||
for (const [dx, dy] of [
|
||||
[0, 0],
|
||||
[10, 0],
|
||||
[-10, 0],
|
||||
[0, 10],
|
||||
[0, -10],
|
||||
])
|
||||
if (engine.picker.pick({ x: x + dx, y: y + dy })) free = false;
|
||||
if (free) return { x: box.left + x, y: box.top + y };
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ timeout: 15000 },
|
||||
)
|
||||
.then((handle) => handle.jsonValue());
|
||||
await p.mouse.click(empty.x, empty.y);
|
||||
await p.waitForFunction(
|
||||
() => !window.qaVesselEntry.module.getSelectedInfo(),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
await click(-79.38, 43.65);
|
||||
await p.waitForFunction(
|
||||
() => window.qaVesselEntry.module.getSelectedInfo()?.mmsi === '123456789',
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
await strict.passed(
|
||||
p,
|
||||
'ais-live-vessels',
|
||||
'a click on the chevron selects the vessel',
|
||||
);
|
||||
};
|
||||
await click(-79.38, 43.65);
|
||||
await p.waitForFunction(
|
||||
() => window.qaVesselEntry.module.getSelectedInfo()?.mmsi === '123456789',
|
||||
);
|
||||
await strict.passed(
|
||||
p,
|
||||
'ais-live-vessels',
|
||||
'a click on the chevron selects the vessel',
|
||||
);
|
||||
let histories = await p.evaluate(() => window.qaVesselHistories);
|
||||
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
|
||||
await strict.switchTo(p, kind);
|
||||
}
|
||||
const sample = await p.evaluate(() => {
|
||||
const entry =
|
||||
window.__godsEyeView.dataManager.layers.get('ais-live-vessels');
|
||||
const drawn = entry.module.qaDrawn();
|
||||
return {
|
||||
retained: entry === window.qaVesselEntry,
|
||||
selected: entry.module.getSelectedInfo()?.mmsi,
|
||||
chevron: drawn.chevrons.find((c) => c.selected && c.show)?.id,
|
||||
card: drawn.cards.find((c) => c.selected)?.id,
|
||||
histories: window.qaVesselHistories,
|
||||
};
|
||||
});
|
||||
assert.deepEqual(
|
||||
sample,
|
||||
{
|
||||
retained: true,
|
||||
selected: '123456789',
|
||||
chevron: '123456789',
|
||||
card: 'vessel:123456789',
|
||||
histories,
|
||||
},
|
||||
kind,
|
||||
);
|
||||
await strict.passed(
|
||||
p,
|
||||
'ais-live-vessels',
|
||||
'same entry; selection, chevron and card kept; no new track request',
|
||||
);
|
||||
if (kind === 'cesium' || histories === 1) {
|
||||
await pickVessel();
|
||||
histories = await p.evaluate(() => window.qaVesselHistories);
|
||||
}
|
||||
console.log(
|
||||
'PASS',
|
||||
kind,
|
||||
'retained selected AIS vessel, chevron, card and single track request',
|
||||
);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
+57
-24
@@ -99,6 +99,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { installNavaraHeadingProbe } from './qa-heading-navara.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Args (same shape as track-regression.mjs)
|
||||
@@ -110,7 +111,13 @@ const getOpt = (name, dflt) => {
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const appUrl = new URL(getOpt('--url', 'http://localhost:4173'));
|
||||
const requestedEngine = argv.find((value) => value.startsWith('--engine='))?.slice(9) || getOpt('--engine');
|
||||
if (requestedEngine) {
|
||||
if (!['cesium', 'navara'].includes(requestedEngine)) throw new Error('Unknown engine');
|
||||
appUrl.searchParams.set('engine', requestedEngine);
|
||||
}
|
||||
const APP_URL = appUrl.href;
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const SHOT_DIR = path.resolve('qa-shots/b3');
|
||||
const SHOT_DIR_V2 = path.resolve('qa-shots/heading-v2');
|
||||
@@ -148,7 +155,7 @@ function record(name, ok, detail) {
|
||||
|
||||
async function observeTrackedHostPaint(page, timeoutMs = 5000) {
|
||||
return page.evaluate((boundedTimeoutMs) => new Promise((resolve) => {
|
||||
const viewer = window.__godsEyeView?.viewer;
|
||||
const viewer = (window.__headingView ?? window.__godsEyeView?.viewer);
|
||||
const scene = viewer?.scene;
|
||||
const history = [];
|
||||
const startedAt = performance.now();
|
||||
@@ -453,7 +460,7 @@ async function main() {
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
...(HEADFUL ? [] : ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`]),
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
@@ -480,6 +487,7 @@ async function main() {
|
||||
|
||||
// ---- Turning-plane fetch shim (installed before any app code runs) ------
|
||||
await page.evaluateOnNewDocument((turn) => {
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
window.__TURN = turn;
|
||||
window.__TURN.epochMs = Date.now(); // arc phase zero = shim install time
|
||||
window.__TURN_HITS = { opensky: 0, mil: 0 };
|
||||
@@ -574,9 +582,12 @@ async function main() {
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
|
||||
() => window.__godsEyeView?.layers && window.__godsEyeView.dataManager,
|
||||
{ timeout: 60000, polling: 200 }
|
||||
);
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.initialRestorePromise);
|
||||
await page.waitForFunction(() => document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 180000 });
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.orbitController.stop());
|
||||
console.log(' App globals ready.');
|
||||
|
||||
// ---- Model finder + course derivation, installed once ------------------
|
||||
@@ -589,7 +600,7 @@ async function main() {
|
||||
// ~90° course spike into the sampled series — a measurement flake, not
|
||||
// a product regression.)
|
||||
window.__findTrackedModel = function (icao) {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
@@ -625,7 +636,7 @@ async function main() {
|
||||
// Invert _modelMatrix (pitch=roll=0): local x in ENU = (cos h, −sin h, 0)
|
||||
// → h = atan2(−x·north, x·east); world course = h − headingOffsetDeg.
|
||||
window.__courseFromModelMatrix = function (mm, headingOffsetDeg) {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
const C3 = v.camera.position.constructor;
|
||||
const Carto = v.camera.positionCartographic.constructor;
|
||||
const carto = Carto.fromCartesian(new C3(mm[12], mm[13], mm[14]));
|
||||
@@ -646,7 +657,7 @@ async function main() {
|
||||
// preUpdate/preRender listeners and reads the rendered model matrix, but
|
||||
// does not depend on ambient render-loop scheduling for sample count.
|
||||
window.__sampleModelCourse = async function (icao, headingOffsetDeg, windowMs, stepMs = 100) {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
const tileset = window.__godsEyeView.tileset;
|
||||
const out = [];
|
||||
const start = performance.now();
|
||||
@@ -685,13 +696,15 @@ async function main() {
|
||||
};
|
||||
});
|
||||
|
||||
await installNavaraHeadingProbe(page);
|
||||
|
||||
// ---- Enable layers with fixes back-dated 32 s, then prime forward ------
|
||||
// First update() fires inside setEnabled, so the FIRST fix must already be
|
||||
// the oldest (history appends only monotonically-newer fix times).
|
||||
console.log('Priming turning 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;
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
window.__TURN.timeOffsetSec = -32;
|
||||
await dm.setEnabled('flights', true);
|
||||
await dm.setEnabled('military', true);
|
||||
@@ -726,7 +739,10 @@ async function main() {
|
||||
|
||||
const flModelUp = await page.waitForFunction((icao) => {
|
||||
const m = window.__findTrackedModel(icao);
|
||||
return !!(m && m.ready && m.show);
|
||||
const app = window.__godsEyeView;
|
||||
const selected = app.dataManager.layers.get('flights').module.getTrackedInfo();
|
||||
const following = app.engine.kind === 'navara' ? app.engine.camera.following() : app.viewer.trackedEntity;
|
||||
return !!(m && m.ready && m.show && selected?.icao24 === icao && following);
|
||||
}, { timeout: 40000, polling: 250 }, TURN.flights[0].icao).then(() => true).catch(() => false);
|
||||
record('flights: tracked 3D model rendered (ready+shown)', flModelUp, flModelUp ? 'model up' : 'model never became ready');
|
||||
if (!flModelUp) { finish(); return; }
|
||||
@@ -783,14 +799,14 @@ async function main() {
|
||||
await sleep(800);
|
||||
const shots = [
|
||||
['flights-far-orbitA.png', async () => { /* default follow framing */ }],
|
||||
['flights-far-orbitB.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.rotateRight(1.9); })],
|
||||
['flights-far-orbitB.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.rotateRight(1.9); })],
|
||||
['flights-near-orbitA.png', () => page.evaluate(() => {
|
||||
const cam = window.__godsEyeView.viewer.camera;
|
||||
const cam = (window.__headingView ?? window.__godsEyeView.viewer).camera;
|
||||
// Synthetic cruise frame settles ~5.6 km out; leave ~430 m so model,
|
||||
// readout anchor, tracking line, and trail can be judged close-up.
|
||||
cam.rotateRight(-1.9); cam.zoomIn(5200);
|
||||
})],
|
||||
['flights-near-orbitB.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.rotateLeft(1.2); })],
|
||||
['flights-near-orbitB.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.rotateLeft(1.2); })],
|
||||
];
|
||||
for (const [name, move] of shots) {
|
||||
try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); }
|
||||
@@ -812,7 +828,10 @@ async function main() {
|
||||
|
||||
const milModelUp = await page.waitForFunction((hex) => {
|
||||
const m = window.__findTrackedModel(hex);
|
||||
return !!(m && m.ready && m.show);
|
||||
const app = window.__godsEyeView;
|
||||
const selected = app.dataManager.layers.get('military').module.getTrackedInfo();
|
||||
const following = app.engine.kind === 'navara' ? app.engine.camera.following() : app.viewer.trackedEntity;
|
||||
return !!(m && m.ready && m.show && selected?.icao24 === hex && following);
|
||||
}, { timeout: 40000, polling: 250 }, TURN.military[0].hex).then(() => true).catch(() => false);
|
||||
record('military: tracked 3D model rendered (ready+shown)', milModelUp, milModelUp ? 'model up' : 'model never became ready');
|
||||
if (milModelUp) {
|
||||
@@ -841,14 +860,14 @@ async function main() {
|
||||
await sleep(800);
|
||||
for (const [name, move] of [
|
||||
['military-orbitA.png', async () => { }],
|
||||
['military-orbitB.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.rotateRight(1.6); })],
|
||||
['military-orbitB.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.rotateRight(1.6); })],
|
||||
['military-near-orbitA.png', () => page.evaluate(() => {
|
||||
const cam = window.__godsEyeView.viewer.camera;
|
||||
const cam = (window.__headingView ?? window.__godsEyeView.viewer).camera;
|
||||
// 12,000 ft synthetic frame settles ~6.3 km out; leave ~630 m.
|
||||
cam.rotateLeft(1.6); cam.zoomIn(5700);
|
||||
})],
|
||||
['military-near-orbitB.png', () => page.evaluate(() => {
|
||||
window.__godsEyeView.viewer.camera.rotateRight(1.2);
|
||||
(window.__headingView ?? window.__godsEyeView.viewer).camera.rotateRight(1.2);
|
||||
})],
|
||||
]) {
|
||||
try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); }
|
||||
@@ -877,7 +896,10 @@ async function main() {
|
||||
}, icao);
|
||||
const up = await page.waitForFunction((id) => {
|
||||
const m = window.__findTrackedModel(id);
|
||||
return !!(m && m.ready && m.show);
|
||||
const app = window.__godsEyeView;
|
||||
const selected = app.dataManager.layers.get('flights').module.getTrackedInfo();
|
||||
const following = app.engine.kind === 'navara' ? app.engine.camera.following() : app.viewer.trackedEntity;
|
||||
return !!(m && m.ready && m.show && selected?.icao24 === id && following);
|
||||
}, { timeout: 40000, polling: 250 }, icao).then(() => true).catch(() => false);
|
||||
record(`${label}: tracked 3D model rendered (ready+shown)`, up, up ? 'model up' : 'model never became ready');
|
||||
return up;
|
||||
@@ -914,7 +936,7 @@ async function main() {
|
||||
});
|
||||
for (const [name, move] of [
|
||||
['slowturn-far.png', async () => { /* default follow framing */ }],
|
||||
['slowturn-near.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.zoomIn(2500); })],
|
||||
['slowturn-near.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.zoomIn(2500); })],
|
||||
]) {
|
||||
try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); }
|
||||
await sleep(1200);
|
||||
@@ -930,8 +952,9 @@ async function main() {
|
||||
// ========================================================================
|
||||
console.log('\nPhase 5 — 65 kt helicopter: tracked↔fleet course handoff (click / click-away)');
|
||||
await page.evaluate(() => {
|
||||
if (window.__headingView) { window.__findModelByIcao = window.__findTrackedModel; return; }
|
||||
window.__findModelByIcao = function (icao) {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
@@ -957,7 +980,7 @@ async function main() {
|
||||
window.__TURN.heli65.active = true;
|
||||
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
fl.setParams({ models3d: false });
|
||||
await fl.update(window.__godsEyeView.viewer); // ingest the first heli fix
|
||||
await fl.update((window.__headingView ?? window.__godsEyeView.viewer)); // ingest the first heli fix
|
||||
});
|
||||
await sleep(4000); // several fleet ticks establish the heli's fleet course
|
||||
await page.evaluate((icao) => {
|
||||
@@ -966,7 +989,7 @@ async function main() {
|
||||
const heliTracked = await page.waitForFunction((icao) => {
|
||||
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
const ti = fl.getTrackedInfo();
|
||||
return !!(ti && ti.icao24 === icao && window.__godsEyeView.viewer.trackedEntity);
|
||||
return !!(ti && ti.icao24 === icao && (window.__headingView ?? window.__godsEyeView.viewer).trackedEntity);
|
||||
}, { timeout: 15000, polling: 250 }, TURN.heli65.icao).then(() => true).catch(() => false);
|
||||
record('heli65: tracking engaged (2D billboard mode)', heliTracked,
|
||||
heliTracked ? 'tracked' : 'tracking never engaged');
|
||||
@@ -983,9 +1006,10 @@ async function main() {
|
||||
// rotation after. The release-in-place keeps the camera static, so
|
||||
// rotation deltas across the boundary are course deltas.
|
||||
const c1 = await page.evaluate(async ({ icao, actionAtMs, windowMs }) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
const findFleetBillboard = (id) => {
|
||||
if (window.__headingView) return window.__godsEyeView.layers.drawn('flights').find((row) => row.id === id && row.show);
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
@@ -1037,7 +1061,16 @@ async function main() {
|
||||
window.__godsEyeView.dataManager.layers.get('flights').module.setParams({ models3d: true });
|
||||
});
|
||||
const fleetModelUp = await page.evaluate(async (icao) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
if (window.__headingView) {
|
||||
const deadline = performance.now() + 40000;
|
||||
while (performance.now() < deadline) {
|
||||
if (window.__findModelByIcao(icao)) return true;
|
||||
window.__godsEyeView.engine.render.request('heading-probe');
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
const tileset = window.__godsEyeView.tileset;
|
||||
const priorDefaultLoop = v.useDefaultRenderLoop;
|
||||
const priorTilesetShow = tileset?.show;
|
||||
@@ -1061,7 +1094,7 @@ async function main() {
|
||||
record('heli65 C2 (click): fleet model rendered before re-track', false, 'fleet model never became ready');
|
||||
} else {
|
||||
const c2 = await page.evaluate(async ({ icao, offsetDeg, actionAtMs, windowMs }) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
const out = [];
|
||||
const start = performance.now();
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Navara ports for the existing heading scenarios. No app viewer is installed.
|
||||
// Samples come from the renderer handles through the read-only layer probe.
|
||||
export async function installNavaraHeadingProbe(page) {
|
||||
await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
if (app.engine.kind !== 'navara') return;
|
||||
const geo = await import('/src/geo/index.js');
|
||||
const engine = app.engine;
|
||||
const drawn = (id) =>
|
||||
['flights', 'military']
|
||||
.flatMap((layer) => app.layers.drawn(layer) ?? [])
|
||||
.filter((row) => row.id === id);
|
||||
window.__findTrackedModel = (id) => {
|
||||
const rows = drawn(id);
|
||||
const row =
|
||||
rows.find((r) => r.trackedModel && r.model?.ready && r.model.show) ??
|
||||
rows.find((r) => r.model?.ready && r.model.show);
|
||||
return row
|
||||
? {
|
||||
id,
|
||||
ready: row.model.ready,
|
||||
show: row.model.show,
|
||||
modelMatrix: row.model.matrix,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
window.__courseFromModelMatrix = (mm, offset) => {
|
||||
if (!mm) return null;
|
||||
const c = geo.cartographic.fromCartesian({
|
||||
x: mm[12],
|
||||
y: mm[13],
|
||||
z: mm[14],
|
||||
});
|
||||
const ex = -Math.sin(c.longitude),
|
||||
ey = Math.cos(c.longitude);
|
||||
const nx = -Math.sin(c.latitude) * Math.cos(c.longitude);
|
||||
const ny = -Math.sin(c.latitude) * Math.sin(c.longitude),
|
||||
nz = Math.cos(c.latitude);
|
||||
const h =
|
||||
(Math.atan2(
|
||||
-(mm[0] * nx + mm[1] * ny + mm[2] * nz),
|
||||
mm[0] * ex + mm[1] * ey,
|
||||
) *
|
||||
180) /
|
||||
Math.PI;
|
||||
return (((h - offset) % 360) + 360) % 360;
|
||||
};
|
||||
const turn = (angle = 0, nearer = 0) => {
|
||||
const handle = engine.camera.following();
|
||||
const target = handle?.target();
|
||||
if (!target)
|
||||
throw new Error('Heading screenshot needs a followed target');
|
||||
const frame = geo.transforms.eastNorthUpToFixedFrame(target);
|
||||
const inverse = geo.matrix4.inverseTransformation(
|
||||
frame,
|
||||
geo.matrix4.create(),
|
||||
);
|
||||
const p = geo.matrix4.multiplyByPoint(
|
||||
inverse,
|
||||
engine.camera.pose().position,
|
||||
geo.cartesian3.create(),
|
||||
);
|
||||
const x = p.x * Math.cos(angle) - p.y * Math.sin(angle);
|
||||
const y = p.x * Math.sin(angle) + p.y * Math.cos(angle);
|
||||
const range = Math.hypot(x, y, p.z);
|
||||
const scale = Math.max(150, range - nearer) / range;
|
||||
handle.retarget({
|
||||
viewFrom: { x: x * scale, y: y * scale, z: p.z * scale },
|
||||
});
|
||||
};
|
||||
window.__headingView = {
|
||||
clock: { currentTime: undefined },
|
||||
get trackedEntity() {
|
||||
const handle = engine.camera.following();
|
||||
if (!handle) return null;
|
||||
const label = handle.label;
|
||||
const id = label?.id?.split(':').at(-1);
|
||||
return {
|
||||
gevTrackedId: label?.id,
|
||||
gevLabelModel:
|
||||
typeof label?.model === 'function' ? label.model() : label?.model,
|
||||
gevDisplayPosition: handle.target,
|
||||
billboard: {
|
||||
rotation: {
|
||||
getValue: () => drawn(id).find((r) => r.tracked)?.trackedRotation,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
camera: {
|
||||
rotateRight: (a) => turn(a),
|
||||
rotateLeft: (a) => turn(-a),
|
||||
zoomIn: (m) => turn(0, m),
|
||||
},
|
||||
scene: {
|
||||
preRender: engine.frames.preRender,
|
||||
postRender: engine.frames.postRender,
|
||||
requestRender: () => engine.render.request('heading-probe'),
|
||||
},
|
||||
};
|
||||
// Observe real Navara frames; unlike Cesium this port does not own a
|
||||
// manual viewer loop. The 600 ms case samples at that cadence while
|
||||
// Navara continues rendering; the dt-clamp branch is covered by unit QA.
|
||||
window.__sampleModelCourse = (id, offset, windowMs, stepMs = 100) =>
|
||||
new Promise((resolve) => {
|
||||
const samples = [];
|
||||
let last = -Infinity;
|
||||
const remove = engine.frames.preRender.addEventListener(() => {
|
||||
const now = performance.now();
|
||||
if (now - last < stepMs) return;
|
||||
last = now;
|
||||
const model = window.__findTrackedModel(id);
|
||||
samples.push({
|
||||
tMs: now,
|
||||
epochMs: Date.now(),
|
||||
course: model
|
||||
? window.__courseFromModelMatrix(model.modelMatrix, offset)
|
||||
: null,
|
||||
});
|
||||
});
|
||||
setTimeout(() => {
|
||||
remove();
|
||||
resolve(samples);
|
||||
}, windowMs);
|
||||
engine.render.request('heading-probe');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -213,7 +213,8 @@ async function readCameraGround(page, camId) {
|
||||
* @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)}`)}`;
|
||||
const url = new URL('/api/terrain/heights', APP_URL);
|
||||
url.searchParams.set('points', `${lon.toFixed(5)},${lat.toFixed(5)}`);
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
|
||||
if (!res.ok) return null;
|
||||
@@ -253,7 +254,7 @@ async function main() {
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
`--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`,
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
@@ -279,7 +280,7 @@ async function main() {
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
|
||||
() => window.__godsEyeView?.layers && window.__godsEyeView.dataManager,
|
||||
{ timeout: 60000 }
|
||||
);
|
||||
await sleep(4000);
|
||||
@@ -287,6 +288,10 @@ async function main() {
|
||||
// =========================================================================
|
||||
// Group 1: CCTV ground correctness
|
||||
// =========================================================================
|
||||
const hasCctv = await page.evaluate(() => window.__godsEyeView.layers.available('cctv'));
|
||||
let allCameras = [], londonCams = [], austinCams = [];
|
||||
if (!hasCctv) console.log(' [SKIP] CCTV ground/regime groups are outside the Navara W1 catalog');
|
||||
if (hasCctv) {
|
||||
console.log('Enabling CCTV layer (full city-packs catalog)...');
|
||||
await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
@@ -337,7 +342,7 @@ async function main() {
|
||||
// `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);
|
||||
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 };
|
||||
@@ -349,8 +354,8 @@ async function main() {
|
||||
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);
|
||||
londonCams = allCameras.filter((c) => c.city === 'London').slice(0, 5);
|
||||
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);
|
||||
@@ -456,6 +461,8 @@ async function main() {
|
||||
record('Austin active camera google-3d ground ∈ [110,135]m', null, 'no Austin camera available to test');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Group 2: Aircraft render altitude
|
||||
// =========================================================================
|
||||
@@ -511,7 +518,7 @@ async function main() {
|
||||
// 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;
|
||||
const center = window.__godsEyeView.engine.camera.livePosition;
|
||||
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]));
|
||||
@@ -626,6 +633,7 @@ async function main() {
|
||||
// =========================================================================
|
||||
// Group 3: Regime C re-resolve
|
||||
// =========================================================================
|
||||
if (hasCctv) {
|
||||
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).
|
||||
@@ -694,6 +702,8 @@ async function main() {
|
||||
record('CCTV grounds re-resolved for regime C', null, 'no test camera available');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Screenshot for human visual review (not a pass/fail gate).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
openPage,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0&look=cinematic';
|
||||
base.hash =
|
||||
'v=2&lat=41.5340&lon=-90.5070&alt=20000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
|
||||
// Every geometry of the bundled files (a Multi* feature counts each part),
|
||||
// read from the files this checkout builds.
|
||||
const geometries = (name) =>
|
||||
readFileSync(
|
||||
new URL(`../src/data/local_data/${name}/${name}.geojsonl`, import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
.split('\n')
|
||||
.filter((line) => line.trim())
|
||||
.reduce((sum, line) => {
|
||||
const { type, coordinates } = JSON.parse(line).geometry;
|
||||
return sum + (type.startsWith('Multi') ? coordinates.length : 1);
|
||||
}, 0);
|
||||
const DATACENTERS = geometries('datacenters');
|
||||
const DAMS = geometries('dams');
|
||||
const strict = createStrict({
|
||||
origin: base.origin,
|
||||
layers: ['local-dams', 'local-datacenters'],
|
||||
});
|
||||
await strict.run({}, async (b) => {
|
||||
const { page: p, errors } = await openPage(b);
|
||||
const reads = { datacenters: 0, dams: 0 };
|
||||
p.on('request', (req) => {
|
||||
const path = new URL(req.url()).pathname;
|
||||
for (const name of Object.keys(reads))
|
||||
if (path.includes(name) && path.endsWith('.geojsonl')) reads[name]++;
|
||||
});
|
||||
await p.evaluateOnNewDocument(() => {
|
||||
window.qaSelections = [];
|
||||
window.addEventListener('gev:entity-selected', ({ detail }) => {
|
||||
if (detail?.layerId?.startsWith('local-'))
|
||||
window.qaSelections.push(detail.id);
|
||||
});
|
||||
window.qaCleared = 0;
|
||||
window.addEventListener('gev:entity-selection-cleared', () => {
|
||||
window.qaCleared++;
|
||||
});
|
||||
});
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
const ids = ['local-datacenters', 'local-dams'];
|
||||
const dataset = await p.evaluate(async (ids) => {
|
||||
const { dataManager, tileset } = window.__godsEyeView;
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Keyless preview required');
|
||||
window.qaSites = [];
|
||||
const records = [];
|
||||
for (const id of ids) {
|
||||
if (!(await dataManager.setEnabled(id, true, { origin: 'user' })))
|
||||
throw Error(id + ' did not enable');
|
||||
const mod = dataManager.layers.get(id).module;
|
||||
window.qaSites.push(mod);
|
||||
const all = mod.getAnalystRecords(Infinity);
|
||||
records.push({
|
||||
count: mod.getStats().count,
|
||||
total: all.length,
|
||||
name: all[0]?.name,
|
||||
});
|
||||
}
|
||||
return records;
|
||||
}, ids);
|
||||
// The bundled files are fixed: every record is loaded.
|
||||
assert.deepEqual(
|
||||
dataset.map((d) => d.count),
|
||||
[DATACENTERS, DAMS],
|
||||
JSON.stringify(dataset),
|
||||
);
|
||||
const record = await p.evaluate(() =>
|
||||
window.qaSites[0]
|
||||
.getAnalystRecords(5000)
|
||||
.find((r) => r.name === 'Geneseo Communications Inc'),
|
||||
);
|
||||
assert.ok(record);
|
||||
const clickSite = async () => {
|
||||
const screen = await p.evaluate(
|
||||
(world) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const at = engine.projector.worldToWindow(world, {});
|
||||
const box = engine.host.canvas.getBoundingClientRect();
|
||||
return { x: box.left + at.x, y: box.top + at.y };
|
||||
},
|
||||
fromDegrees(record.lon, record.lat),
|
||||
);
|
||||
await p.mouse.click(screen.x, screen.y);
|
||||
await p.waitForFunction(
|
||||
() => window.qaSelections.at(-1) === 'local-datacenters:342664739',
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
};
|
||||
// A dam in view, clicked where its record is.
|
||||
const dam = await p.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('local-dams')
|
||||
.module.getAnalystRecords(5000)
|
||||
.find((r) => r.name === 'Arsenal Power Dam'),
|
||||
);
|
||||
assert.ok(dam);
|
||||
const clickDam = async () => {
|
||||
const screen = await p.evaluate(
|
||||
(world) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const at = engine.projector.worldToWindow(world, {});
|
||||
const box = engine.host.canvas.getBoundingClientRect();
|
||||
window.qaSelections = [];
|
||||
return { x: box.left + at.x, y: box.top + at.y };
|
||||
},
|
||||
fromDegrees(dam.lon, dam.lat),
|
||||
);
|
||||
await p.mouse.click(screen.x, screen.y);
|
||||
await p
|
||||
.waitForFunction(
|
||||
() => window.qaSelections.at(-1) === 'local-dams:-12841971',
|
||||
{ timeout: 15000 },
|
||||
)
|
||||
.catch(async (error) => {
|
||||
console.log(
|
||||
'dam click',
|
||||
JSON.stringify(screen),
|
||||
JSON.stringify(
|
||||
await p.evaluate(({ x, y }) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const box = engine.host.canvas.getBoundingClientRect();
|
||||
const id = engine.picker.pick({
|
||||
x: x - box.left,
|
||||
y: y - box.top,
|
||||
})?.id;
|
||||
return {
|
||||
selections: window.qaSelections,
|
||||
cleared: window.qaCleared,
|
||||
pick: String(id?.id ?? id),
|
||||
hit: document.elementFromPoint(x, y)?.tagName,
|
||||
};
|
||||
}, screen),
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
const start = fromDegrees(-90.507, 41.534, 20000);
|
||||
const home = async () => {
|
||||
await p.evaluate((destination) => {
|
||||
const { camera, render } = window.__godsEyeView.engine;
|
||||
camera.cancelFlight();
|
||||
camera.setView({
|
||||
destination,
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
render.request('qa-home');
|
||||
}, start);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
};
|
||||
const counts = { ...reads };
|
||||
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
|
||||
await p.evaluate(() => {
|
||||
window.qaSelections = [];
|
||||
});
|
||||
await strict.switchTo(p, kind);
|
||||
await p.waitForFunction(
|
||||
() => window.qaSelections.includes('local-datacenters:342664739'),
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
const result = await p.evaluate(
|
||||
(ids) =>
|
||||
ids.map((id, i) => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get(id).module;
|
||||
return {
|
||||
same: mod === window.qaSites[i],
|
||||
lod: mod.getLodDiagnostics(),
|
||||
count: mod.getStats().count,
|
||||
error: mod.getStats().error,
|
||||
name: mod.getAnalystRecords(1)[0].name,
|
||||
};
|
||||
}),
|
||||
ids,
|
||||
);
|
||||
console.log(kind, JSON.stringify(result));
|
||||
for (const [i, layer] of result.entries()) {
|
||||
assert.ok(
|
||||
layer.same &&
|
||||
!layer.error &&
|
||||
layer.lod.active > 0 &&
|
||||
layer.lod.active <= layer.lod.budgetLimit,
|
||||
);
|
||||
assert.equal(layer.count, dataset[i].count);
|
||||
assert.equal(layer.name, dataset[i].name);
|
||||
}
|
||||
assert.deepEqual(
|
||||
reads,
|
||||
counts,
|
||||
'Do not reload bundled data on scene handoff',
|
||||
);
|
||||
await strict.passed(
|
||||
p,
|
||||
ids,
|
||||
'same model, every bundled record, bounded LOD stems, no file read again',
|
||||
result.map((r) => r.count),
|
||||
);
|
||||
if (
|
||||
kind !== 'navara' ||
|
||||
(await p.evaluate(() => window.qaSelections.length))
|
||||
)
|
||||
await strict.passed(
|
||||
p,
|
||||
'local-datacenters',
|
||||
'the clicked datacenter stays selected across the switch',
|
||||
);
|
||||
// Click-picking on this engine: a dam, then the datacenter again, each
|
||||
// from the starting view (a selection flies the camera to it).
|
||||
await home();
|
||||
await clickDam();
|
||||
await strict.passed(p, 'local-dams', 'a click on a dam selects it');
|
||||
await home();
|
||||
await clickSite();
|
||||
await strict.passed(
|
||||
p,
|
||||
'local-datacenters',
|
||||
'a click on the datacenter selects it',
|
||||
);
|
||||
}
|
||||
assert.equal(counts.datacenters, 1);
|
||||
assert.equal(counts.dams, 1);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS complete bundled datasets, bounded stems, retained selection and one read per file',
|
||||
);
|
||||
});
|
||||
@@ -6,15 +6,18 @@
|
||||
* `<dir>/clip.mp4` through ffmpeg's concat demuxer with per-frame
|
||||
* durations, so held frames last as long as they did on screen.
|
||||
* - `installFrameProbe(page)` measures every animation frame in the page:
|
||||
* the requestAnimationFrame interval and, for frames Cesium rendered, the
|
||||
* CPU time between `scene.preUpdate` and `scene.postRender`.
|
||||
* the requestAnimationFrame interval and, for rendered frames, the
|
||||
* CPU time between engine preUpdate and postRender events.
|
||||
* - `frameStats(frames)` reduces a probe window to count / mean / p50 / p95.
|
||||
* - `moveCamera(page, view, seconds)` eases the camera like a drag or wheel
|
||||
* zoom rather than teleporting it.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Percentile of a numeric list (nearest rank); NaN for an empty list. */
|
||||
export function percentile(values, p) {
|
||||
@@ -25,7 +28,7 @@ export function percentile(values, p) {
|
||||
];
|
||||
}
|
||||
|
||||
/** Summarize probe frames: rAF interval and Cesium render CPU time (ms). */
|
||||
/** Summarize probe frames: rAF interval and engine render CPU time (ms). */
|
||||
export function frameStats(frames) {
|
||||
const intervals = frames.map((f) => f.dt).filter(Number.isFinite);
|
||||
const renders = frames.map((f) => f.renderMs).filter(Number.isFinite);
|
||||
@@ -49,16 +52,17 @@ export function frameStats(frames) {
|
||||
export async function installFrameProbe(page) {
|
||||
await page.evaluate(() => {
|
||||
if (window.__gevFrameProbe) return;
|
||||
const { viewer } = window.__godsEyeView;
|
||||
const { viewer, engine } = window.__godsEyeView;
|
||||
const events = viewer?.scene ?? engine.frames;
|
||||
const probe = { frames: [], started: 0 };
|
||||
window.__gevFrameProbe = probe;
|
||||
let last = performance.now();
|
||||
let renderStart = null;
|
||||
let pendingRender = null;
|
||||
viewer.scene.preUpdate.addEventListener(() => {
|
||||
events.preUpdate.addEventListener(() => {
|
||||
renderStart = performance.now();
|
||||
});
|
||||
viewer.scene.postRender.addEventListener(() => {
|
||||
events.postRender.addEventListener(() => {
|
||||
if (renderStart !== null) pendingRender = performance.now() - renderStart;
|
||||
renderStart = null;
|
||||
});
|
||||
@@ -98,6 +102,34 @@ export function pageNow(page) {
|
||||
* @param {number} seconds Flight duration; 0 jumps.
|
||||
*/
|
||||
export async function moveCamera(page, view, seconds) {
|
||||
if (
|
||||
await page.evaluate(() => window.__godsEyeView.engine?.kind === 'navara')
|
||||
) {
|
||||
await page.evaluate(
|
||||
async (v, duration) => {
|
||||
const geo = await import('/src/geo/index.js');
|
||||
const camera = window.__godsEyeView.engine.camera;
|
||||
camera.cancelFlight();
|
||||
await new Promise((resolve) =>
|
||||
camera.flyTo({
|
||||
destination: geo.cartesian3.fromDegrees(v.lon, v.lat, v.height),
|
||||
orientation: {
|
||||
heading: ((v.heading ?? 0) * Math.PI) / 180,
|
||||
pitch: ((v.pitch ?? -45) * Math.PI) / 180,
|
||||
roll: 0,
|
||||
},
|
||||
duration,
|
||||
maximumHeight: Math.max(v.height, camera.height()),
|
||||
complete: resolve,
|
||||
cancel: resolve,
|
||||
}),
|
||||
);
|
||||
},
|
||||
view,
|
||||
seconds,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await page.evaluate(
|
||||
(v, duration) =>
|
||||
new Promise((resolve) => {
|
||||
@@ -191,7 +223,8 @@ export async function createScreencast(page, dir, options = {}) {
|
||||
const list = path.join(dir, 'frames.txt');
|
||||
fs.writeFileSync(list, `${lines.join('\n')}\n`);
|
||||
const clip = path.join(dir, 'clip.mp4');
|
||||
execFileSync('ffmpeg', [
|
||||
// Keep servicing Puppeteer's request interception while encoding.
|
||||
await execFileAsync('ffmpeg', [
|
||||
'-y',
|
||||
'-loglevel',
|
||||
'error',
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
* legibility over bright ground.
|
||||
*
|
||||
* Run:
|
||||
* node scripts/qa-label-readability.mjs --url http://localhost:4244 --tag after
|
||||
* node scripts/qa-label-readability.mjs --url http://localhost:4244 --tag after [--engine=cesium|navara]
|
||||
*
|
||||
* Engine-neutral: camera, tiles and layers go through the globe engine and the
|
||||
* QA probe; an engine without the flights/satellites layers (Navara today)
|
||||
* shoots the scenes without a contact field and says so.
|
||||
*
|
||||
* Every scene pins its own camera pose AND substitutes a deterministic
|
||||
* synthetic contact field for the live pollers, so a `before` run and an
|
||||
@@ -36,7 +40,12 @@ const getOpt = (name, fallback) => {
|
||||
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
||||
};
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4244');
|
||||
const ENGINE = argv.find((value) => value.startsWith('--engine='))?.slice(9) ?? 'cesium';
|
||||
const APP_URL = (() => {
|
||||
const url = new URL(getOpt('--url', 'http://localhost:4244'));
|
||||
url.searchParams.set('engine', ENGINE);
|
||||
return url.href;
|
||||
})();
|
||||
const TAG = getOpt('--tag', 'after');
|
||||
/** Comma-separated scene-id prefixes. Empty runs the whole set. */
|
||||
const SCENE_FILTER = getOpt('--scenes', '')
|
||||
@@ -205,11 +214,8 @@ async function settle(page) {
|
||||
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 { engine } = window.__godsEyeView;
|
||||
return { tilesLoaded: engine.surface.tilesLoaded(), pending: 0 };
|
||||
});
|
||||
const settled = state.tilesLoaded && state.pending === 0;
|
||||
if (settled && previous && Date.now() - started > SETTLE_MIN_MS) return true;
|
||||
@@ -239,7 +245,7 @@ async function main() {
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox',
|
||||
'--use-gl=angle', '--use-angle=swiftshader',
|
||||
'--use-gl=angle', `--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`,
|
||||
'--disable-dev-shm-usage', '--disable-web-security',
|
||||
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
@@ -254,7 +260,7 @@ async function main() {
|
||||
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.styleManager,
|
||||
() => window.__godsEyeView?.layers && window.__godsEyeView?.styleManager,
|
||||
{ timeout: 60_000, polling: 100 },
|
||||
);
|
||||
// flyToAustin arrives ~500 ms after init; let it start and land before any
|
||||
@@ -268,12 +274,16 @@ async function main() {
|
||||
|
||||
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;
|
||||
const fieldShown = await page.evaluate(async (spec) => {
|
||||
const { engine, dataManager, styleManager, layers } = window.__godsEyeView;
|
||||
const geo = await import('/src/geo/index.js');
|
||||
engine.camera.cancelFlight();
|
||||
const Cartesian3 = Object.assign(
|
||||
(x, y, z) => geo.cartesian3.create(x, y, z),
|
||||
{ fromDegrees: (lon, lat, height) => geo.cartesian3.fromDegrees(lon, lat, height) },
|
||||
);
|
||||
|
||||
viewer.camera.setView({
|
||||
engine.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(
|
||||
spec.camera.lon, spec.camera.lat, spec.camera.height,
|
||||
),
|
||||
@@ -290,10 +300,9 @@ async function main() {
|
||||
// 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 camera = engine.camera.pose();
|
||||
const halfHeight = Math.tan((camera.fovy ?? 1.0) / 2);
|
||||
const halfWidth = halfHeight * (camera.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,
|
||||
@@ -305,7 +314,7 @@ async function main() {
|
||||
const pointAt = (ndcX, ndcY, distance) => {
|
||||
const sx = ndcX * halfWidth * distance;
|
||||
const sy = ndcY * halfHeight * distance;
|
||||
return new Cartesian3(
|
||||
return 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,
|
||||
@@ -347,6 +356,9 @@ async function main() {
|
||||
? { satellites: build('s'), flights: [], military: [] }
|
||||
: { flights: build('f'), military: [], satellites: [] };
|
||||
window.__LABEL_EVIDENCE = assignment;
|
||||
const fieldLayers = Object.keys(assignment).filter(
|
||||
(layerId) => assignment[layerId].length,
|
||||
);
|
||||
for (const layerId of Object.keys(assignment)) {
|
||||
const entry = dataManager.layers.get(layerId);
|
||||
if (entry?.module) {
|
||||
@@ -362,6 +374,7 @@ async function main() {
|
||||
densityPct: 100,
|
||||
allocationStrategy: 'elastic',
|
||||
});
|
||||
return fieldLayers.every((layerId) => layers.available(layerId));
|
||||
}, scene);
|
||||
|
||||
const converged = await settle(page);
|
||||
@@ -399,10 +412,11 @@ async function main() {
|
||||
const diagnostics = await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.getDetectionState?.() || {},
|
||||
);
|
||||
results.push({ scene: scene.id, converged, ...stats });
|
||||
results.push({ scene: scene.id, engine: ENGINE, converged, fieldShown, ...stats });
|
||||
console.log(
|
||||
`p05=${stats.p05} p25=${stats.p25} p50=${stats.p50} p95=${stats.p95}`
|
||||
+ `${converged ? '' : ' (tiles did not fully settle)'}`
|
||||
+ `${fieldShown ? '' : ` (no contact field: its layers are not on ${ENGINE})`}`
|
||||
+ ` [${diagnostics.detectionMode || '?'}]`,
|
||||
);
|
||||
}
|
||||
|
||||
+98
-24
@@ -3,7 +3,12 @@
|
||||
* Deterministic QA for the detection overlay's bounded two-lane label pipeline.
|
||||
*
|
||||
* Run:
|
||||
* node scripts/qa-labels.mjs --url http://localhost:4173
|
||||
* node scripts/qa-labels.mjs --url http://localhost:4173 [--engine=cesium|navara]
|
||||
*
|
||||
* Engine-neutral: the camera and frames go through the globe engine and the
|
||||
* layers through the QA probe (window.__godsEyeView.layers). On an engine
|
||||
* without the flights, military and satellites layers (Navara today) the
|
||||
* field checks are SKIPPED and the settings checks still run.
|
||||
*
|
||||
* 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
|
||||
@@ -22,7 +27,12 @@ const getOpt = (name, fallback) => {
|
||||
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
||||
};
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const ENGINE = argv.find((value) => value.startsWith('--engine='))?.slice(9) ?? 'cesium';
|
||||
const APP_URL = (() => {
|
||||
const url = new URL(getOpt('--url', 'http://localhost:4173'));
|
||||
url.searchParams.set('engine', ENGINE);
|
||||
return url.href;
|
||||
})();
|
||||
const APP_ORIGIN = new URL(APP_URL).origin;
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const SHOT_DIR = path.resolve('qa-shots/labels');
|
||||
@@ -74,6 +84,8 @@ function findChromeExecutable() {
|
||||
}
|
||||
|
||||
const results = [];
|
||||
let skippedChecks = 0;
|
||||
let skippedField = false;
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
@@ -214,7 +226,7 @@ async function main() {
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
`--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`,
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
@@ -263,7 +275,7 @@ async function main() {
|
||||
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.styleManager,
|
||||
() => window.__godsEyeView?.layers && window.__godsEyeView?.styleManager,
|
||||
{ timeout: 60000, polling: 100 },
|
||||
);
|
||||
|
||||
@@ -271,7 +283,7 @@ async function main() {
|
||||
// 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());
|
||||
await page.evaluate(() => window.__godsEyeView.engine.camera.cancelFlight());
|
||||
|
||||
const stateChecks = await page.evaluate((storageKey) => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
@@ -297,10 +309,62 @@ async function main() {
|
||||
`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 fieldLayers = ['flights', 'military', 'satellites'];
|
||||
const missingLayers = await page.evaluate(
|
||||
(ids) => ids.filter((id) => !window.__godsEyeView.layers.available(id)),
|
||||
fieldLayers,
|
||||
);
|
||||
// An engine with flights but not the other field layers: the same 12,000 / 5,200 observations, all as aircraft, so the
|
||||
// detection lane is measured on the layers the engine has.
|
||||
let fieldCounts = FIELD_COUNTS;
|
||||
let normalCounts = NORMAL_COUNTS;
|
||||
if (missingLayers.length && !missingLayers.includes('flights')) {
|
||||
const sum = (counts) =>
|
||||
Object.values(counts).reduce((total, count) => total + count, 0);
|
||||
fieldCounts = Object.fromEntries(
|
||||
fieldLayers.map((id) => [
|
||||
id,
|
||||
missingLayers.includes(id)
|
||||
? 0
|
||||
: id === 'flights'
|
||||
? sum(FIELD_COUNTS) - fieldLayers.filter((other) => other !== 'flights' && !missingLayers.includes(other)).reduce((total, other) => total + FIELD_COUNTS[other], 0)
|
||||
: FIELD_COUNTS[id],
|
||||
]),
|
||||
);
|
||||
normalCounts = Object.fromEntries(
|
||||
fieldLayers.map((id) => [
|
||||
id,
|
||||
missingLayers.includes(id)
|
||||
? 0
|
||||
: id === 'flights'
|
||||
? sum(NORMAL_COUNTS) - fieldLayers.filter((other) => other !== 'flights' && !missingLayers.includes(other)).reduce((total, other) => total + NORMAL_COUNTS[other], 0)
|
||||
: NORMAL_COUNTS[id],
|
||||
]),
|
||||
);
|
||||
console.log(
|
||||
` [INFO] the field as ${JSON.stringify(fieldCounts)} (no ${missingLayers.join(', ')} on ${ENGINE})`,
|
||||
);
|
||||
} else if (missingLayers.length) {
|
||||
console.log(` [SKIP] the deterministic field checks — no ${missingLayers.join(', ')} on ${ENGINE}`);
|
||||
skippedChecks += 1;
|
||||
record(
|
||||
'console remains clean',
|
||||
consoleErrors.length === 0,
|
||||
consoleErrors.slice(0, 3).join(' | ') || '0 errors',
|
||||
);
|
||||
skippedField = true;
|
||||
return;
|
||||
}
|
||||
const injected = await page.evaluate(async ({ fieldCounts, normalCounts }) => {
|
||||
const { engine, dataManager, styleManager } = window.__godsEyeView;
|
||||
const geo = await import('/src/geo/index.js');
|
||||
engine.camera.cancelFlight();
|
||||
// A frame counter on the engine (the samples are one per drawn frame).
|
||||
window.__LABEL_QA_FRAME = 0;
|
||||
engine.frames.postRender.addEventListener(() => {
|
||||
window.__LABEL_QA_FRAME += 1;
|
||||
});
|
||||
const Cartesian3 = { fromDegrees: (lon, lat, height) => geo.cartesian3.fromDegrees(lon, lat, height) };
|
||||
const field = { flights: [], military: [], satellites: [] };
|
||||
const layerIds = Object.keys(field);
|
||||
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
|
||||
@@ -328,13 +392,19 @@ async function main() {
|
||||
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];
|
||||
if (entry)
|
||||
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 },
|
||||
});
|
||||
// The global camera, orbiting west to east (the old rotateRight about
|
||||
// the globe centre at 25 000 km).
|
||||
let orbitLon = -97;
|
||||
const placeCamera = () =>
|
||||
engine.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(orbitLon, 30, 25000000),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
placeCamera();
|
||||
styleManager.setDetection({
|
||||
enabled: true,
|
||||
densityPct: 100,
|
||||
@@ -351,13 +421,14 @@ async function main() {
|
||||
let lastOrbitAt = performance.now();
|
||||
window.__LABEL_QA_ORBIT = setInterval(() => {
|
||||
const now = performance.now();
|
||||
viewer.camera.rotateRight((now - lastOrbitAt) * 0.0000012);
|
||||
orbitLon -= ((now - lastOrbitAt) * 0.0000012 * 180) / Math.PI;
|
||||
lastOrbitAt = now;
|
||||
viewer.scene.requestRender();
|
||||
placeCamera();
|
||||
engine.render.request('qa-labels');
|
||||
}, 100);
|
||||
const sample = () => {
|
||||
const diagnostics = styleManager.getDetectionDiagnostics();
|
||||
const frameNumber = viewer.scene.frameState?.frameNumber ?? -1;
|
||||
const frameNumber = window.__LABEL_QA_FRAME;
|
||||
if (diagnostics && frameNumber !== window.__LABEL_QA.lastFrameNumber) {
|
||||
window.__LABEL_QA.lastFrameNumber = frameNumber;
|
||||
window.__LABEL_QA.frames.push({
|
||||
@@ -387,7 +458,7 @@ async function main() {
|
||||
observationCount: Number(dataset.observationCount),
|
||||
selectedCount: Number(dataset.selectedCount),
|
||||
fadingCount: Number(dataset.fadingCount),
|
||||
cameraHeight: viewer.camera.positionCartographic.height,
|
||||
cameraHeight: engine.camera.height(),
|
||||
demandByLayer: JSON.parse(dataset.demandByLayer || '{}'),
|
||||
cohortByLayer: JSON.parse(dataset.cohortByLayer || '{}'),
|
||||
entitlementByLayer: JSON.parse(dataset.entitlementByLayer || '{}'),
|
||||
@@ -398,7 +469,7 @@ async function main() {
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
return Object.fromEntries(layerIds.map((layerId) => [layerId, field[layerId].length]));
|
||||
}, { fieldCounts: FIELD_COUNTS, normalCounts: NORMAL_COUNTS });
|
||||
}, { fieldCounts, normalCounts });
|
||||
|
||||
record(
|
||||
'deterministic field contains exactly 12,000 observations',
|
||||
@@ -412,12 +483,11 @@ async function main() {
|
||||
// -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.lastFrameNumber = window.__LABEL_QA_FRAME;
|
||||
window.__LABEL_QA.startedAt = performance.now();
|
||||
});
|
||||
const syntheticSampling = await waitForConclusiveSamples(page, {
|
||||
@@ -452,12 +522,11 @@ async function main() {
|
||||
{ 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.lastFrameNumber = window.__LABEL_QA_FRAME;
|
||||
window.__LABEL_QA.startedAt = performance.now();
|
||||
});
|
||||
const normalSampling = await waitForConclusiveSamples(page, {
|
||||
@@ -579,10 +648,15 @@ async function main() {
|
||||
console.log(` Synthetic solves: ${solveSamples.length}; normal frames: ${normalFieldFrames.length} (${normalFrames.length} non-solve)`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
if (skippedField) summarize();
|
||||
}
|
||||
|
||||
summarize();
|
||||
}
|
||||
|
||||
function summarize() {
|
||||
const failures = results.filter((result) => !result.ok);
|
||||
console.log(`\n${results.length - failures.length}/${results.length} checks passed.`);
|
||||
console.log(`\n${results.length - failures.length}/${results.length} checks passed${skippedChecks ? `, ${skippedChecks} skipped` : ''} (engine ${ENGINE}).`);
|
||||
if (failures.length) process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||
|
||||
const url = localPreview(process.argv[2]);
|
||||
const military = process.argv.includes('--military');
|
||||
const layerId = military ? 'military' : 'flights';
|
||||
const aircraftId = military ? 'aaa052' : '71be26';
|
||||
url.search = '?engine=navara&welcome=0';
|
||||
url.hash =
|
||||
'v=2&lat=40.2539&lon=-125.5779&alt=21130&heading=360&pitch=-35&map=sentinel2' +
|
||||
(military ? '&l=m&lo=f.e.1_f.u.aaa052' : '&l=f&lo=f.e.1_f.t.71be26');
|
||||
// The Command Dock, and the Globe Engine picker in it, is hidden in cockpit
|
||||
// mode: no person can pick an engine there, so the cockpit switches below go
|
||||
// through the select element; the real picker is used once the cockpit is
|
||||
// left.
|
||||
const HIDDEN_PICKER = 'the Globe Engine picker is hidden in cockpit mode';
|
||||
const strict = createStrict({ origin: url.origin, layers: [layerId] });
|
||||
await strict.run({}, async (browser) => {
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.setViewport({ width: 1280, height: 720 });
|
||||
await page.evaluateOnNewDocument(
|
||||
(layerId, aircraftId) => {
|
||||
window.__qaAircraft = { layerId, aircraftId };
|
||||
const fetch = window.fetch.bind(window);
|
||||
window.__flightPolls = 0;
|
||||
const interval = window.setInterval.bind(window);
|
||||
window.setInterval = function (callback, delay, ...args) {
|
||||
if (typeof callback !== 'function')
|
||||
return interval(callback, delay, ...args);
|
||||
const id = interval(
|
||||
function (...values) {
|
||||
if (window.__handoff?.timer === id) window.__handoff.ticks++;
|
||||
return callback.apply(this, values);
|
||||
},
|
||||
delay,
|
||||
...args,
|
||||
);
|
||||
return id;
|
||||
};
|
||||
window.fetch = (input, options) => {
|
||||
const path = new URL(input.url ?? input, document.baseURI).pathname;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (path === '/api/flights') {
|
||||
if (layerId === 'flights') window.__flightPolls++;
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
time: now,
|
||||
states: [
|
||||
[
|
||||
'71be26',
|
||||
'GEV123',
|
||||
'QA',
|
||||
now,
|
||||
now,
|
||||
-125.5779,
|
||||
40.2539,
|
||||
9700,
|
||||
false,
|
||||
200,
|
||||
45,
|
||||
0,
|
||||
null,
|
||||
9700,
|
||||
null,
|
||||
false,
|
||||
0,
|
||||
],
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (path === '/api/military') {
|
||||
if (layerId === 'military') window.__flightPolls++;
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
now: Date.now(),
|
||||
ac: [
|
||||
{
|
||||
hex: 'aaa052',
|
||||
flight: 'GEVMIL',
|
||||
lon: -125.5779,
|
||||
lat: 40.2539,
|
||||
alt_geom: 31825,
|
||||
alt_baro: 31825,
|
||||
gs: 389,
|
||||
track: 45,
|
||||
seen: 0,
|
||||
seen_pos: 0,
|
||||
dbFlags: 1,
|
||||
type: 'adsb_icao',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (path === '/api/flights/track')
|
||||
return Promise.resolve(Response.json({ path: [] }));
|
||||
if (path.startsWith('/api/flights/'))
|
||||
return Promise.resolve(Response.json({}));
|
||||
return fetch(input, options);
|
||||
};
|
||||
},
|
||||
layerId,
|
||||
aircraftId,
|
||||
);
|
||||
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
||||
const ready = (kind) =>
|
||||
page.waitForFunction(
|
||||
(engine) =>
|
||||
window.__godsEyeView?.engine?.kind === engine &&
|
||||
!document.querySelector('#globe-engine')?.disabled &&
|
||||
document.querySelector('#loading-screen.hidden') &&
|
||||
!document.querySelector('.engine-switch-frame'),
|
||||
{ timeout: 90000 },
|
||||
kind,
|
||||
);
|
||||
await ready('navara');
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||
);
|
||||
await page.waitForSelector('#cockpit-entry', { visible: true });
|
||||
await page.click('#cockpit-entry');
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.styleManager.getCockpitState().active,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const { dataManager, styleManager, tileset } = window.__godsEyeView;
|
||||
if (window.__GOOGLE_MAPS_API_KEY__ || tileset)
|
||||
throw Error('Keyless preview required');
|
||||
styleManager.cockpitView.setVisionMode('thermal');
|
||||
const entry = dataManager.layers.get(window.__qaAircraft.layerId);
|
||||
window.__handoff = {
|
||||
dataManager,
|
||||
entry,
|
||||
row: document.querySelector(
|
||||
`[data-layer-id="${window.__qaAircraft.layerId}"] .data-toggle-btn`,
|
||||
),
|
||||
timer: entry.intervalId,
|
||||
ticks: 0,
|
||||
model: entry.module,
|
||||
pollCount: window.__flightPolls,
|
||||
timestamp: entry.module.getStats().lastUpdate,
|
||||
};
|
||||
});
|
||||
// Refuse the first Cesium canvas. The application must roll back with the
|
||||
// original aircraft still in the cockpit, then allow an ordinary retry.
|
||||
await page.evaluate(() => {
|
||||
const original = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = function (type, ...args) {
|
||||
if (String(type).includes('webgl')) {
|
||||
window.__refusedContext = true;
|
||||
throw new Error('QA renderer startup failure');
|
||||
}
|
||||
return original.call(this, type, ...args);
|
||||
};
|
||||
window.__restoreContext = () => {
|
||||
HTMLCanvasElement.prototype.getContext = original;
|
||||
};
|
||||
});
|
||||
const historyLength = await page.evaluate(() => history.length);
|
||||
await strict.selectEngine(page, 'cesium', HIDDEN_PICKER);
|
||||
await page.waitForFunction(() => window.__refusedContext === true);
|
||||
await ready('navara');
|
||||
// A failed switch leaves the session usable on the engine that worked and
|
||||
// adds no history entry.
|
||||
const survived = (kind) =>
|
||||
page.evaluate(
|
||||
(kind, length) => {
|
||||
const { dataManager, engine, styleManager } = window.__godsEyeView;
|
||||
return (
|
||||
engine.kind === kind &&
|
||||
new URL(location.href).searchParams.get('engine') === kind &&
|
||||
history.length === length &&
|
||||
dataManager === window.__handoff.dataManager &&
|
||||
dataManager.layers.get(window.__qaAircraft.layerId) ===
|
||||
window.__handoff.entry &&
|
||||
styleManager.getCockpitState().active &&
|
||||
styleManager.getCockpitState().visionMode === 'thermal' &&
|
||||
styleManager.getAircraftTrackingTarget()?.id ===
|
||||
window.__qaAircraft.aircraftId
|
||||
);
|
||||
},
|
||||
kind,
|
||||
historyLength,
|
||||
);
|
||||
await page.evaluate(() => window.__restoreContext());
|
||||
await strict.check(
|
||||
page,
|
||||
layerId,
|
||||
'cold Cesium failure rolls back to Navara: same session, cockpit, aircraft, no history entry',
|
||||
await survived('navara'),
|
||||
);
|
||||
console.log(
|
||||
'PASS cold Cesium failure rolled back to Navara with the same flight, cockpit and history',
|
||||
);
|
||||
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
await strict.selectEngine(page, kind, HIDDEN_PICKER);
|
||||
await ready(kind);
|
||||
const result = await page.evaluate(() => {
|
||||
const { dataManager, engine, styleManager } = window.__godsEyeView;
|
||||
const before = window.__handoff;
|
||||
const entry = dataManager.layers.get(window.__qaAircraft.layerId);
|
||||
const cockpit = styleManager.getCockpitState();
|
||||
return {
|
||||
sameOwner:
|
||||
before.dataManager === dataManager &&
|
||||
before.entry === entry &&
|
||||
before.model === entry.module,
|
||||
sameRow:
|
||||
Boolean(before.row) &&
|
||||
before.row ===
|
||||
document.querySelector(
|
||||
`[data-layer-id="${window.__qaAircraft.layerId}"] .data-toggle-btn`,
|
||||
),
|
||||
sameTimer:
|
||||
before.timer === entry.intervalId && entry.intervalId != null,
|
||||
sourcePolls: window.__flightPolls - before.pollCount,
|
||||
timerTicks: before.ticks,
|
||||
stamp: entry.module.getStats().lastUpdate,
|
||||
initialStamp: before.timestamp,
|
||||
id: cockpit.subject?.id,
|
||||
active: cockpit.active,
|
||||
mode: cockpit.visionMode,
|
||||
hud: !document.querySelector('#cockpit-hud').hidden,
|
||||
drawn: entry.module
|
||||
.qaDrawn()
|
||||
.some((row) => row.id === window.__qaAircraft.aircraftId),
|
||||
height: engine.camera.height(),
|
||||
};
|
||||
});
|
||||
assert.ok(
|
||||
result.sameOwner &&
|
||||
result.sameTimer &&
|
||||
result.sameRow &&
|
||||
result.active &&
|
||||
result.hud &&
|
||||
result.drawn,
|
||||
JSON.stringify(result),
|
||||
);
|
||||
assert.equal(result.id, aircraftId);
|
||||
assert.equal(result.mode, 'thermal');
|
||||
assert.ok(
|
||||
result.sourcePolls <= result.timerTicks,
|
||||
'switch must not add a source request',
|
||||
);
|
||||
if (result.timerTicks === 0) {
|
||||
assert.equal(result.stamp, result.initialStamp);
|
||||
}
|
||||
await strict.passed(
|
||||
page,
|
||||
layerId,
|
||||
'same manager entry, model, row, poll timer, cockpit, thermal mode, HUD and drawn aircraft',
|
||||
result,
|
||||
);
|
||||
// Polling goes on after the switch: the same timer ticks and each tick
|
||||
// reads the source once (no tick may be skipped, none added).
|
||||
const polls = await page.evaluate(async (layerId) => {
|
||||
const entry = window.__godsEyeView.dataManager.layers.get(layerId);
|
||||
const every =
|
||||
Number(entry.module.refreshInterval) ||
|
||||
Number(entry.module.updateInterval);
|
||||
const start = {
|
||||
polls: window.__flightPolls,
|
||||
ticks: window.__handoff.ticks,
|
||||
stamp: entry.module.getStats().lastUpdate,
|
||||
};
|
||||
const deadline = Date.now() + 2.5 * every + 5000;
|
||||
while (window.__handoff.ticks - start.ticks < 2 && Date.now() < deadline)
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
// Let the last tick's request land.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
return {
|
||||
every,
|
||||
ticks: window.__handoff.ticks - start.ticks,
|
||||
polls: window.__flightPolls - start.polls,
|
||||
updated: entry.module.getStats().lastUpdate !== start.stamp,
|
||||
sameTimer: entry.intervalId === window.__handoff.timer,
|
||||
};
|
||||
}, layerId);
|
||||
await strict.check(
|
||||
page,
|
||||
layerId,
|
||||
'polling continues after the switch: two timer ticks, one source read each, fresh data',
|
||||
polls.every > 0 &&
|
||||
polls.ticks >= 2 &&
|
||||
polls.polls === polls.ticks &&
|
||||
polls.updated &&
|
||||
polls.sameTimer,
|
||||
polls,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => Math.abs(window.__godsEyeView.engine.camera.height() - 9700) < 1000,
|
||||
);
|
||||
console.log(
|
||||
`PASS ${kind} cockpit, aircraft, HUD, manager and timer (${result.sourcePolls} polls since entry)`,
|
||||
);
|
||||
}
|
||||
// The other direction, warm: the parked Navara renderer fails to resume on
|
||||
// the way back from Cesium. The session rolls back to Cesium, then an
|
||||
// ordinary retry starts Navara cold.
|
||||
await page.evaluate(() => {
|
||||
window.__parkedNavara = window.__godsEyeView.engine;
|
||||
});
|
||||
await strict.selectEngine(page, 'cesium', HIDDEN_PICKER);
|
||||
await ready('cesium');
|
||||
const lengthOnCesium = await page.evaluate(() => {
|
||||
const request = window.__parkedNavara.render.request;
|
||||
window.__parkedNavara.render.request = function (...args) {
|
||||
window.__parkedNavara.render.request = request;
|
||||
window.__refusedResume = true;
|
||||
throw new Error('QA parked renderer failure');
|
||||
};
|
||||
return history.length;
|
||||
});
|
||||
await strict.selectEngine(page, 'navara', HIDDEN_PICKER);
|
||||
await page.waitForFunction(() => window.__refusedResume === true);
|
||||
await ready('cesium');
|
||||
assert.ok(
|
||||
(await page.evaluate(
|
||||
(length) => history.length === length,
|
||||
lengthOnCesium,
|
||||
)) &&
|
||||
(await page.evaluate(() => {
|
||||
const { styleManager } = window.__godsEyeView;
|
||||
return (
|
||||
styleManager.getCockpitState().active &&
|
||||
styleManager.getAircraftTrackingTarget()?.id ===
|
||||
window.__qaAircraft.aircraftId &&
|
||||
new URL(location.href).searchParams.get('engine') === 'cesium'
|
||||
);
|
||||
})),
|
||||
'a failed warm Navara resume must leave Cesium usable without a history entry',
|
||||
);
|
||||
await strict.passed(
|
||||
page,
|
||||
layerId,
|
||||
'warm Navara failure rolls back to Cesium: same aircraft and cockpit, no history entry',
|
||||
);
|
||||
console.log(
|
||||
'PASS warm Navara failure rolled back to Cesium with the same flight and cockpit',
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__parkedCesium = window.__godsEyeView.engine;
|
||||
});
|
||||
await strict.selectEngine(page, 'navara', HIDDEN_PICKER);
|
||||
await ready('navara');
|
||||
assert.ok(
|
||||
await page.evaluate(() => {
|
||||
const { engine, styleManager } = window.__godsEyeView;
|
||||
return (
|
||||
engine !== window.__parkedNavara &&
|
||||
styleManager.getCockpitState().active &&
|
||||
styleManager.getAircraftTrackingTarget()?.id ===
|
||||
window.__qaAircraft.aircraftId
|
||||
);
|
||||
}),
|
||||
'the retry starts a new Navara renderer on the same session',
|
||||
);
|
||||
console.log('PASS retry after the warm failure started Navara cold');
|
||||
|
||||
// Both engines fail: the warm Cesium resume and the warm Navara recovery.
|
||||
// No engine runs, but the error screen offers the engine picker, and a
|
||||
// retry starts Cesium cold on the same session without a reload.
|
||||
const lengthBeforeDoubleFailure = await page.evaluate(() => {
|
||||
window.__failResume = true;
|
||||
for (const engine of [window.__godsEyeView.engine, window.__parkedCesium]) {
|
||||
const request = engine.render.request;
|
||||
engine.render.request = function (reason, ...args) {
|
||||
if (reason === 'engine-resume' && window.__failResume) {
|
||||
window.__refusedResumes = (window.__refusedResumes ?? 0) + 1;
|
||||
throw new Error('QA parked renderer failure');
|
||||
}
|
||||
return request.call(this, reason, ...args);
|
||||
};
|
||||
}
|
||||
window.__startDocument = document;
|
||||
return history.length;
|
||||
});
|
||||
await strict.selectEngine(page, 'cesium', HIDDEN_PICKER);
|
||||
await page
|
||||
.waitForSelector('#loading-screen .engine-switch-retry button', {
|
||||
visible: true,
|
||||
timeout: 90000,
|
||||
})
|
||||
.catch(async (error) => {
|
||||
console.log(
|
||||
'no retry picker',
|
||||
JSON.stringify(
|
||||
await page.evaluate(() => ({
|
||||
refused: window.__refusedResumes,
|
||||
engine: window.__godsEyeView?.engine?.kind ?? null,
|
||||
retry: !!document.querySelector('.engine-switch-retry'),
|
||||
loading: document.querySelector('#loading-screen')?.className,
|
||||
status: document.querySelector('#loading-screen .loader-status')
|
||||
?.textContent,
|
||||
address: location.search,
|
||||
})),
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
assert.ok(
|
||||
await page.evaluate(
|
||||
(length) =>
|
||||
window.__refusedResumes === 2 &&
|
||||
// No globe runs (the page's debug object may stay, without one).
|
||||
!window.__godsEyeView?.engine &&
|
||||
history.length === length &&
|
||||
!document
|
||||
.querySelector('#loading-screen')
|
||||
.classList.contains('hidden') &&
|
||||
/QA parked renderer failure/.test(
|
||||
document.querySelector('#loading-screen .loader-status').textContent,
|
||||
),
|
||||
lengthBeforeDoubleFailure,
|
||||
),
|
||||
'a failed recovery shows its error and the engine picker ' +
|
||||
JSON.stringify(
|
||||
await page.evaluate(() => ({
|
||||
refused: window.__refusedResumes,
|
||||
app: !!window.__godsEyeView,
|
||||
engine: window.__godsEyeView?.engine?.kind ?? null,
|
||||
history: history.length,
|
||||
loading: document.querySelector('#loading-screen')?.className,
|
||||
status: document.querySelector('#loading-screen .loader-status')
|
||||
?.textContent,
|
||||
})),
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__failResume = false;
|
||||
});
|
||||
await page.click(
|
||||
'#loading-screen .engine-switch-retry [data-engine="cesium"]',
|
||||
);
|
||||
await ready('cesium');
|
||||
assert.ok(
|
||||
await page.evaluate(() => {
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
return (
|
||||
window.__startDocument === document &&
|
||||
!document.querySelector('.engine-switch-retry') &&
|
||||
dataManager === window.__handoff.dataManager &&
|
||||
dataManager.layers.get(window.__qaAircraft.layerId) ===
|
||||
window.__handoff.entry &&
|
||||
styleManager.getCockpitState().active &&
|
||||
styleManager.getAircraftTrackingTarget()?.id ===
|
||||
window.__qaAircraft.aircraftId &&
|
||||
new URL(location.href).searchParams.get('engine') === 'cesium'
|
||||
);
|
||||
}),
|
||||
'the retry restores the same session on Cesium',
|
||||
);
|
||||
await strict.passed(
|
||||
page,
|
||||
layerId,
|
||||
'after both engines fail, the retry restores the same session',
|
||||
);
|
||||
console.log(
|
||||
'PASS failed recovery offered the picker; the retry restored the session on Cesium',
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.controlCockpit('exit'),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => !window.__godsEyeView.styleManager.getCockpitState().active,
|
||||
);
|
||||
// Out of the cockpit the dock shows the picker again: a switch with
|
||||
// real input keeps the same entry and timer, the aircraft drawn, and the
|
||||
// source polled on its timer.
|
||||
await page.evaluate(() => {
|
||||
window.__handoff.ticks = 0;
|
||||
window.__handoff.pollCount = window.__flightPolls;
|
||||
window.__handoff.timer = window.__godsEyeView.dataManager.layers.get(
|
||||
window.__qaAircraft.layerId,
|
||||
).intervalId;
|
||||
});
|
||||
await strict.pick(page, 'navara');
|
||||
await ready('navara');
|
||||
const after = await page.evaluate(async () => {
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
const entry = dataManager.layers.get(window.__qaAircraft.layerId);
|
||||
const every =
|
||||
Number(entry.module.refreshInterval) ||
|
||||
Number(entry.module.updateInterval);
|
||||
const deadline = Date.now() + 2.5 * every + 5000;
|
||||
while (window.__handoff.ticks < 1 && Date.now() < deadline)
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
return {
|
||||
same:
|
||||
dataManager === window.__handoff.dataManager &&
|
||||
entry === window.__handoff.entry,
|
||||
timer: entry.intervalId === window.__handoff.timer,
|
||||
drawn: entry.module
|
||||
.qaDrawn()
|
||||
.some((row) => row.id === window.__qaAircraft.aircraftId),
|
||||
ticks: window.__handoff.ticks,
|
||||
polls: window.__flightPolls - window.__handoff.pollCount,
|
||||
};
|
||||
});
|
||||
await strict.check(
|
||||
page,
|
||||
layerId,
|
||||
'picker switch keeps the entry, timer and drawn aircraft; polling continues',
|
||||
after.same &&
|
||||
after.timer &&
|
||||
after.drawn &&
|
||||
after.ticks >= 1 &&
|
||||
after.polls === after.ticks,
|
||||
after,
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
openPage,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0&look=cinematic';
|
||||
base.hash =
|
||||
'v=2&lat=41.5340&lon=-90.5070&alt=20000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
|
||||
const strict = createStrict({ origin: base.origin, layers: ['local-adsb'] });
|
||||
await strict.run({}, async (b) => {
|
||||
const { page: p, errors } = await openPage(b);
|
||||
await p.evaluateOnNewDocument(() => {
|
||||
const raw = window.fetch;
|
||||
window.fetch = function (input, ...args) {
|
||||
const url = new URL(input?.url ?? input, location.href);
|
||||
if (url.pathname === '/api/local-receivers/aircraft') {
|
||||
const at = Date.now();
|
||||
// Each poll reports the aircraft a little further north.
|
||||
const n = (window.qaAdsbPolls = (window.qaAdsbPolls ?? 0) + 1);
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
configured: true,
|
||||
generatedAt: at,
|
||||
records: [
|
||||
{
|
||||
icao: 'abc123',
|
||||
callsign: 'LOCAL1',
|
||||
lat: 41.534 + 0.0002 * n,
|
||||
lon: -90.507,
|
||||
altitudeFt: 11000,
|
||||
groundSpeedKt: 180,
|
||||
trackDeg: 5,
|
||||
lastPositionAt: at,
|
||||
lastMessageAt: at,
|
||||
messageCount: 12 + n,
|
||||
band: '1090',
|
||||
source: 'decoder',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return raw.call(this, input, ...args);
|
||||
};
|
||||
window.qaSelections = [];
|
||||
window.addEventListener('gev:entity-selected', ({ detail }) =>
|
||||
window.qaSelections.push(detail.id),
|
||||
);
|
||||
});
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(async () => {
|
||||
const { dataManager, tileset } = window.__godsEyeView;
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Keyless preview required');
|
||||
const mod = dataManager.layers.get('local-adsb').module;
|
||||
window.qaAdsb = {
|
||||
mod,
|
||||
receiver: mod.receiver,
|
||||
feeds: mod.feeds,
|
||||
starts: 0,
|
||||
stops: 0,
|
||||
closes: 0,
|
||||
};
|
||||
for (const [obj, key, counter] of [
|
||||
[mod.receiver, 'destroy', 'closes'],
|
||||
[mod.feeds, 'start', 'starts'],
|
||||
[mod.feeds, 'stop', 'stops'],
|
||||
]) {
|
||||
const original = obj[key].bind(obj);
|
||||
obj[key] = (...args) => {
|
||||
window.qaAdsb[counter]++;
|
||||
return original(...args);
|
||||
};
|
||||
}
|
||||
await dataManager.setEnabled('local-adsb', true, { origin: 'user' });
|
||||
});
|
||||
await p.waitForFunction(
|
||||
() => window.qaAdsb.mod.getAllPositions().length === 1,
|
||||
);
|
||||
// The selection hook the selectable layers share, by the row's own id.
|
||||
assert.ok(
|
||||
await p.evaluate(() =>
|
||||
window.qaAdsb.mod.selectById(window.qaAdsb.mod.getAllPositions()[0].id),
|
||||
),
|
||||
);
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
await p.evaluate(() => {
|
||||
window.qaSelections = [];
|
||||
});
|
||||
await strict.switchTo(p, kind);
|
||||
await p.waitForFunction(() =>
|
||||
window.qaSelections.includes('local-adsb:abc123'),
|
||||
);
|
||||
const result = await p.evaluate(() => {
|
||||
const q = window.qaAdsb,
|
||||
manager = window.__godsEyeView.dataManager;
|
||||
const mod = manager.layers.get('local-adsb').module;
|
||||
return [
|
||||
manager.isEnabled(mod.id),
|
||||
q.mod === mod,
|
||||
q.receiver === mod.receiver,
|
||||
q.feeds === mod.feeds,
|
||||
mod.getAllPositions()[0]?.id,
|
||||
q.starts,
|
||||
q.stops,
|
||||
q.closes,
|
||||
];
|
||||
});
|
||||
assert.deepEqual(result, [true, true, true, true, 'abc123', 1, 0, 0]);
|
||||
await strict.passed(
|
||||
p,
|
||||
'local-adsb',
|
||||
'same decoder, receiver and feeds, one feed start, selection republished',
|
||||
);
|
||||
// Updates continue on the new engine: further polls move the aircraft.
|
||||
const before = await p.evaluate(() => ({
|
||||
polls: window.qaAdsbPolls,
|
||||
lat: window.qaAdsb.mod.getAnalystRecords()[0]?.lat,
|
||||
stamp: window.qaAdsb.mod.getStats().lastUpdate,
|
||||
}));
|
||||
await p.waitForFunction(
|
||||
(before) =>
|
||||
window.qaAdsbPolls >= before.polls + 2 &&
|
||||
window.qaAdsb.mod.getAnalystRecords()[0]?.lat > before.lat &&
|
||||
window.qaAdsb.mod.getStats().lastUpdate > before.stamp,
|
||||
{ timeout: 15000 },
|
||||
before,
|
||||
);
|
||||
await strict.passed(
|
||||
p,
|
||||
'local-adsb',
|
||||
'receiver polls continue and move the selected aircraft',
|
||||
before,
|
||||
);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS retained local decoder, receiver, marker and selection in both scenes',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/** Install before navigation, without intercepting workers or ordinary tiles. */
|
||||
export async function blockPaidQaMaps(page, { allowPaidMaps = false } = {}) {
|
||||
if (allowPaidMaps) return;
|
||||
const session = await page.createCDPSession();
|
||||
await session.send('Network.enable');
|
||||
await session.send('Network.setBlockedURLs', {
|
||||
urls: [
|
||||
'*://tile.googleapis.com/*',
|
||||
'*://*.googleapis.com/*3dtiles*',
|
||||
'*://api.cesium.com/v1/assets/2275207/*',
|
||||
'*/api/google/tiles*',
|
||||
],
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Free public tile hosts the strict acceptance lets a page ask for. The page
|
||||
* never reaches them: the strict harness answers them from a disk cache
|
||||
* (scripts/qa/tileCache.mjs), and Node fetches a missing tile once, politely.
|
||||
* OpenStreetMap raster tiles, the keyless re:earth terrain, the EOX
|
||||
* Sentinel-2 cloudless mosaic and Esri World Imagery (the keyless Cesium
|
||||
* base imagery and the Navara startup imagery).
|
||||
*/
|
||||
export const STRICT_TILE_HOSTS = Object.freeze([
|
||||
'tile.openstreetmap.org',
|
||||
'terrain.reearth.land',
|
||||
'tiles.maps.eox.at',
|
||||
'services.arcgisonline.com',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Requests every keyless page makes on its own, answered by the strict
|
||||
* harness exactly as the keyless preview would (or with an empty stylesheet
|
||||
* for Google Fonts), so they reach neither the preview's paid routes nor
|
||||
* Google. Any other request under a paid route or host stays blocked.
|
||||
* Keys are URLPattern strings relative to the preview origin or absolute.
|
||||
*/
|
||||
export const KEYLESS_PROBES = Object.freeze([
|
||||
{
|
||||
pattern: '/api/openai/hud-summary/status',
|
||||
method: 'GET',
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: '{"configured":false}',
|
||||
},
|
||||
{ pattern: '/api/google/tiles-token', method: 'GET', status: 204 },
|
||||
{ pattern: '/api/realtime/debug-log', method: 'POST', status: 204 },
|
||||
{
|
||||
pattern: 'https://fonts.googleapis.com/css2*',
|
||||
method: 'GET',
|
||||
status: 200,
|
||||
contentType: 'text/css',
|
||||
body: '/* Google Fonts withheld by the strict QA policy */',
|
||||
},
|
||||
]);
|
||||
|
||||
/** A probe's absolute URLPattern string. */
|
||||
export const probePattern = (origin, probe) =>
|
||||
probe.pattern.startsWith('/')
|
||||
? new URL(origin).origin + probe.pattern
|
||||
: probe.pattern;
|
||||
|
||||
/** Paid or keyed services: blocked first, whatever else a scenario allows. */
|
||||
export const PAID_HOST_PATTERNS = Object.freeze([
|
||||
'*://*.googleapis.com/*',
|
||||
'*://googleapis.com/*',
|
||||
'*://*.google.com/*',
|
||||
'*://google.com/*',
|
||||
'*://maps.google.*/*',
|
||||
'*://*.gstatic.com/*',
|
||||
'*://*.cesium.com/*',
|
||||
'*://cesium.com/*',
|
||||
]);
|
||||
|
||||
/** Preview routes that spend a key (OpenAI, Realtime, Google proxies). */
|
||||
export const PAID_PREVIEW_PATHS = Object.freeze([
|
||||
'/api/openai/*',
|
||||
'/api/realtime/*',
|
||||
'/api/google/*',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The ordered Network.setBlockedURLs patterns of the strict policy (first
|
||||
* match wins): the keyless probes the harness answers are let through to
|
||||
* it, paid hosts and paid preview routes are blocked, then the
|
||||
* preview origin, the scenario's fixture URLs and the free tile hosts are
|
||||
* allowed, then everything else is blocked.
|
||||
*
|
||||
* @param {string} origin The preview origin, e.g. http://127.0.0.1:4208
|
||||
* @param {{fixtures?: string[], tileHosts?: readonly string[]}} [options]
|
||||
* `fixtures` are URLPattern strings for requests the scenario answers.
|
||||
*/
|
||||
export function strictBlockPatterns(
|
||||
origin,
|
||||
{ fixtures = [], tileHosts = STRICT_TILE_HOSTS } = {},
|
||||
) {
|
||||
const preview = new URL(origin).origin;
|
||||
return [
|
||||
...KEYLESS_PROBES.map((probe) => ({
|
||||
urlPattern: probePattern(origin, probe),
|
||||
block: false,
|
||||
})),
|
||||
...PAID_HOST_PATTERNS.map((urlPattern) => ({ urlPattern, block: true })),
|
||||
...PAID_PREVIEW_PATHS.map((path) => ({
|
||||
urlPattern: preview + path,
|
||||
block: true,
|
||||
})),
|
||||
{ urlPattern: preview + '/*', block: false },
|
||||
...fixtures.map((urlPattern) => ({ urlPattern, block: false })),
|
||||
...tileHosts.map((host) => ({
|
||||
urlPattern: `https://${host}/*`,
|
||||
block: false,
|
||||
})),
|
||||
{ urlPattern: '*://*:*/*', block: true },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
blockPaidQaMaps,
|
||||
KEYLESS_PROBES,
|
||||
probePattern,
|
||||
strictBlockPatterns,
|
||||
} from './qa-map-policy.mjs';
|
||||
|
||||
test('routine QA blocks direct Google tiles, hosted Google assets and token fallback', async () => {
|
||||
const calls = [];
|
||||
const page = {
|
||||
createCDPSession: async () => ({
|
||||
send: async (...args) => calls.push(args),
|
||||
}),
|
||||
};
|
||||
await blockPaidQaMaps(page);
|
||||
assert.equal(calls[0][0], 'Network.enable');
|
||||
assert.equal(calls[1][0], 'Network.setBlockedURLs');
|
||||
assert.ok(calls[1][1].urls.includes('*://tile.googleapis.com/*'));
|
||||
assert.ok(
|
||||
calls[1][1].urls.includes('*://api.cesium.com/v1/assets/2275207/*'),
|
||||
);
|
||||
assert.ok(calls[1][1].urls.includes('*/api/google/tiles*'));
|
||||
});
|
||||
|
||||
test('a necessary 3D-specific check must explicitly opt out of the request block', async () => {
|
||||
await blockPaidQaMaps(
|
||||
{ createCDPSession: () => assert.fail('unexpected block') },
|
||||
{ allowPaidMaps: true },
|
||||
);
|
||||
});
|
||||
|
||||
test('the strict policy blocks paid hosts and routes first and everything unlisted last', () => {
|
||||
const patterns = strictBlockPatterns('http://127.0.0.1:4208', {
|
||||
fixtures: ['https://tiles.dontgetflocked.com/*'],
|
||||
});
|
||||
const decide = (url) =>
|
||||
patterns.find(({ urlPattern }) => new URLPattern(urlPattern).test(url))
|
||||
?.block;
|
||||
for (const url of [
|
||||
'https://tile.googleapis.com/v1/3dtiles/root.json',
|
||||
'https://maps.googleapis.com/maps/api/js',
|
||||
'https://fonts.gstatic.com/s/inter.woff2',
|
||||
'https://api.cesium.com/v1/assets/1/endpoint',
|
||||
'https://assets.ion.cesium.com/google-credit.png',
|
||||
'http://127.0.0.1:4208/api/openai/hud-summary',
|
||||
'http://127.0.0.1:4208/api/realtime/token',
|
||||
'http://127.0.0.1:4208/api/google/nearby-places?lat=1',
|
||||
'https://example.com/anything',
|
||||
'wss://example.com/socket',
|
||||
])
|
||||
assert.equal(decide(url), true, url);
|
||||
for (const url of [
|
||||
'http://127.0.0.1:4208/assets/index.js',
|
||||
'http://127.0.0.1:4208/api/flights',
|
||||
'https://tiles.dontgetflocked.com/cameras-us-hourly/1/2/3.mvt',
|
||||
'https://tile.openstreetmap.org/1/2/3.png',
|
||||
'https://terrain.reearth.land/cesium-mesh/layer.json',
|
||||
'https://tiles.maps.eox.at/wmts/1.0.0/s2/1/2/3.jpg',
|
||||
])
|
||||
assert.equal(decide(url), false, url);
|
||||
});
|
||||
|
||||
test('the keyless probes are let through only to their exact routes', () => {
|
||||
const patterns = strictBlockPatterns('http://127.0.0.1:4208');
|
||||
const decide = (url) =>
|
||||
patterns.find(({ urlPattern }) => new URLPattern(urlPattern).test(url))
|
||||
?.block;
|
||||
for (const probe of KEYLESS_PROBES) {
|
||||
const url = probePattern('http://127.0.0.1:4208', probe).replace(
|
||||
/\*$/,
|
||||
'?family=Inter',
|
||||
);
|
||||
assert.equal(decide(url), false, url);
|
||||
}
|
||||
assert.equal(
|
||||
decide('http://127.0.0.1:4208/api/openai/hud-summary'),
|
||||
true,
|
||||
'the paid summary itself stays blocked',
|
||||
);
|
||||
assert.equal(decide('https://fonts.googleapis.com/icon?family=x'), true);
|
||||
});
|
||||
@@ -316,9 +316,9 @@ try {
|
||||
controls: document.getElementById('control-panel-toggle')?.getAttribute('aria-controls'),
|
||||
}));
|
||||
check(
|
||||
'exact five-source presentation; the retired left Map Stack panel is gone',
|
||||
'exact six-source presentation; the retired left Map Stack panel is gone',
|
||||
JSON.stringify(presentation.ids) === JSON.stringify([
|
||||
'photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm',
|
||||
'photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'sentinel2', 'osm',
|
||||
]) && !presentation.retiredPanel,
|
||||
JSON.stringify(presentation),
|
||||
);
|
||||
@@ -338,12 +338,16 @@ try {
|
||||
while (!document.body.innerText.includes('Powered by Esri') && performance.now() < creditDeadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
const provider = controller._activeImageryProvider;
|
||||
// The base imagery the controller draws now (it is handed to the globe
|
||||
// engine, src/maps/controller.js).
|
||||
const liveProvider = () =>
|
||||
controller._liveBaseProvider?.() ?? controller._activeImageryProvider;
|
||||
const provider = liveProvider();
|
||||
const before = {
|
||||
activeId: controller.getActiveId(),
|
||||
creditVisible: document.body.innerText.includes('Powered by Esri'),
|
||||
globeShown: styleManager.viewer.scene.globe.show,
|
||||
hasLayer: Boolean(controller._imageryLayer),
|
||||
hasLayer: Boolean(liveProvider() ?? controller._imageryLayer),
|
||||
};
|
||||
provider?.errorEvent?.raiseEvent?.({ timesRetried: 0 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
@@ -370,7 +374,7 @@ try {
|
||||
lastError: controller.getState().lastError,
|
||||
creditVisible: document.body.innerText.includes('Powered by Esri'),
|
||||
globeShown: styleManager.viewer.scene.globe.show,
|
||||
hasLayer: Boolean(controller._imageryLayer),
|
||||
hasLayer: Boolean(liveProvider() ?? controller._imageryLayer),
|
||||
active: [...document.querySelectorAll('.map-stack-chip')]
|
||||
.filter((chip) => chip.getAttribute('aria-pressed') === 'true')
|
||||
.map((chip) => chip.dataset.stackId),
|
||||
@@ -564,9 +568,16 @@ try {
|
||||
await page.keyboard.press('Enter');
|
||||
await waitTray('true', keyboardSource);
|
||||
|
||||
// Visual Styles precede the five Map Source tiles. Keep the existing opening
|
||||
// handoff to selected OSM, then use the user's real Shift+Tab path into styles.
|
||||
await pressTabs(5, true);
|
||||
// Visual Styles precede the Map Source section: its globe-engine picker
|
||||
// and tiles (OSM is the last). Keep the existing opening handoff to
|
||||
// selected OSM, then use the user's real Shift+Tab path into styles: one
|
||||
// press per control of that section.
|
||||
const mapSourceControls = await page.evaluate(
|
||||
() =>
|
||||
document.querySelectorAll('.map-stack-chip').length +
|
||||
(document.getElementById('globe-engine') ? 1 : 0),
|
||||
);
|
||||
await pressTabs(mapSourceControls, true);
|
||||
const noirFocus = await styleFocusMetrics();
|
||||
check('Shift+Tab reaches Noir with a visible ring while Normal and OSM stay selected',
|
||||
hasVisibleStyleFocus(noirFocus, 'noir') && !noirFocus.selected
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env node
|
||||
// Annotations on both engines, under the strict network policy: the same
|
||||
// board (a hand-drawn area, a pin in view and a pin on the far side of the
|
||||
// globe) is drawn on Navara, survives a switch to Cesium and back, and on
|
||||
// each engine the draped area paints amber pixels on the canvas, the near
|
||||
// pin's callout shows and the far pin is culled at the horizon.
|
||||
//
|
||||
// node scripts/qa-navara-annotations.mjs http://127.0.0.1:<PORT>/
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
const LAT = 37.7749;
|
||||
const LON = -122.4194;
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0';
|
||||
base.hash = `v=2&lat=${LAT}&lon=${LON}&alt=7000&heading=0&pitch=-90&roll=0&map=osm&l=`;
|
||||
const strict = createStrict({ origin: base.origin, layers: ['annotations'] });
|
||||
const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-annotations';
|
||||
fs.mkdirSync(SHOTS, { recursive: true });
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
// The area: a box around the view centre, clear of the panels.
|
||||
const RING = [
|
||||
[LON - 0.012, LAT - 0.009],
|
||||
[LON + 0.012, LAT - 0.009],
|
||||
[LON + 0.012, LAT + 0.009],
|
||||
[LON - 0.012, LAT + 0.009],
|
||||
];
|
||||
|
||||
await strict.run(
|
||||
{
|
||||
args: [
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
],
|
||||
},
|
||||
async (browser) => {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 860 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.goto(base.href);
|
||||
await waitForEngine(page, 'navara');
|
||||
assert.equal(
|
||||
await page.evaluate(() =>
|
||||
Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__),
|
||||
),
|
||||
false,
|
||||
'keyless preview required',
|
||||
);
|
||||
|
||||
/** Amber pixels of the globe canvas alone (the SVG board hidden). */
|
||||
const canvasAmber = async () => {
|
||||
await page.addStyleTag({
|
||||
content: '.gev-screen-whiteboard{visibility:hidden!important}',
|
||||
});
|
||||
await page.evaluate(() => window.__godsEyeView.requestRender?.('qa'));
|
||||
await wait(700);
|
||||
const { data, info } = await sharp(
|
||||
await page.screenshot({
|
||||
clip: { x: 340, y: 180, width: 600, height: 500 },
|
||||
}),
|
||||
)
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
await page.evaluate(() =>
|
||||
document
|
||||
.querySelectorAll('style')
|
||||
.forEach(
|
||||
(style) =>
|
||||
style.textContent.includes('gev-screen-whiteboard{visibility') &&
|
||||
style.remove(),
|
||||
),
|
||||
);
|
||||
let amber = 0;
|
||||
for (let i = 0; i < data.length; i += info.channels) {
|
||||
const [r, g, b] = [data[i], data[i + 1], data[i + 2]];
|
||||
if (r > 150 && g > 90 && g < 225 && b < 150 && r - b > 60 && r - g > 15)
|
||||
amber++;
|
||||
}
|
||||
return amber;
|
||||
};
|
||||
/** The callout texts the page board shows, and those it hides. */
|
||||
const board = () =>
|
||||
page.evaluate(() => {
|
||||
const out = { shown: [], hidden: [] };
|
||||
for (const group of document.querySelectorAll(
|
||||
'.gev-screen-whiteboard-svg g.gev-anno',
|
||||
)) {
|
||||
const text = group.querySelector('.gev-anno-text')?.textContent;
|
||||
if (!text) continue;
|
||||
const hidden =
|
||||
group.style.display === 'none' ||
|
||||
getComputedStyle(group).display === 'none';
|
||||
(hidden ? out.hidden : out.shown).push(text);
|
||||
}
|
||||
out.shown.sort();
|
||||
out.hidden.sort();
|
||||
return out;
|
||||
});
|
||||
|
||||
let round = 0;
|
||||
const drawn = await page.evaluate(
|
||||
async (ring, lat, lon) => {
|
||||
const annotations = window.__gevAnnotations;
|
||||
window.qaBoard = annotations;
|
||||
const result = await annotations.annotate(
|
||||
[
|
||||
{
|
||||
type: 'area',
|
||||
manual: true,
|
||||
ring,
|
||||
label: 'QA Area',
|
||||
color: 'amber',
|
||||
},
|
||||
{
|
||||
type: 'pin',
|
||||
manual: true,
|
||||
latitude: lat + 0.004,
|
||||
longitude: lon + 0.006,
|
||||
label: 'QA Near',
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
type: 'pin',
|
||||
manual: true,
|
||||
latitude: -lat,
|
||||
longitude: lon + 180,
|
||||
label: 'QA Far',
|
||||
color: 'cyan',
|
||||
},
|
||||
],
|
||||
{ persist: true, flyTo: false },
|
||||
);
|
||||
return {
|
||||
drawn: result.drawn,
|
||||
attached: annotations.attached,
|
||||
handle: window.__godsEyeView.annotations === annotations,
|
||||
};
|
||||
},
|
||||
RING,
|
||||
LAT,
|
||||
LON,
|
||||
);
|
||||
await strict.check(
|
||||
page,
|
||||
'annotations',
|
||||
'the board draws three marks on the attached engine and is the debug handle',
|
||||
drawn.drawn === 3 && drawn.attached && drawn.handle,
|
||||
drawn,
|
||||
);
|
||||
|
||||
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||
if (
|
||||
(await page.evaluate(() => window.__godsEyeView.engine.kind)) !== kind
|
||||
) {
|
||||
await strict.switchTo(page, kind);
|
||||
}
|
||||
await wait(2500);
|
||||
const state = await page.evaluate(() => ({
|
||||
same: window.__gevAnnotations === window.qaBoard,
|
||||
count: window.qaBoard.count(),
|
||||
attached: window.qaBoard.attached,
|
||||
handle: window.__godsEyeView.annotations === window.qaBoard,
|
||||
}));
|
||||
await strict.check(
|
||||
page,
|
||||
'annotations',
|
||||
'the same session board keeps its three marks and draws on this engine',
|
||||
state.same && state.count === 3 && state.attached && state.handle,
|
||||
state,
|
||||
);
|
||||
const seen = await board();
|
||||
console.log(kind, 'board', JSON.stringify(seen));
|
||||
await strict.check(
|
||||
page,
|
||||
'annotations',
|
||||
'the near callouts show and the far pin is culled at the horizon',
|
||||
seen.shown.includes('QA Near') &&
|
||||
seen.shown.includes('QA Area') &&
|
||||
!seen.shown.includes('QA Far'),
|
||||
seen,
|
||||
);
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/annotations-${kind}-${round}.png`,
|
||||
});
|
||||
const amber = await canvasAmber();
|
||||
// The same view without the marks (hidden from the list).
|
||||
const setHidden = (hidden) =>
|
||||
page.evaluate((h) => {
|
||||
for (const mark of window.qaBoard.list())
|
||||
window.qaBoard.setHidden(mark.id, h);
|
||||
}, hidden);
|
||||
await setHidden(true);
|
||||
const bare = await canvasAmber();
|
||||
await setHidden(false);
|
||||
console.log(kind, 'amber pixels', { bare, amber });
|
||||
await strict.check(
|
||||
page,
|
||||
'annotations',
|
||||
'the draped area paints amber on the globe canvas',
|
||||
amber - bare > 400,
|
||||
{ bare, amber },
|
||||
);
|
||||
round++;
|
||||
}
|
||||
|
||||
// Fly-to a mark goes through the engine's camera on Navara.
|
||||
const flight = await page.evaluate(async () => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const before = engine.camera.height();
|
||||
const area = window.qaBoard.list().find((m) => m.label === 'QA Area');
|
||||
const ok = window.qaBoard.frame(area.id);
|
||||
await new Promise((r) => setTimeout(r, 2600));
|
||||
return { ok, before, after: engine.camera.height() };
|
||||
});
|
||||
await strict.check(
|
||||
page,
|
||||
'annotations',
|
||||
'framing a mark flies the Navara camera to it',
|
||||
flight.ok && Math.abs(flight.after - flight.before) > 100,
|
||||
flight,
|
||||
);
|
||||
// Hidden from the list: the board and the canvas let it go.
|
||||
const hidden = await page.evaluate(async () => {
|
||||
for (const mark of window.qaBoard.list())
|
||||
window.qaBoard.setHidden(mark.id, true);
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
return window.qaBoard.list().every((m) => m.hidden);
|
||||
});
|
||||
const afterHide = await board();
|
||||
await strict.check(
|
||||
page,
|
||||
'annotations',
|
||||
'hidden marks leave the board',
|
||||
hidden && afterHide.shown.length === 0,
|
||||
afterHide,
|
||||
);
|
||||
await page.evaluate(() => window.qaBoard.clear());
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS annotations drawn, culled and kept across engine switches',
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,202 @@
|
||||
// The app's Bloom on Navara: the Display bloom glows the
|
||||
// Navara frame (brighter, more at a higher slider), it sits after the
|
||||
// overlays and before the visual styles, Cesium's dead zone draws nothing,
|
||||
// turning it off restores the frame exactly, and on Cesium the scene's own
|
||||
// bloom stage still runs with the engine left alone. Keyless preview under
|
||||
// the strict default-deny network policy.
|
||||
//
|
||||
// node scripts/qa-navara-bloom.mjs http://127.0.0.1:<PORT>/ [--out=DIR]
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
import {
|
||||
capture,
|
||||
checker,
|
||||
diff,
|
||||
freeze,
|
||||
pickStyle,
|
||||
pinDaytime,
|
||||
saveCapture,
|
||||
stats,
|
||||
visualPageUrl,
|
||||
} from './qa/navaraVisual.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
const outDir = process.argv
|
||||
.find((arg) => arg.startsWith('--out='))
|
||||
?.slice('--out='.length);
|
||||
const strict = createStrict({ origin: base.origin, layers: [] });
|
||||
const report = { checks: [] };
|
||||
const check = checker(report, assert);
|
||||
const save = (name, pixels) => saveCapture(outDir, name, pixels);
|
||||
const luma = (s) => 0.299 * s.r + 0.587 * s.g + 0.114 * s.b;
|
||||
|
||||
// The Display bloom, as its toggle and slider set it.
|
||||
const setBloom = (page, enabled, intensityPct) =>
|
||||
page.evaluate(
|
||||
(enabled, intensityPct) => {
|
||||
const a = window.__godsEyeView;
|
||||
a.styleManager.setBloom({ enabled, intensityPct });
|
||||
return {
|
||||
control: a.styleManager.getControlState().bloom,
|
||||
engine: a.engine.render.bloom?.() ?? null,
|
||||
pipeline: a.engine.pipeline?.() ?? null,
|
||||
};
|
||||
},
|
||||
enabled,
|
||||
intensityPct,
|
||||
);
|
||||
|
||||
await strict.run({}, async (browser) => {
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
// The same sun on every run: the frame's brightness decides what blooms
|
||||
// and what each style's signature reads.
|
||||
await pinDaytime(page);
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
});
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.goto(visualPageUrl(base.origin, 'navara'), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await waitForEngine(page, 'navara');
|
||||
await freeze(page);
|
||||
|
||||
const initial = await page.evaluate(() => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
return { bloom: engine.render.bloom(), pipeline: engine.pipeline() };
|
||||
});
|
||||
check(
|
||||
'Navara blooms itself and starts with bloom off',
|
||||
initial.bloom.enabled === false && !initial.pipeline?.bloomShown,
|
||||
initial,
|
||||
);
|
||||
const normal = await capture(page);
|
||||
save('normal', normal);
|
||||
const normalStats = stats(normal);
|
||||
|
||||
const full = await setBloom(page, true, 200);
|
||||
check(
|
||||
'the slider at 200 reaches the engine at full strength',
|
||||
full.engine.enabled === true &&
|
||||
full.engine.strength === 1 &&
|
||||
full.pipeline.bloomShown === true,
|
||||
full,
|
||||
);
|
||||
const order = full.pipeline.passes ?? [];
|
||||
const at = order.indexOf('gevBloom');
|
||||
check(
|
||||
'bloom runs after the scene and overlays, just before the final copy',
|
||||
at >= 0 &&
|
||||
order[at + 1] === 'final' &&
|
||||
['toneMapping', 'gevSharpen', 'gevOverlay', 'smaa', 'fxaa'].every(
|
||||
(key) => !order.includes(key) || order.indexOf(key) < at,
|
||||
),
|
||||
order,
|
||||
);
|
||||
const glowFull = await capture(page);
|
||||
save('bloom-200', glowFull);
|
||||
const fullChange = diff(normal, glowFull);
|
||||
const fullStats = stats(glowFull);
|
||||
check(
|
||||
'bloom at 200 glows the frame brighter',
|
||||
fullChange.mean > 3 && luma(fullStats) > luma(normalStats) + 2,
|
||||
{
|
||||
...fullChange,
|
||||
normalLuma: luma(normalStats),
|
||||
bloomLuma: luma(fullStats),
|
||||
},
|
||||
);
|
||||
|
||||
await setBloom(page, true, 100);
|
||||
const glowHalf = await capture(page);
|
||||
save('bloom-100', glowHalf);
|
||||
const halfChange = diff(normal, glowHalf);
|
||||
check(
|
||||
'a lower slider glows less',
|
||||
halfChange.mean > 0.5 && halfChange.mean < fullChange.mean,
|
||||
{ half: halfChange.mean, full: fullChange.mean },
|
||||
);
|
||||
|
||||
const dead = await setBloom(page, true, 10);
|
||||
const deadFrame = await capture(page);
|
||||
check(
|
||||
"Cesium's dead zone: the toggle on, nothing glows",
|
||||
dead.engine.enabled === false &&
|
||||
dead.pipeline.bloomShown === false &&
|
||||
diff(normal, deadFrame).mean === 0,
|
||||
{ engine: dead.engine, diff: diff(normal, deadFrame) },
|
||||
);
|
||||
|
||||
// With a style: bloom first, then the style, then the final copy.
|
||||
await setBloom(page, true, 200);
|
||||
const styled = await pickStyle(page, 'noir', 'normal');
|
||||
const styledOrder = styled.pipeline?.passes ?? [];
|
||||
check(
|
||||
'bloom runs before the visual styles',
|
||||
styledOrder.indexOf('gevBloom') >= 0 &&
|
||||
styledOrder.indexOf('gevBloom') + 1 ===
|
||||
styledOrder.indexOf('gevStyles') &&
|
||||
styledOrder.indexOf('gevStyles') + 1 === styledOrder.indexOf('final'),
|
||||
styledOrder,
|
||||
);
|
||||
await pickStyle(page, 'normal', 'noir');
|
||||
|
||||
const off = await setBloom(page, false, 200);
|
||||
const offFrame = await capture(page);
|
||||
check(
|
||||
'turning bloom off restores the frame',
|
||||
off.engine.enabled === false &&
|
||||
off.pipeline.bloomShown === false &&
|
||||
diff(normal, offFrame).mean === 0,
|
||||
{ engine: off.engine, diff: diff(normal, offFrame) },
|
||||
);
|
||||
|
||||
// Cesium keeps its scene bloom stage; its engine has no bloom.
|
||||
await strict.switchTo(page, 'cesium');
|
||||
await freeze(page);
|
||||
await setBloom(page, false, 200);
|
||||
const cesiumNormal = await capture(page);
|
||||
const cesium = await setBloom(page, true, 200);
|
||||
const cesiumStage = await page.evaluate(() => {
|
||||
const a = window.__godsEyeView;
|
||||
const stage = a.viewer?.scene?.postProcessStages?.bloom;
|
||||
return {
|
||||
setBloom: typeof a.engine.render.setBloom,
|
||||
enabled: stage?.enabled ?? null,
|
||||
contrast: stage?.uniforms?.contrast ?? null,
|
||||
};
|
||||
});
|
||||
const cesiumGlow = await capture(page);
|
||||
save('cesium-bloom-200', cesiumGlow);
|
||||
check(
|
||||
"Cesium: its scene's bloom stage glows, the engine draws none",
|
||||
cesium.engine === null &&
|
||||
cesiumStage.setBloom === 'undefined' &&
|
||||
cesiumStage.enabled !== false &&
|
||||
diff(cesiumNormal, cesiumGlow).mean > 1,
|
||||
{ stage: cesiumStage, diff: diff(cesiumNormal, cesiumGlow) },
|
||||
);
|
||||
await setBloom(page, false, 200);
|
||||
const cesiumBack = diff(cesiumNormal, await capture(page));
|
||||
check(
|
||||
'Cesium: bloom off restores its frame',
|
||||
cesiumBack.mean < 0.5,
|
||||
cesiumBack,
|
||||
);
|
||||
|
||||
check('no page errors', errors.length === 0, errors);
|
||||
if (outDir)
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, 'bloom.json'),
|
||||
JSON.stringify(report, null, 2),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
// The cockpit weather clouds on Navara: in cockpit mode with
|
||||
// the cockpit weather on, Navara's own volumetric clouds take their cover
|
||||
// from the weather under the camera (clear: none; overcast: a heavy deck),
|
||||
// with no second canvas or WebGL context; leaving the cockpit gives the look
|
||||
// its own clouds back. On Cesium the cockpit keeps its own cloud canvas and
|
||||
// the engine draws none. The weather route is a fixture, the page clock is
|
||||
// shifted to an Austin midday (the look shows clouds only in daylight), and
|
||||
// cockpit mode is entered through its event (the cloud controllers listen
|
||||
// to it; a real cockpit needs a tracked aircraft). Keyless preview under the
|
||||
// strict default-deny network policy.
|
||||
//
|
||||
// node scripts/qa-navara-cockpit-clouds.mjs http://127.0.0.1:<PORT>/ [--out=DIR]
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
import {
|
||||
capture,
|
||||
checker,
|
||||
diff,
|
||||
freeze,
|
||||
saveCapture,
|
||||
stats,
|
||||
visualPageUrl,
|
||||
} from './qa/navaraVisual.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
const outDir = process.argv
|
||||
.find((arg) => arg.startsWith('--out='))
|
||||
?.slice('--out='.length);
|
||||
const LOW_VIEW =
|
||||
'v=2&lat=30.2672&lon=-97.7431&alt=2500&heading=90&pitch=-8&map=osm&l=';
|
||||
const MIDDAY_UTC = '2026-07-15T18:00:00Z';
|
||||
const WEATHER = {
|
||||
clear: { weatherCode: 0, cloudCoverPct: 0, windKph: 6, visibilityM: 20000 },
|
||||
overcast: {
|
||||
weatherCode: 3,
|
||||
cloudCoverPct: 100,
|
||||
windKph: 25,
|
||||
visibilityM: 15000,
|
||||
},
|
||||
};
|
||||
let weather = WEATHER.clear;
|
||||
const asked = [];
|
||||
const strict = createStrict({
|
||||
origin: base.origin,
|
||||
layers: [],
|
||||
routes: [
|
||||
{
|
||||
pattern: `${base.origin}/api/weather-effects*`,
|
||||
respond: (request) => {
|
||||
asked.push(request.url);
|
||||
return {
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'live', weather }),
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const report = { checks: [] };
|
||||
const check = checker(report, assert);
|
||||
const save = (name, pixels) => saveCapture(outDir, name, pixels);
|
||||
|
||||
// Clouds grey the frame: the blue sky and the map's coloured lines go.
|
||||
const saturation = (pixels) => stats(pixels).saturation;
|
||||
|
||||
// A few seconds of continuous frames: the clouds converge over frames.
|
||||
async function converged(page, name) {
|
||||
await page.evaluate(async () => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
engine.render.setContinuous(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
engine.render.setContinuous(false);
|
||||
});
|
||||
const pixels = await capture(page);
|
||||
save(name, pixels);
|
||||
return pixels;
|
||||
}
|
||||
|
||||
const cockpit = (page, active) =>
|
||||
page.evaluate((active) => {
|
||||
document.body.classList.toggle('cockpit-mode', active);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:cockpit-mode-changed', { detail: { active } }),
|
||||
);
|
||||
}, active);
|
||||
const weatherToggle = (page, enabled) =>
|
||||
page.evaluate((enabled) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:cockpit-weather-toggle', { detail: { enabled } }),
|
||||
);
|
||||
}, enabled);
|
||||
const coverage = (page) =>
|
||||
page.evaluate(
|
||||
() => window.__godsEyeView.engine.render.cloudCover?.().coverage,
|
||||
);
|
||||
|
||||
await strict.run({}, async (browser) => {
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.evaluateOnNewDocument((utc) => {
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
const offset = Date.parse(utc) - Date.now();
|
||||
const RealDate = Date;
|
||||
class ShiftedDate extends RealDate {
|
||||
constructor(...args) {
|
||||
if (args.length === 0) super(RealDate.now() + offset);
|
||||
else super(...args);
|
||||
}
|
||||
static now() {
|
||||
return RealDate.now() + offset;
|
||||
}
|
||||
}
|
||||
globalThis.Date = ShiftedDate;
|
||||
}, MIDDAY_UTC);
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.goto(visualPageUrl(base.origin, 'navara', '', LOW_VIEW), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await waitForEngine(page, 'navara');
|
||||
await freeze(page);
|
||||
|
||||
const setup = await page.evaluate(() => {
|
||||
const a = window.__godsEyeView;
|
||||
return {
|
||||
cover: a.engine.render.cloudCover?.() ?? null,
|
||||
canvas: Boolean(document.getElementById('cockpit-cloud-effects')),
|
||||
canvases: document.querySelectorAll('canvas').length,
|
||||
};
|
||||
});
|
||||
check(
|
||||
'Navara takes the cockpit cloud cover itself, no cloud canvas',
|
||||
setup.cover?.coverage === null && !setup.canvas,
|
||||
setup,
|
||||
);
|
||||
const lookOwn = await converged(page, 'look-own');
|
||||
|
||||
await weatherToggle(page, true);
|
||||
await cockpit(page, true);
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.engine.render.cloudCover().coverage === 0,
|
||||
{ timeout: 10000, polling: 100 },
|
||||
);
|
||||
check('clear weather in the cockpit: no cloud cover', asked.length >= 1, {
|
||||
requests: asked.length,
|
||||
});
|
||||
const clear = await converged(page, 'cockpit-clear');
|
||||
|
||||
// Overcast 30 km east: the movement makes the weather stale.
|
||||
weather = WEATHER.overcast;
|
||||
await page.evaluate(() => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const place = engine.camera.pose().cartographic;
|
||||
const east = place.longitude + 30000 / (6378137 * Math.cos(place.latitude));
|
||||
const { sin, cos } = Math;
|
||||
// WGS84 Earth-fixed position of (east, latitude, height).
|
||||
const a = 6378137;
|
||||
const e2 = 0.00669437999014;
|
||||
const n = a / Math.sqrt(1 - e2 * sin(place.latitude) ** 2);
|
||||
const h = place.height;
|
||||
engine.camera.setView({
|
||||
destination: {
|
||||
x: (n + h) * cos(place.latitude) * cos(east),
|
||||
y: (n + h) * cos(place.latitude) * sin(east),
|
||||
z: (n * (1 - e2) + h) * sin(place.latitude),
|
||||
},
|
||||
orientation: { heading: Math.PI / 2, pitch: -0.14, roll: 0 },
|
||||
});
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.engine.render.cloudCover().coverage > 0.4,
|
||||
{ timeout: 15000, polling: 100 },
|
||||
);
|
||||
const overcastCover = await coverage(page);
|
||||
const overcast = await converged(page, 'cockpit-overcast');
|
||||
const greyed = {
|
||||
lookOwn: saturation(lookOwn),
|
||||
clear: saturation(clear),
|
||||
overcast: saturation(overcast),
|
||||
clearToOvercast: diff(clear, overcast).mean,
|
||||
};
|
||||
check(
|
||||
'overcast weather covers the view with Navara clouds, clear leaves it open',
|
||||
overcastCover > 0.4 &&
|
||||
greyed.overcast < greyed.clear * 0.6 &&
|
||||
greyed.lookOwn < greyed.clear &&
|
||||
greyed.clearToOvercast > 15,
|
||||
{ overcastCover, greyed, requests: asked.length },
|
||||
);
|
||||
const contexts = await page.evaluate(() => ({
|
||||
canvas: Boolean(document.getElementById('cockpit-cloud-effects')),
|
||||
canvases: document.querySelectorAll('canvas').length,
|
||||
}));
|
||||
check(
|
||||
'no second canvas or WebGL context in the cockpit',
|
||||
!contexts.canvas && contexts.canvases === setup.canvases,
|
||||
{ before: setup.canvases, after: contexts },
|
||||
);
|
||||
|
||||
await cockpit(page, false);
|
||||
check(
|
||||
'leaving the cockpit gives the look its own clouds back',
|
||||
(await coverage(page)) === null,
|
||||
);
|
||||
|
||||
// Cesium keeps its own cockpit cloud canvas; its engine draws none.
|
||||
await strict.switchTo(page, 'cesium');
|
||||
await freeze(page);
|
||||
weather = WEATHER.overcast;
|
||||
await cockpit(page, true);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
Number(
|
||||
document.getElementById('cockpit-cloud-effects')?.dataset.cloudStrength,
|
||||
) > 0.3,
|
||||
{ timeout: 15000, polling: 100 },
|
||||
);
|
||||
const cesium = await page.evaluate(() => {
|
||||
const canvas = document.getElementById('cockpit-cloud-effects');
|
||||
return {
|
||||
setCloudCover: typeof window.__godsEyeView.engine.render.setCloudCover,
|
||||
strength: Number(canvas.dataset.cloudStrength),
|
||||
cockpit: canvas.dataset.cockpit,
|
||||
};
|
||||
});
|
||||
check(
|
||||
'Cesium: the cockpit draws its own cloud canvas, the engine none',
|
||||
cesium.setCloudCover === 'undefined' && cesium.cockpit === 'true',
|
||||
cesium,
|
||||
);
|
||||
await cockpit(page, false);
|
||||
await weatherToggle(page, false);
|
||||
|
||||
check('no page errors', errors.length === 0, errors);
|
||||
if (outDir)
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, 'cockpit-clouds.json'),
|
||||
JSON.stringify(report, null, 2),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env node
|
||||
// UTILITIES ▸ Draw on Navara under the strict network policy, with real
|
||||
// clicks and keys: Draw takes the pointer, clicks on the globe are vertices
|
||||
// with a live rubber band, a double click finishes an area that paints on
|
||||
// the canvas and joins the session board, Enter finishes a line, Escape
|
||||
// cancels a sketch and then leaves Draw, and a switch to Cesium in the
|
||||
// middle of a sketch ends it (the pointer is free, shape and colour kept);
|
||||
// the shapes drawn on either engine stay on the board and draw on both.
|
||||
//
|
||||
// node scripts/qa-navara-draw.mjs http://127.0.0.1:<PORT>/
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import sharp from 'sharp';
|
||||
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||
|
||||
const VIEW = { lat: 37.7749, lon: -122.4194, alt: 6000 };
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0';
|
||||
base.hash = `v=2&lat=${VIEW.lat}&lon=${VIEW.lon}&alt=${VIEW.alt}&heading=0&pitch=-90&roll=0&map=osm&l=`;
|
||||
const strict = createStrict({ origin: base.origin, layers: ['draw'] });
|
||||
const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-draw';
|
||||
fs.mkdirSync(SHOTS, { recursive: true });
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
// An area's corners, clear of the open UTILITIES panel and the rails.
|
||||
const AREA = [
|
||||
[560, 330],
|
||||
[760, 330],
|
||||
[760, 500],
|
||||
[560, 500],
|
||||
];
|
||||
|
||||
await strict.run(
|
||||
{
|
||||
args: [
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
],
|
||||
},
|
||||
async (browser) => {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 860 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.goto(base.href);
|
||||
const ready = async (kind) => {
|
||||
await page.waitForFunction(
|
||||
(k) =>
|
||||
window.__godsEyeView?.engine?.kind === k &&
|
||||
window.__gevDrawTool?.attached &&
|
||||
!document.querySelector('#globe-engine')?.disabled &&
|
||||
!document.querySelector('.engine-switch-frame') &&
|
||||
document.querySelector('#loading-screen.hidden'),
|
||||
{ timeout: 120000 },
|
||||
kind,
|
||||
);
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||
);
|
||||
};
|
||||
await ready('navara');
|
||||
assert.equal(
|
||||
await page.evaluate(() =>
|
||||
Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__),
|
||||
),
|
||||
false,
|
||||
'keyless preview required',
|
||||
);
|
||||
const tool = () =>
|
||||
page.evaluate(() => {
|
||||
const draw = window.__gevDrawTool;
|
||||
return {
|
||||
active: draw.active,
|
||||
shape: draw.shape,
|
||||
color: draw.color,
|
||||
vertices: draw.session?.vertices.length ?? null,
|
||||
board: window.__gevAnnotations.count(),
|
||||
attached: window.__gevAnnotations.attached,
|
||||
diagnostics: draw.diagnostics(),
|
||||
band:
|
||||
window.__godsEyeView.engine.lines
|
||||
?.list?.()
|
||||
.find(({ id }) => id === 'gev-draw-preview') ?? null,
|
||||
};
|
||||
});
|
||||
/** Amber pixels of the globe canvas alone (the SVG board hidden). */
|
||||
const amber = async () => {
|
||||
await page.addStyleTag({
|
||||
content: '.gev-screen-whiteboard{visibility:hidden!important}',
|
||||
});
|
||||
await wait(800);
|
||||
const { data, info } = await sharp(
|
||||
await page.screenshot({
|
||||
clip: { x: 520, y: 300, width: 280, height: 230 },
|
||||
}),
|
||||
)
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
await page.evaluate(() =>
|
||||
document.querySelectorAll('style').forEach((style) => {
|
||||
if (style.textContent.includes('gev-screen-whiteboard{visibility'))
|
||||
style.remove();
|
||||
}),
|
||||
);
|
||||
let n = 0;
|
||||
for (let i = 0; i < data.length; i += info.channels) {
|
||||
const [r, g, b] = [data[i], data[i + 1], data[i + 2]];
|
||||
if (r > 150 && g > 90 && g < 225 && b < 150 && r - b > 60 && r - g > 15)
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
};
|
||||
const openUtilities = () =>
|
||||
page.evaluate(() => {
|
||||
const panel = document.getElementById('utilities-panel');
|
||||
if (panel?.classList.contains('collapsed'))
|
||||
document
|
||||
.querySelector(
|
||||
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
const drawShape = async (shape, points, finish) => {
|
||||
await page.evaluate((s) => {
|
||||
window.__gevDrawTool.setShape(s);
|
||||
window.__gevDrawTool.setColor('amber');
|
||||
}, shape);
|
||||
for (const [x, y] of points) {
|
||||
await page.mouse.click(x, y);
|
||||
await wait(120);
|
||||
}
|
||||
await finish();
|
||||
await wait(800);
|
||||
};
|
||||
|
||||
await openUtilities();
|
||||
await page.waitForSelector('#draw-toggle', { visible: true });
|
||||
const bare = await amber();
|
||||
await page.click('#draw-toggle');
|
||||
await page.waitForFunction(() => window.__gevDrawTool.active);
|
||||
const on = await page.evaluate(() => ({
|
||||
owner: document.body.classList.contains('gev-drawing'),
|
||||
}));
|
||||
// Three corners, then the rubber band to the fourth under the pointer.
|
||||
await page.evaluate(() => {
|
||||
window.__gevDrawTool.setShape('area');
|
||||
window.__gevDrawTool.setColor('amber');
|
||||
});
|
||||
// Each click is a vertex (the rubber band under the pointer never
|
||||
// swallows one).
|
||||
for (const [index, [x, y]] of AREA.slice(0, 3).entries()) {
|
||||
await page.mouse.click(x, y);
|
||||
await page.waitForFunction(
|
||||
(n) => window.__gevDrawTool.session?.vertices.length === n,
|
||||
{ timeout: 3000 },
|
||||
index + 1,
|
||||
);
|
||||
await wait(150);
|
||||
}
|
||||
await page.mouse.move(...AREA[3]);
|
||||
await wait(300);
|
||||
const sketch = await tool();
|
||||
await page.screenshot({ path: `${SHOTS}/navara-sketch.png` });
|
||||
await strict.check(
|
||||
page,
|
||||
'draw',
|
||||
'Draw takes the pointer; clicks on the globe are vertices with a live rubber band',
|
||||
on.owner &&
|
||||
sketch.active &&
|
||||
sketch.vertices === 3 &&
|
||||
sketch.diagnostics.sceneHandler &&
|
||||
sketch.band?.shown === true,
|
||||
sketch,
|
||||
);
|
||||
await page.mouse.click(AREA[3][0], AREA[3][1], { count: 2 });
|
||||
await page.waitForFunction(() => window.__gevAnnotations.count() === 1, {
|
||||
timeout: 10000,
|
||||
});
|
||||
await wait(800);
|
||||
const drawn = await amber();
|
||||
await page.screenshot({ path: `${SHOTS}/navara-area.png` });
|
||||
const area = await page.evaluate(() =>
|
||||
window.__gevAnnotations.list().map((m) => [m.type, m.color, m.manual]),
|
||||
);
|
||||
await strict.check(
|
||||
page,
|
||||
'draw',
|
||||
'a double click finishes the area: it joins the board and paints amber on Navara',
|
||||
area.length === 1 && area[0][0] === 'area' && drawn - bare > 1500,
|
||||
{ area, bare, drawn },
|
||||
);
|
||||
// Enter finishes a line.
|
||||
await drawShape(
|
||||
'line',
|
||||
[
|
||||
[600, 560],
|
||||
[760, 600],
|
||||
],
|
||||
() => page.keyboard.press('Enter'),
|
||||
);
|
||||
const lines = await page.evaluate(() =>
|
||||
window.__gevAnnotations.list().map((m) => m.type),
|
||||
);
|
||||
await strict.check(
|
||||
page,
|
||||
'draw',
|
||||
'Enter on the map finishes a line',
|
||||
lines.length === 2 && lines.includes('route'),
|
||||
{ lines },
|
||||
);
|
||||
// Escape: the sketch, then Draw itself.
|
||||
await page.evaluate(() => window.__gevDrawTool.setShape('line'));
|
||||
await page.mouse.click(620, 360);
|
||||
await page.mouse.click(700, 380);
|
||||
const before = await tool();
|
||||
await page.keyboard.press('Escape');
|
||||
const cancelled = await tool();
|
||||
await page.keyboard.press('Escape');
|
||||
const left = await tool();
|
||||
await strict.check(
|
||||
page,
|
||||
'draw',
|
||||
'Escape cancels the sketch, then leaves Draw and gives the pointer back',
|
||||
before.vertices === 2 &&
|
||||
cancelled.active &&
|
||||
cancelled.vertices === 0 &&
|
||||
!left.active &&
|
||||
!left.diagnostics.sceneHandler &&
|
||||
left.diagnostics.pointerOwner == null &&
|
||||
left.band?.shown !== true,
|
||||
{ before, cancelled, left },
|
||||
);
|
||||
assert.equal(left.board, 2, 'Escape keeps the finished shapes');
|
||||
|
||||
// A switch to Cesium in the middle of a sketch.
|
||||
await page.click('#draw-toggle');
|
||||
await page.waitForFunction(() => window.__gevDrawTool.active);
|
||||
await page.evaluate(() => {
|
||||
window.__gevDrawTool.setShape('line');
|
||||
window.__gevDrawTool.setColor('green');
|
||||
});
|
||||
await page.mouse.click(620, 360);
|
||||
await page.mouse.click(700, 380);
|
||||
assert.equal((await tool()).vertices, 2);
|
||||
await strict.pick(page, 'cesium');
|
||||
await ready('cesium');
|
||||
await wait(1500);
|
||||
const switched = await tool();
|
||||
await page.screenshot({ path: `${SHOTS}/cesium-after-switch.png` });
|
||||
await strict.check(
|
||||
page,
|
||||
'draw',
|
||||
'a switch mid-sketch ends the sketch, frees the pointer and keeps shape, colour and the board',
|
||||
!switched.active &&
|
||||
switched.vertices === null &&
|
||||
switched.diagnostics.pointerOwner == null &&
|
||||
switched.shape === 'line' &&
|
||||
switched.color === 'green' &&
|
||||
switched.board === 2 &&
|
||||
switched.attached,
|
||||
switched,
|
||||
);
|
||||
// Draw on Cesium too, with the same clicks.
|
||||
await openUtilities();
|
||||
await page.click('#draw-toggle');
|
||||
await page.waitForFunction(() => window.__gevDrawTool.active);
|
||||
await drawShape(
|
||||
'line',
|
||||
[
|
||||
[600, 420],
|
||||
[760, 450],
|
||||
],
|
||||
() => page.keyboard.press('Enter'),
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
const onCesium = await tool();
|
||||
await strict.check(
|
||||
page,
|
||||
'draw',
|
||||
'a shape drawn on Cesium joins the same board',
|
||||
onCesium.board === 3 && !onCesium.active,
|
||||
onCesium,
|
||||
);
|
||||
await strict.pick(page, 'navara');
|
||||
await ready('navara');
|
||||
await wait(2000);
|
||||
const back = await tool();
|
||||
const backAmber = await amber();
|
||||
await page.screenshot({ path: `${SHOTS}/navara-back.png` });
|
||||
await strict.check(
|
||||
page,
|
||||
'draw',
|
||||
'back on Navara the three shapes are on the board and the area paints again',
|
||||
back.board === 3 && back.attached && backAmber - bare > 1500,
|
||||
{ board: back.board, bare, backAmber },
|
||||
);
|
||||
await page.evaluate(() => window.__gevAnnotations.clear());
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS Draw on Navara with real clicks and keys');
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env node
|
||||
// UTILITIES ▸ Import on Navara under the strict network policy: GeoJSON,
|
||||
// KML, KMZ (a packed icon and a ground overlay) and GPX through the real
|
||||
// file input, each one row; Navara draws their shapes in the row colour and
|
||||
// their pins, counts what it cannot draw (the KMZ ground overlay) and says
|
||||
// so in the hint; hide, fly-to and remove work on the engine; the files
|
||||
// survive a switch to Cesium and back and draw on each engine. No file makes
|
||||
// the page request anything it names (the strict policy fails any request
|
||||
// outside the preview and the free tile cache).
|
||||
//
|
||||
// node scripts/qa-navara-geo-import.mjs http://127.0.0.1:<PORT>/
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||
|
||||
const VIEW = { lat: 37.7694, lon: -122.4862, alt: 4200 };
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0';
|
||||
base.hash = `v=2&lat=${VIEW.lat}&lon=${VIEW.lon}&alt=${VIEW.alt}&heading=0&pitch=-90&roll=0&map=osm&l=`;
|
||||
const strict = createStrict({ origin: base.origin, layers: ['geo-import'] });
|
||||
const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-geo-import';
|
||||
fs.mkdirSync(SHOTS, { recursive: true });
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const TRAP = 'gev-qa-trap.invalid';
|
||||
|
||||
/* ── fixtures (as scripts/qa-geo-import.mjs) ───────────────────────────── */
|
||||
const ring = [
|
||||
[-122.511, 37.7645],
|
||||
[-122.4545, 37.7645],
|
||||
[-122.4545, 37.7745],
|
||||
[-122.511, 37.7745],
|
||||
[-122.511, 37.7645],
|
||||
];
|
||||
const geojson = JSON.stringify({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
name: 'QA park',
|
||||
description: `<img src="https://${TRAP}/props.png">`,
|
||||
},
|
||||
geometry: { type: 'Polygon', coordinates: [ring] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'QA pin' },
|
||||
geometry: { type: 'Point', coordinates: [-122.4862, 37.7694] },
|
||||
},
|
||||
],
|
||||
});
|
||||
const kml = (iconHref, overlay) => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2">
|
||||
<Document>
|
||||
<Style id="s"><IconStyle><Icon><href>${iconHref}</href></Icon></IconStyle></Style>
|
||||
<NetworkLink><Link><href>https://${TRAP}/link.kml</href></Link></NetworkLink>
|
||||
<Placemark><name>QA KML pin</name><styleUrl>#s</styleUrl>
|
||||
<description><![CDATA[<img src="https://${TRAP}/balloon.png">]]></description>
|
||||
<Point><coordinates>-122.47,37.77,0</coordinates></Point></Placemark>
|
||||
<Placemark><name>QA KML line</name>
|
||||
<LineString><coordinates>-122.50,37.766 -122.46,37.772</coordinates></LineString></Placemark>
|
||||
<GroundOverlay><Icon><href>${overlay}</href></Icon>
|
||||
<LatLonBox><north>37.77</north><south>37.76</south><east>-122.46</east><west>-122.47</west></LatLonBox></GroundOverlay>
|
||||
</Document>
|
||||
</kml>`;
|
||||
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="qa" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<wpt lat="37.769" lon="-122.49"><name>QA waypoint</name></wpt>
|
||||
<trk><name>QA track</name><trkseg>
|
||||
<trkpt lat="37.766" lon="-122.505"></trkpt><trkpt lat="37.768" lon="-122.49"></trkpt>
|
||||
</trkseg></trk>
|
||||
</gpx>`;
|
||||
// 8×8 opaque white PNG.
|
||||
const PNG = await sharp({
|
||||
create: { width: 8, height: 8, channels: 4, background: '#ffffff' },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
function storedZip(files) {
|
||||
const parts = [];
|
||||
const central = [];
|
||||
let offset = 0;
|
||||
for (const [name, data] of files) {
|
||||
const n = Buffer.from(name);
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt32LE(data.length, 18);
|
||||
local.writeUInt32LE(data.length, 22);
|
||||
local.writeUInt16LE(n.length, 26);
|
||||
const c = Buffer.alloc(46);
|
||||
c.writeUInt32LE(0x02014b50, 0);
|
||||
c.writeUInt32LE(data.length, 20);
|
||||
c.writeUInt32LE(data.length, 24);
|
||||
c.writeUInt16LE(n.length, 28);
|
||||
c.writeUInt32LE(offset, 42);
|
||||
parts.push(local, n, data);
|
||||
central.push(c, n);
|
||||
offset += 30 + n.length + data.length;
|
||||
}
|
||||
const dir = Buffer.concat(central);
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
end.writeUInt16LE(files.length, 8);
|
||||
end.writeUInt16LE(files.length, 10);
|
||||
end.writeUInt32LE(dir.length, 12);
|
||||
end.writeUInt32LE(offset, 16);
|
||||
return Buffer.concat([...parts, dir, end]);
|
||||
}
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-qa-navara-import-'));
|
||||
const fixture = (name, data) => {
|
||||
const file = path.join(dir, name);
|
||||
fs.writeFileSync(file, data);
|
||||
return file;
|
||||
};
|
||||
const FILES = [
|
||||
fixture('park.geojson', geojson),
|
||||
fixture(
|
||||
'remote-bits.kml',
|
||||
kml(`https://${TRAP}/icon.png`, `https://${TRAP}/overlay.png`),
|
||||
),
|
||||
fixture(
|
||||
'packed.kmz',
|
||||
storedZip([
|
||||
['doc.kml', Buffer.from(kml('files/pin.png', 'files/overlay.png'))],
|
||||
['files/pin.png', PNG],
|
||||
['files/overlay.png', PNG],
|
||||
]),
|
||||
),
|
||||
fixture('walk.gpx', gpx),
|
||||
];
|
||||
|
||||
await strict.run(
|
||||
{
|
||||
args: [
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
],
|
||||
},
|
||||
async (browser) => {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 860 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.goto(base.href);
|
||||
const ready = async (kind) => {
|
||||
await page.waitForFunction(
|
||||
(k) =>
|
||||
window.__godsEyeView?.engine?.kind === k &&
|
||||
window.__gevGeoImport?.diagnostics().attached &&
|
||||
!document.querySelector('#globe-engine')?.disabled &&
|
||||
!document.querySelector('.engine-switch-frame') &&
|
||||
document.querySelector('#loading-screen.hidden'),
|
||||
{ timeout: 120000 },
|
||||
kind,
|
||||
);
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||
);
|
||||
};
|
||||
await ready('navara');
|
||||
assert.equal(
|
||||
await page.evaluate(() =>
|
||||
Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__),
|
||||
),
|
||||
false,
|
||||
'keyless preview required',
|
||||
);
|
||||
const state = () =>
|
||||
page.evaluate(() => ({
|
||||
files: window.__gevGeoImport.list(),
|
||||
diagnostics: window.__gevGeoImport.diagnostics(),
|
||||
rows: document.querySelectorAll('#geo-import-list .geo-import-row')
|
||||
.length,
|
||||
hint: document.getElementById('geo-import-hint')?.textContent || '',
|
||||
// Movers are a Navara capability; Cesium draws the pins as entities.
|
||||
pins: (window.__godsEyeView.engine.movers?.list() ?? [])
|
||||
.filter(({ id }) => id.startsWith('geo-import-'))
|
||||
.reduce((n, set) => n + set.count, 0),
|
||||
}));
|
||||
/** Pixels near the first file's colour (#39d0ff) on the canvas. */
|
||||
const cyan = async () => {
|
||||
await page.evaluate(() => window.__godsEyeView.requestRender?.('qa'));
|
||||
await wait(900);
|
||||
const { data, info } = await sharp(
|
||||
await page.screenshot({
|
||||
clip: { x: 400, y: 260, width: 480, height: 300 },
|
||||
}),
|
||||
)
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
let n = 0;
|
||||
for (let i = 0; i < data.length; i += info.channels) {
|
||||
const [r, g, b] = [data[i], data[i + 1], data[i + 2]];
|
||||
if (b > 120 && g > 90 && b - r > 50) n++;
|
||||
}
|
||||
return n;
|
||||
};
|
||||
const setAll = (visible) =>
|
||||
page.evaluate((v) => {
|
||||
for (const file of window.__gevGeoImport.list())
|
||||
window.__gevGeoImport.setVisible(file.id, v);
|
||||
}, visible);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const panel = document.getElementById('utilities-panel');
|
||||
if (panel?.classList.contains('collapsed'))
|
||||
document
|
||||
.querySelector(
|
||||
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
const input = await page.$('#geo-import-input');
|
||||
assert.ok(input, 'the Import file input');
|
||||
await input.uploadFile(...FILES);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__gevGeoImport.list().length === 4 &&
|
||||
!/Reading/.test(
|
||||
document.getElementById('geo-import-hint')?.textContent || '',
|
||||
),
|
||||
{ timeout: 90000 },
|
||||
);
|
||||
const imported = await state();
|
||||
console.log('navara import', JSON.stringify(imported));
|
||||
await strict.check(
|
||||
page,
|
||||
'geo-import',
|
||||
'GeoJSON, KML, KMZ and GPX each become one row and one drawing on Navara',
|
||||
imported.files.length === 4 &&
|
||||
imported.rows === 4 &&
|
||||
imported.diagnostics.drawn === 4,
|
||||
{ files: imported.files.map((f) => f.format), rows: imported.rows },
|
||||
);
|
||||
// The KMZ's packed ground overlay is a rectangle Navara has no drawing
|
||||
// for; the KML's remote one was left out of the file on import.
|
||||
const undrawn = Object.fromEntries(
|
||||
imported.files.map((f) => [f.name, f.undrawn]),
|
||||
);
|
||||
await strict.check(
|
||||
page,
|
||||
'geo-import',
|
||||
'the ground overlay Navara cannot draw is counted and reported in the hint',
|
||||
undrawn['packed.kmz'] === 1 &&
|
||||
undrawn['park.geojson'] === 0 &&
|
||||
undrawn['remote-bits.kml'] === 0 &&
|
||||
undrawn['walk.gpx'] === 0 &&
|
||||
/leaves out of packed\.kmz: 1/.test(imported.hint),
|
||||
{ undrawn, hint: imported.hint },
|
||||
);
|
||||
await strict.check(
|
||||
page,
|
||||
'geo-import',
|
||||
'the files’ pins are drawn as Navara sprites',
|
||||
imported.pins >= 4,
|
||||
{ pins: imported.pins },
|
||||
);
|
||||
// The first file is flown to after the batch; settle there.
|
||||
await wait(3000);
|
||||
await page.screenshot({ path: `${SHOTS}/navara-imported.png` });
|
||||
|
||||
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||
if (
|
||||
(await page.evaluate(() => window.__godsEyeView.engine.kind)) !== kind
|
||||
) {
|
||||
await strict.pick(page, kind);
|
||||
await ready(kind);
|
||||
await page.waitForFunction(
|
||||
() => window.__gevGeoImport.diagnostics().drawn === 4,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await wait(2500);
|
||||
await page.screenshot({ path: `${SHOTS}/${kind}-after-switch.png` });
|
||||
}
|
||||
const shown = await cyan();
|
||||
await setAll(false);
|
||||
const hidden = await cyan();
|
||||
await setAll(true);
|
||||
console.log(kind, 'cyan', { shown, hidden });
|
||||
await strict.check(
|
||||
page,
|
||||
'geo-import',
|
||||
'the imported shapes paint the first file’s colour and stop when hidden',
|
||||
shown - hidden > 1500,
|
||||
{ shown, hidden },
|
||||
);
|
||||
const kept = await state();
|
||||
await strict.check(
|
||||
page,
|
||||
'geo-import',
|
||||
'the same four files are listed and drawn on this engine',
|
||||
kept.files.length === 4 && kept.diagnostics.drawn === 4,
|
||||
kept.diagnostics,
|
||||
);
|
||||
}
|
||||
// Fly-to on Navara goes through the engine camera.
|
||||
const flight = await page.evaluate(
|
||||
async (destination) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
engine.camera.setView({ destination });
|
||||
const before = engine.camera.height();
|
||||
const [first] = window.__gevGeoImport.list();
|
||||
const ok = window.__gevGeoImport.flyTo(first.id);
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
return { ok, before, after: engine.camera.height() };
|
||||
},
|
||||
fromDegrees(-100, 40, 3e6),
|
||||
);
|
||||
await strict.check(
|
||||
page,
|
||||
'geo-import',
|
||||
'Fly to a file moves the Navara camera onto it',
|
||||
flight.ok && flight.after < flight.before / 2,
|
||||
flight,
|
||||
);
|
||||
// Remove gives the drawing back.
|
||||
await page.evaluate(() => {
|
||||
for (const file of window.__gevGeoImport.list())
|
||||
window.__gevGeoImport.remove(file.id);
|
||||
});
|
||||
const removed = await state();
|
||||
await strict.check(
|
||||
page,
|
||||
'geo-import',
|
||||
'Remove takes every file and its pins off Navara',
|
||||
removed.files.length === 0 && removed.rows === 0 && removed.pins === 0,
|
||||
{ files: removed.files.length, pins: removed.pins },
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS Navara import of GeoJSON, KML, KMZ and GPX');
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env node
|
||||
// The DISPLAY scope mask and the celestial ring on Navara, under the strict
|
||||
// network policy: at city altitude the mask canvas sits in the engine's
|
||||
// container with an opaque outside, the screen's edge goes dark with the
|
||||
// scope on and shows the map with it off, and the ring overlay draws; the
|
||||
// same holds after a switch to Cesium and back.
|
||||
//
|
||||
// node scripts/qa-navara-scope.mjs http://127.0.0.1:<PORT>/
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import sharp from 'sharp';
|
||||
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0';
|
||||
base.hash =
|
||||
'v=2&lat=37.7749&lon=-122.4194&alt=3000&heading=0&pitch=-90&roll=0&map=osm&l=';
|
||||
const strict = createStrict({
|
||||
origin: base.origin,
|
||||
layers: ['scope-mask', 'celestial-ring'],
|
||||
});
|
||||
const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-scope';
|
||||
fs.mkdirSync(SHOTS, { recursive: true });
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
// The screen's right edge below the rail: outside the keyhole, clear of UI.
|
||||
const GLOBE = fromDegrees(-122.4194, 37.7749, 20_000_000);
|
||||
const CITY = fromDegrees(-122.4194, 37.7749, 3000);
|
||||
const EDGE = { x: 1250, y: 560, width: 26, height: 90 };
|
||||
|
||||
await strict.run(
|
||||
{
|
||||
args: [
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
],
|
||||
},
|
||||
async (browser) => {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 860 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.goto(base.href);
|
||||
await waitForEngine(page, 'navara');
|
||||
assert.equal(
|
||||
await page.evaluate(() =>
|
||||
Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__),
|
||||
),
|
||||
false,
|
||||
'keyless preview required',
|
||||
);
|
||||
const edgeLuminance = async () => {
|
||||
await wait(900);
|
||||
const { data, info } = await sharp(await page.screenshot({ clip: EDGE }))
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
let sum = 0;
|
||||
for (let i = 0; i < data.length; i += info.channels)
|
||||
sum += 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2];
|
||||
return sum / (data.length / info.channels);
|
||||
};
|
||||
// DISPLAY ▸ Scope, as its checkbox sends it.
|
||||
const setScope = async (enabled) => {
|
||||
const found = await page.evaluate((on) => {
|
||||
const toggle = document.getElementById('scope-toggle');
|
||||
if (!toggle) return false;
|
||||
toggle.checked = on;
|
||||
toggle.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
}, enabled);
|
||||
assert.ok(found, 'the DISPLAY scope toggle');
|
||||
};
|
||||
|
||||
for (const [round, kind] of ['navara', 'cesium', 'navara'].entries()) {
|
||||
if (
|
||||
(await page.evaluate(() => window.__godsEyeView.engine.kind)) !== kind
|
||||
) {
|
||||
await strict.switchTo(page, kind);
|
||||
}
|
||||
await wait(2500);
|
||||
const mask = await page.evaluate(() => {
|
||||
const canvas = document.getElementById('scope-mask');
|
||||
const { engine } = window.__godsEyeView;
|
||||
const context = canvas?.getContext('2d');
|
||||
const corner = context?.getImageData(2, 2, 1, 1).data;
|
||||
const middle = context?.getImageData(
|
||||
Math.round(canvas.width / 2),
|
||||
Math.round(canvas.height / 2),
|
||||
1,
|
||||
1,
|
||||
).data;
|
||||
return {
|
||||
exists: Boolean(canvas),
|
||||
inContainer: canvas?.parentElement === engine.host.container,
|
||||
count: document.querySelectorAll('#scope-mask').length,
|
||||
height: Math.round(engine.camera.height()),
|
||||
cornerAlpha: corner?.[3],
|
||||
middleAlpha: middle?.[3],
|
||||
};
|
||||
});
|
||||
await page.screenshot({ path: `${SHOTS}/${kind}-${round}-scope.png` });
|
||||
const on = await edgeLuminance();
|
||||
await setScope(false);
|
||||
const off = await edgeLuminance();
|
||||
const cleared = await page.evaluate(
|
||||
() =>
|
||||
document
|
||||
.getElementById('scope-mask')
|
||||
?.getContext('2d')
|
||||
.getImageData(2, 2, 1, 1).data[3],
|
||||
);
|
||||
await setScope(true);
|
||||
console.log(kind, JSON.stringify({ mask, on, off, cleared }));
|
||||
await strict.check(
|
||||
page,
|
||||
'scope-mask',
|
||||
'one scope canvas in the engine container, opaque outside and clear inside at city altitude',
|
||||
mask.exists &&
|
||||
mask.inContainer &&
|
||||
mask.count === 1 &&
|
||||
mask.cornerAlpha === 255 &&
|
||||
mask.middleAlpha === 0,
|
||||
mask,
|
||||
);
|
||||
await strict.check(
|
||||
page,
|
||||
'scope-mask',
|
||||
'the screen edge is dark with the scope on and shows the map with it off',
|
||||
on < 12 && off > on + 30 && cleared === 0,
|
||||
{ on, off, cleared },
|
||||
);
|
||||
// The ring is a full-globe view: up to the whole Earth, ring on.
|
||||
await page.evaluate((destination) => {
|
||||
const { engine, styleManager } = window.__godsEyeView;
|
||||
engine.camera.setView({ destination });
|
||||
styleManager.setCelestialRingEnabled(true);
|
||||
}, GLOBE);
|
||||
await wait(2500);
|
||||
const ring = await page.evaluate(() => {
|
||||
const overlay = document.getElementById('celestial-ring-overlay');
|
||||
const canvases = [...(overlay?.querySelectorAll('canvas') ?? [])];
|
||||
return {
|
||||
exists: Boolean(overlay),
|
||||
disabled: overlay?.classList.contains('disabled') ?? null,
|
||||
visible: overlay?.classList.contains('visible') ?? null,
|
||||
updates: Number(overlay?.dataset.ephemerisUpdates ?? 0),
|
||||
canvases: canvases.map((c) => [c.width, c.height]),
|
||||
};
|
||||
});
|
||||
await page.screenshot({ path: `${SHOTS}/${kind}-${round}-ring.png` });
|
||||
console.log(kind, 'ring', JSON.stringify(ring));
|
||||
await strict.check(
|
||||
page,
|
||||
'celestial-ring',
|
||||
'at full-globe altitude the celestial ring draws its sun and moon',
|
||||
ring.exists &&
|
||||
ring.disabled === false &&
|
||||
ring.visible === true &&
|
||||
ring.updates >= 1 &&
|
||||
ring.canvases.length === 2 &&
|
||||
ring.canvases.every(([w, h]) => w > 0 && h > 0),
|
||||
ring,
|
||||
);
|
||||
// Back down to the city for the next engine.
|
||||
await page.evaluate((destination) => {
|
||||
const { engine, styleManager } = window.__godsEyeView;
|
||||
styleManager.setCelestialRingEnabled(false);
|
||||
engine.camera.setView({ destination });
|
||||
}, CITY);
|
||||
await wait(1000);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS scope mask and celestial ring on both engines');
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,286 @@
|
||||
// The Cyber HUD sonar on Navara: with the Cyber HUD on, the
|
||||
// sweep lights the contacts it passes and dims the rest to the opacity
|
||||
// floor, by each contact's place on screen exactly as the CPU sampler of
|
||||
// src/cyberSonar.js rates it; the sweep turns; with the sweep off every
|
||||
// contact takes the neutral factor; a parked scene draws at the fleet
|
||||
// cadence only while the sweep runs over contacts; leaving the Cyber HUD
|
||||
// gives the contacts back unchanged. On Cesium the command adapter keeps the
|
||||
// treatment and the engine draws none. The contacts are an engine point set
|
||||
// (engine.movers) in a ring around the screen centre. Keyless preview under
|
||||
// the strict default-deny network policy.
|
||||
//
|
||||
// node scripts/qa-navara-sonar.mjs http://127.0.0.1:<PORT>/ [--out=DIR]
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
import { checker, freeze, visualPageUrl } from './qa/navaraVisual.mjs';
|
||||
import { sonarFactorAt } from '../src/globe/navara/sonar.js';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
const outDir = process.argv
|
||||
.find((arg) => arg.startsWith('--out='))
|
||||
?.slice('--out='.length);
|
||||
const strict = createStrict({ origin: base.origin, layers: [] });
|
||||
const report = { checks: [] };
|
||||
const check = checker(report, assert);
|
||||
const WIDTH = 1280;
|
||||
const HEIGHT = 800;
|
||||
|
||||
// 32 contacts on a ring at half the keyhole radius (11.25° apart: one is
|
||||
// always well inside the 24° sector), and one outside the keyhole.
|
||||
function contactPlaces() {
|
||||
const radius = Math.min(WIDTH * 0.4, HEIGHT * 0.52) * 0.5;
|
||||
const places = [];
|
||||
for (let i = 0; i < 32; i++) {
|
||||
const angle = (i / 32) * Math.PI * 2;
|
||||
places.push({
|
||||
x: Math.round(WIDTH / 2 + Math.sin(angle) * radius),
|
||||
y: Math.round(HEIGHT / 2 - Math.cos(angle) * radius),
|
||||
});
|
||||
}
|
||||
places.push({ x: 120, y: 110 });
|
||||
return places;
|
||||
}
|
||||
|
||||
// The luma at each contact (a 3×3 mean) of the next drawn frame, and the
|
||||
// sonar state that frame drew with.
|
||||
const read = (page, places) =>
|
||||
page.evaluate(async (places) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const canvas = document.createElement('canvas');
|
||||
const source = engine.host.canvas;
|
||||
canvas.width = source.width;
|
||||
canvas.height = source.height;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
let sonar = null;
|
||||
await new Promise((resolve) => {
|
||||
const off = engine.frames.postRender.addEventListener(() => {
|
||||
off();
|
||||
ctx.drawImage(source, 0, 0);
|
||||
sonar = engine.render.contactSonar?.() ?? null;
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('qa-sonar');
|
||||
});
|
||||
const scale = source.width / engine.host.size().width;
|
||||
const luma = places.map(({ x, y }) => {
|
||||
const data = ctx.getImageData(
|
||||
Math.round(x * scale) - 1,
|
||||
Math.round(y * scale) - 1,
|
||||
3,
|
||||
3,
|
||||
).data;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < data.length; i += 4)
|
||||
sum += 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
|
||||
return sum / 9;
|
||||
});
|
||||
return { luma, sonar };
|
||||
}, places);
|
||||
|
||||
// Frames the engine draws in `ms` without being asked.
|
||||
const framesIn = (page, ms) =>
|
||||
page.evaluate(async (ms) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
let frames = 0;
|
||||
const off = engine.frames.postRender.addEventListener(() => frames++);
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
off();
|
||||
return frames;
|
||||
}, ms);
|
||||
|
||||
const mean = (values) =>
|
||||
values.reduce((sum, value) => sum + value, 0) / Math.max(1, values.length);
|
||||
|
||||
await strict.run({}, async (browser) => {
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
});
|
||||
await page.setViewport({ width: WIDTH, height: HEIGHT });
|
||||
await page.goto(visualPageUrl(base.origin, 'navara'), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await waitForEngine(page, 'navara');
|
||||
await freeze(page);
|
||||
|
||||
const places = contactPlaces();
|
||||
const placed = await page.evaluate((places) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const set = engine.movers.createSet({
|
||||
id: 'qa-sonar-contacts',
|
||||
kind: 'point',
|
||||
capacity: places.length,
|
||||
depthTest: 'always',
|
||||
point: { sizePx: 14, outlinePx: 0 },
|
||||
});
|
||||
window.__qaSonarSet = set;
|
||||
let missing = 0;
|
||||
places.forEach((place, index) => {
|
||||
const position = engine.camera.pickEllipsoid(place);
|
||||
if (!position) missing++;
|
||||
set.set(index, { position, color: [1, 1, 1, 1] });
|
||||
});
|
||||
set.setCount(places.length);
|
||||
set.commit();
|
||||
return { missing, sonar: engine.render.contactSonar?.() ?? null };
|
||||
}, places);
|
||||
check(
|
||||
'Navara treats contacts itself; the sonar starts off',
|
||||
placed.missing === 0 && placed.sonar?.enabled === false,
|
||||
placed,
|
||||
);
|
||||
const baseline = await read(page, places);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const { styleManager } = window.__godsEyeView;
|
||||
styleManager._setHudVariant('cyber');
|
||||
// Normal keeps the contacts' own colours; the HUD stays on (in Normal
|
||||
// it would hide itself, and the sweep with it).
|
||||
styleManager.setStyle('normal');
|
||||
styleManager.hud.setMode('on');
|
||||
styleManager._setCyberSonarEnabled(true);
|
||||
styleManager._setCyberSonarSetting('opacity', 35);
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.styleManager.transitions.size === 0,
|
||||
);
|
||||
const theme = await page.evaluate(() => ({
|
||||
theme: document.documentElement.dataset.uiTheme,
|
||||
gpu: document.documentElement.dataset.cyberSonarGpu,
|
||||
hud: document.getElementById('intel-hud')?.classList.contains('active'),
|
||||
}));
|
||||
check(
|
||||
'the Cyber HUD runs with the contact adapter',
|
||||
theme.theme === 'cyber' && theme.gpu === 'supported' && theme.hud,
|
||||
theme,
|
||||
);
|
||||
|
||||
const sweeps = [];
|
||||
for (const wait of [0, 1300, 1300]) {
|
||||
await new Promise((resolve) => setTimeout(resolve, wait));
|
||||
const { luma, sonar } = await read(page, places);
|
||||
const expected = places.map(({ x, y }) =>
|
||||
sonarFactorAt(x, y, WIDTH, HEIGHT, sonar),
|
||||
);
|
||||
const lit = luma.filter((_, i) => expected[i] > 0.9);
|
||||
const dim = luma.filter((_, i) => expected[i] <= sonar.opacity / 100);
|
||||
sweeps.push({
|
||||
spread: Math.max(...luma.slice(0, 32)) - Math.min(...luma.slice(0, 32)),
|
||||
angle: sonar.angleDeg,
|
||||
lit: lit.length,
|
||||
litLuma: mean(lit),
|
||||
dimLuma: mean(dim),
|
||||
brightest: luma.indexOf(Math.max(...luma)),
|
||||
expectedBrightest: expected.indexOf(Math.max(...expected)),
|
||||
sonar,
|
||||
});
|
||||
check(
|
||||
`the sweep at ${Math.round(sonar.angleDeg)}° lights the contacts it passes, the rest sit at the floor`,
|
||||
sonar.enabled &&
|
||||
sonar.active &&
|
||||
sonar.opacity === 35 &&
|
||||
lit.length >= 1 &&
|
||||
dim.length >= 8 &&
|
||||
mean(lit) > mean(dim) + 20 &&
|
||||
expected[places.length - 1] === 0.35,
|
||||
sweeps.at(-1),
|
||||
);
|
||||
}
|
||||
check(
|
||||
'the sweep turns: another contact is lit',
|
||||
new Set(sweeps.map((s) => s.expectedBrightest)).size > 1 &&
|
||||
new Set(sweeps.map((s) => s.brightest)).size > 1,
|
||||
sweeps.map(({ angle, brightest, expectedBrightest }) => ({
|
||||
angle,
|
||||
brightest,
|
||||
expectedBrightest,
|
||||
})),
|
||||
);
|
||||
const cadence = await framesIn(page, 1500);
|
||||
check(
|
||||
'a parked scene draws at the fleet cadence while the sweep runs over contacts',
|
||||
cadence >= 8,
|
||||
{ frames: cadence },
|
||||
);
|
||||
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager._setCyberSonarEnabled(false),
|
||||
);
|
||||
const neutral = await read(page, places);
|
||||
const spread =
|
||||
Math.max(...neutral.luma.slice(0, 32)) -
|
||||
Math.min(...neutral.luma.slice(0, 32));
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const idle = await framesIn(page, 1500);
|
||||
check(
|
||||
'with the sweep off every contact takes the neutral factor and the scene rests',
|
||||
neutral.sonar.enabled &&
|
||||
!neutral.sonar.active &&
|
||||
// What still differs is the map under the translucent contacts.
|
||||
spread < Math.min(...sweeps.map((sweep) => sweep.spread)) * 0.6 &&
|
||||
idle <= 3,
|
||||
{ spread, sweeping: sweeps.map((sweep) => sweep.spread), frames: idle },
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const { styleManager } = window.__godsEyeView;
|
||||
styleManager._setCyberSonarEnabled(true);
|
||||
styleManager._setHudVariant('tactical');
|
||||
styleManager.setStyle('normal');
|
||||
styleManager.hud.setMode('auto');
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.styleManager.transitions.size === 0,
|
||||
);
|
||||
const after = await read(page, places);
|
||||
const drift = Math.max(
|
||||
...after.luma.map((value, i) => Math.abs(value - baseline.luma[i])),
|
||||
);
|
||||
check(
|
||||
'leaving the Cyber HUD gives the contacts back unchanged',
|
||||
after.sonar.enabled === false && drift < 3,
|
||||
{ drift },
|
||||
);
|
||||
await page.evaluate(() => window.__qaSonarSet.destroy());
|
||||
|
||||
// Cesium keeps its command adapter; its engine draws no sonar.
|
||||
await strict.switchTo(page, 'cesium');
|
||||
await page.evaluate(() => {
|
||||
const { styleManager } = window.__godsEyeView;
|
||||
styleManager._setHudVariant('cyber');
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => document.documentElement.dataset.cyberSonarGpu,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
const cesium = await page.evaluate(() => ({
|
||||
setContactSonar: typeof window.__godsEyeView.engine.render.setContactSonar,
|
||||
gpu: document.documentElement.dataset.cyberSonarGpu,
|
||||
}));
|
||||
check(
|
||||
'Cesium: the command adapter treats contacts, the engine none',
|
||||
cesium.setContactSonar === 'undefined' && cesium.gpu === 'supported',
|
||||
cesium,
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager._setHudVariant('tactical'),
|
||||
);
|
||||
|
||||
check('no page errors', errors.length === 0, errors);
|
||||
if (outDir) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, 'sonar.json'),
|
||||
JSON.stringify(report, null, 2),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
// The app's visual styles on Navara: each of CRT, NVG, FLIR,
|
||||
// Anime and Noir filters the Navara frame (pixels and a style-specific
|
||||
// signature against Normal), switching styles crossfades (both styles drawn
|
||||
// at once mid-way, the fade completes), Normal restores the frame, the
|
||||
// retired Snow opens on Normal, a link's style opens on Navara, and on
|
||||
// Cesium the styles still run as Cesium post stages with the engine left
|
||||
// alone. Keyless preview under the strict default-deny network policy.
|
||||
//
|
||||
// node scripts/qa-navara-visual-styles.mjs http://127.0.0.1:<PORT>/ [--out=DIR]
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
import {
|
||||
STILL_VIEW,
|
||||
capture,
|
||||
checker,
|
||||
diff,
|
||||
freeze,
|
||||
histogramShift,
|
||||
pickStyle,
|
||||
pinDaytime,
|
||||
saveCapture,
|
||||
stats,
|
||||
visualPageUrl,
|
||||
} from './qa/navaraVisual.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
const outDir = process.argv
|
||||
.find((arg) => arg.startsWith('--out='))
|
||||
?.slice('--out='.length);
|
||||
const STYLES = ['retro', 'surveillance', 'thermal', 'anime', 'noir'];
|
||||
const URL_NAMES = { retro: 'crt', surveillance: 'nvg', thermal: 'flir' };
|
||||
const pageUrl = (engine, extra = '') =>
|
||||
visualPageUrl(base.origin, engine, extra, STILL_VIEW);
|
||||
|
||||
const strict = createStrict({ origin: base.origin, layers: [] });
|
||||
const report = { checks: [] };
|
||||
const check = checker(report, assert);
|
||||
|
||||
const save = (name, pixels) => saveCapture(outDir, name, pixels);
|
||||
|
||||
await strict.run({}, async (browser) => {
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
// The same sun on every run: the frame's brightness decides what blooms
|
||||
// and what each style's signature reads.
|
||||
await pinDaytime(page);
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
});
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.goto(pageUrl('navara'), { waitUntil: 'domcontentloaded' });
|
||||
await waitForEngine(page, 'navara');
|
||||
await freeze(page);
|
||||
|
||||
const capabilities = await page.evaluate(() => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
return {
|
||||
styles: engine.render.visualStyles,
|
||||
pipeline: engine.pipeline(),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'Navara draws every app style itself',
|
||||
JSON.stringify(capabilities.styles) === JSON.stringify(STYLES),
|
||||
capabilities,
|
||||
);
|
||||
check(
|
||||
'no style pass shows on Normal',
|
||||
capabilities.pipeline?.shown === false,
|
||||
capabilities.pipeline,
|
||||
);
|
||||
|
||||
// The military styles' presets set sharpening and detection (as on
|
||||
// Cesium); Normal keeps them, so they are put back before comparing.
|
||||
const display = await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.getControlState().sharpen,
|
||||
);
|
||||
const normal = await capture(page);
|
||||
const normalAgain = await capture(page);
|
||||
const noise = diff(normal, normalAgain);
|
||||
check('Normal is a still frame to compare against', noise.mean < 0.5, noise);
|
||||
save('normal', normal);
|
||||
const normalStats = stats(normal);
|
||||
|
||||
let previous = 'normal';
|
||||
for (const name of STYLES) {
|
||||
const fade = await pickStyle(page, name, previous);
|
||||
check(
|
||||
`${name}: the crossfade completes`,
|
||||
fade.pending === 0 && fade.ms < 1500 && fade.active === name,
|
||||
{ ms: fade.ms, pending: fade.pending },
|
||||
);
|
||||
const between = fade.samples.filter(
|
||||
([, next]) => next > 0.02 && next < 0.98,
|
||||
);
|
||||
check(
|
||||
`${name}: the engine fades it in over several frames`,
|
||||
between.length >= 1,
|
||||
{ frames: fade.samples.length, between: between.length },
|
||||
);
|
||||
if (previous !== 'normal') {
|
||||
const both = fade.samples.filter(
|
||||
([out, next]) => out > 0.05 && out < 0.95 && next > 0.05 && next < 0.95,
|
||||
);
|
||||
check(
|
||||
`${name}: ${previous} and ${name} draw together mid-crossfade`,
|
||||
both.length >= 1,
|
||||
{ both: both.length },
|
||||
);
|
||||
}
|
||||
const others = Object.entries(fade.intensities).filter(
|
||||
([style, value]) => style !== name && value !== 0,
|
||||
);
|
||||
check(
|
||||
`${name}: alone at full intensity once settled`,
|
||||
fade.intensities[name] === 1 && others.length === 0,
|
||||
fade.intensities,
|
||||
);
|
||||
const order = fade.pipeline?.passes ?? [];
|
||||
const at = order.indexOf('gevStyles');
|
||||
check(
|
||||
`${name}: the style pass shows, last before the final copy and after the overlays`,
|
||||
fade.pipeline?.shown === true &&
|
||||
at >= 0 &&
|
||||
order[at + 1] === 'final' &&
|
||||
['toneMapping', 'gevSharpen', 'gevOverlay', 'smaa', 'fxaa'].every(
|
||||
(key) => !order.includes(key) || order.indexOf(key) < at,
|
||||
),
|
||||
fade.pipeline,
|
||||
);
|
||||
const pixels = await capture(page);
|
||||
save(name, pixels);
|
||||
const styled = stats(pixels);
|
||||
const change = diff(normal, pixels);
|
||||
const shift = histogramShift(normalStats, styled);
|
||||
check(
|
||||
`${name}: the frame changes against Normal`,
|
||||
change.mean > 6 && change.changedShare > 0.25,
|
||||
{ ...change, histogramShift: shift },
|
||||
);
|
||||
const signature = {
|
||||
// Night vision: phosphor green over red and blue.
|
||||
surveillance: styled.g > styled.r * 1.3 && styled.g > styled.b * 1.3,
|
||||
// FLIR white-hot (palette 0): grey.
|
||||
thermal: styled.saturation < normalStats.saturation * 0.5,
|
||||
// Noir: greyer, with a slight sepia, and vignetted corners.
|
||||
noir:
|
||||
styled.saturation < normalStats.saturation &&
|
||||
styled.vignette < normalStats.vignette * 0.85,
|
||||
// Anime: cel bands, more saturated.
|
||||
anime: styled.saturation > normalStats.saturation * 1.05 || shift > 0.05,
|
||||
// CRT: pixelated, dithered, scanlined.
|
||||
retro: shift > 0.05 || change.mean > 12,
|
||||
}[name];
|
||||
check(`${name}: the style's own look`, signature, {
|
||||
normal: {
|
||||
r: normalStats.r,
|
||||
g: normalStats.g,
|
||||
b: normalStats.b,
|
||||
saturation: normalStats.saturation,
|
||||
vignette: normalStats.vignette,
|
||||
},
|
||||
styled: {
|
||||
r: styled.r,
|
||||
g: styled.g,
|
||||
b: styled.b,
|
||||
saturation: styled.saturation,
|
||||
vignette: styled.vignette,
|
||||
},
|
||||
histogramShift: shift,
|
||||
});
|
||||
previous = name;
|
||||
}
|
||||
|
||||
// A style parameter reaches the Navara pass (FLIR's Ironbow palette).
|
||||
await pickStyle(page, 'thermal', previous);
|
||||
previous = 'thermal';
|
||||
const grey = await capture(page);
|
||||
const ironbow = await page.evaluate(() => {
|
||||
const a = window.__godsEyeView;
|
||||
a.styleManager.stages.thermal.uniforms.palette = 1;
|
||||
return a.engine.render.style('thermal').uniforms.palette;
|
||||
});
|
||||
const coloured = await capture(page);
|
||||
check(
|
||||
'FLIR Ironbow reaches the engine and colours the frame',
|
||||
ironbow === 1 && stats(coloured).saturation > stats(grey).saturation + 10,
|
||||
{
|
||||
grey: stats(grey).saturation,
|
||||
ironbow: stats(coloured).saturation,
|
||||
},
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.styleManager.stages.thermal.uniforms.palette = 0;
|
||||
});
|
||||
|
||||
const back = await pickStyle(page, 'normal', previous);
|
||||
check(
|
||||
'Normal: every style back at 0 and the pass hidden',
|
||||
Object.values(back.intensities).every((v) => v === 0) &&
|
||||
back.pipeline?.shown === false,
|
||||
back,
|
||||
);
|
||||
await page.evaluate((sharpen) => {
|
||||
const { styleManager } = window.__godsEyeView;
|
||||
styleManager._setDetectionMode?.('OFF');
|
||||
styleManager.setSharpen(sharpen);
|
||||
}, display);
|
||||
const restored = await capture(page);
|
||||
save('normal-restored', restored);
|
||||
const residue = diff(normal, restored);
|
||||
check(
|
||||
'Normal restores the frame',
|
||||
residue.mean < 1 && residue.changedShare < 0.01,
|
||||
residue,
|
||||
);
|
||||
|
||||
// The retired Snow opens on Normal; a link's style opens on Navara.
|
||||
await page.goto(pageUrl('navara', '&style=snow'), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await waitForEngine(page, 'navara');
|
||||
const snow = await page.evaluate(() => {
|
||||
const a = window.__godsEyeView;
|
||||
return {
|
||||
active: a.styleManager.activeStyle,
|
||||
intensities: a.engine.render.visualStyles.map(
|
||||
(s) => a.engine.render.style(s).intensity,
|
||||
),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'style=snow opens on Normal',
|
||||
snow.active === 'normal' && snow.intensities.every((v) => v === 0),
|
||||
snow,
|
||||
);
|
||||
await page.goto(pageUrl('navara', `&style=${URL_NAMES.thermal}`), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await waitForEngine(page, 'navara');
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.engine.render.style('thermal').intensity === 1,
|
||||
{ timeout: 10000, polling: 50 },
|
||||
);
|
||||
check('a FLIR link opens filtered on Navara', true);
|
||||
|
||||
// Cesium keeps its own post stages; its engine draws no styles. The
|
||||
// switch is real input on the picker, in the same document.
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setStyle('normal'),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.styleManager.transitions.size === 0,
|
||||
);
|
||||
await strict.switchTo(page, 'cesium');
|
||||
await freeze(page);
|
||||
// The address may still carry the FLIR link: Normal first.
|
||||
await pickStyle(page, 'normal', 'thermal');
|
||||
await page.evaluate((sharpen) => {
|
||||
const { styleManager } = window.__godsEyeView;
|
||||
styleManager._setDetectionMode?.('OFF');
|
||||
styleManager.setSharpen(sharpen);
|
||||
}, display);
|
||||
const cesiumNormal = await capture(page);
|
||||
const cesium = await pickStyle(page, 'thermal', 'normal');
|
||||
const cesiumState = await page.evaluate(() => {
|
||||
const a = window.__godsEyeView;
|
||||
const stage = a.styleManager.stages.thermal;
|
||||
return {
|
||||
setStyle: typeof a.engine.render.setStyle,
|
||||
stageName: stage.name,
|
||||
enabled: stage.enabled,
|
||||
intensity: stage.uniforms.intensity,
|
||||
inScene: a.viewer?.scene?.postProcessStages?.contains?.(stage) ?? null,
|
||||
};
|
||||
});
|
||||
const cesiumThermal = await capture(page);
|
||||
save('cesium-normal', cesiumNormal);
|
||||
save('cesium-thermal', cesiumThermal);
|
||||
if (outDir)
|
||||
await page.screenshot({
|
||||
path: path.join(outDir, 'cesium-thermal-page.png'),
|
||||
});
|
||||
check(
|
||||
'Cesium: FLIR is its own post stage, the engine draws none',
|
||||
cesium.pending === 0 &&
|
||||
cesiumState.setStyle === 'undefined' &&
|
||||
cesiumState.enabled === true &&
|
||||
cesiumState.intensity === 1 &&
|
||||
cesiumState.inScene !== false,
|
||||
cesiumState,
|
||||
);
|
||||
const cesiumChange = diff(cesiumNormal, cesiumThermal);
|
||||
check('Cesium: FLIR changes its frame', cesiumChange.mean > 6, cesiumChange);
|
||||
await pickStyle(page, 'normal', 'thermal');
|
||||
await page.evaluate((sharpen) => {
|
||||
const { styleManager } = window.__godsEyeView;
|
||||
styleManager._setDetectionMode?.('OFF');
|
||||
styleManager.setSharpen(sharpen);
|
||||
}, display);
|
||||
const cesiumBack = diff(cesiumNormal, await capture(page));
|
||||
check('Cesium: Normal restores its frame', cesiumBack.mean < 1.5, cesiumBack);
|
||||
|
||||
check('no page errors', errors.length === 0, errors);
|
||||
if (outDir)
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, 'visual-styles.json'),
|
||||
JSON.stringify(report, null, 2),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import sharp from 'sharp';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
openPage,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0&look=cinematic';
|
||||
base.hash =
|
||||
'v=2&lat=28.2110&lon=85.3380&alt=9000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
|
||||
// The media panel embeds YouTube players with their thumbnails: a plain
|
||||
// thumbnail and an inert player page stand in for them.
|
||||
const thumbnail = await sharp({
|
||||
create: { width: 480, height: 360, channels: 3, background: '#40506a' },
|
||||
})
|
||||
.jpeg()
|
||||
.toBuffer();
|
||||
const strict = createStrict({
|
||||
origin: base.origin,
|
||||
layers: ['bhote-koshi-2026'],
|
||||
routes: [
|
||||
{
|
||||
pattern: 'https://i.ytimg.com/*',
|
||||
respond: () => ({ contentType: 'image/jpeg', body: thumbnail }),
|
||||
},
|
||||
{
|
||||
// The player API script: an inert stub (the app keeps its own controls).
|
||||
pattern: 'https://www.youtube.com/iframe_api*',
|
||||
respond: () => ({
|
||||
contentType: 'text/javascript',
|
||||
body: '/* QA: YouTube iframe API withheld */',
|
||||
}),
|
||||
},
|
||||
{
|
||||
pattern: 'https://www.youtube-nocookie.com/*',
|
||||
respond: () => ({
|
||||
contentType: 'text/html',
|
||||
body: '<!doctype html><title>QA player</title><body style="background:#111"></body>',
|
||||
}),
|
||||
},
|
||||
// A Facebook post the playback reaches in a slower run: its SDK and
|
||||
// post page, inert as well.
|
||||
{
|
||||
pattern: 'https://connect.facebook.net/*',
|
||||
respond: () => ({
|
||||
contentType: 'text/javascript',
|
||||
body: '/* QA: Facebook SDK withheld */',
|
||||
}),
|
||||
},
|
||||
{
|
||||
pattern: 'https://www.facebook.com/*',
|
||||
respond: () => ({
|
||||
contentType: 'text/html',
|
||||
body: '<!doctype html><title>QA post</title><body style="background:#111"></body>',
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
await strict.run({}, async (b) => {
|
||||
const { page: p, errors } = await openPage(b, { shaderErrors: true });
|
||||
const assets = [];
|
||||
p.on('request', (req) => {
|
||||
const path = new URL(req.url()).pathname;
|
||||
if (
|
||||
/\/events\/bhote-koshi-2026\/(event\.json|pre\.webp|post\.webp)$/.test(
|
||||
path,
|
||||
)
|
||||
)
|
||||
assets.push(path);
|
||||
});
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(async () => {
|
||||
const { tileset, dataManager, sceneDirector } = window.__godsEyeView;
|
||||
if (!sceneDirector?.listScenes().length)
|
||||
throw Error('No scenes available on Navara');
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Keyless preview required');
|
||||
const mod = dataManager.layers.get('bhote-koshi-2026').module;
|
||||
window.qaEvent = mod;
|
||||
await dataManager.setEnabled(mod.id, true, { origin: 'user' });
|
||||
mod.setParams({
|
||||
presentation: 'standalone',
|
||||
progress: 0.52,
|
||||
split: 0.27,
|
||||
autoPlay: false,
|
||||
});
|
||||
});
|
||||
const sample = async () => {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
return sharp(await p.screenshot())
|
||||
.extract({ left: 490, top: 370, width: 25, height: 25 })
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
};
|
||||
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
|
||||
await strict.switchTo(p, kind);
|
||||
}
|
||||
const state = await p.evaluate(() => {
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
return {
|
||||
same:
|
||||
window.qaEvent === dataManager.layers.get('bhote-koshi-2026').module,
|
||||
state: window.qaEvent.getPlaybackState(),
|
||||
player:
|
||||
document.querySelector('#bhote-koshi-embedded-media-root') ===
|
||||
window.qaPlayer,
|
||||
};
|
||||
});
|
||||
assert.equal(state.same, true);
|
||||
assert.equal(state.state.enabled, true);
|
||||
assert.equal(state.state.split, 0.27);
|
||||
assert.equal(state.state.playing, false);
|
||||
assert.equal(state.state.progress, 0.52);
|
||||
await p.evaluate(() => {
|
||||
window.qaPlayer ??= document.querySelector(
|
||||
'#bhote-koshi-embedded-media-root',
|
||||
);
|
||||
window.qaEvent.setParams({ split: 0.75 });
|
||||
});
|
||||
assert.ok(
|
||||
await p.evaluate(() => Boolean(window.qaPlayer)),
|
||||
'persistent embedded media root',
|
||||
);
|
||||
const before = await sample();
|
||||
await p.evaluate(() => window.qaEvent.setParams({ split: 0.27 }));
|
||||
const after = await sample();
|
||||
const diff =
|
||||
before.reduce((n, v, i) => n + Math.abs(v - after[i]), 0) / before.length;
|
||||
console.log(kind, state.state, 'imagery difference', diff);
|
||||
assert.ok(diff > 6, 'divider changes the actual imagery in the viewport');
|
||||
await strict.passed(
|
||||
p,
|
||||
'bhote-koshi-2026',
|
||||
'same model and media root; paused at 0.52 with split 0.27; the divider changes the imagery',
|
||||
{ diff },
|
||||
);
|
||||
assert.equal(
|
||||
await p.evaluate(
|
||||
() =>
|
||||
document.querySelector('#bhote-koshi-embedded-media-root') ===
|
||||
window.qaPlayer,
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
for (const suffix of ['/event.json', '/pre.webp', '/post.webp'])
|
||||
assert.equal(
|
||||
assets.filter((a) => a.endsWith(suffix)).length,
|
||||
1,
|
||||
JSON.stringify(assets),
|
||||
);
|
||||
await strict.passed(
|
||||
p,
|
||||
'bhote-koshi-2026',
|
||||
'event file and image pair read once over three engine visits',
|
||||
);
|
||||
await p.evaluate(() => window.qaEvent.setParams({ autoPlay: true }));
|
||||
await p.waitForFunction(
|
||||
() => window.qaEvent.getPlaybackState().progress > 0.53,
|
||||
);
|
||||
await strict.switchTo(p, 'cesium');
|
||||
const live = await p.evaluate(() => window.qaEvent.getPlaybackState());
|
||||
await strict.check(
|
||||
p,
|
||||
'bhote-koshi-2026',
|
||||
'playback keeps playing past 0.53 through the switch',
|
||||
live.playing && live.progress > 0.53,
|
||||
live,
|
||||
);
|
||||
await p.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('bhote-koshi-2026', false, {
|
||||
origin: 'user',
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS event timeline, paused and playing handoff, image pair, divider, player host and single asset acquisition',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||
import sharp from 'sharp';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
openPage,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0&look=cinematic';
|
||||
base.hash =
|
||||
'v=2&lat=28.3326&lon=85.4848&alt=18000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
|
||||
const strict = createStrict({
|
||||
origin: base.origin,
|
||||
layers: ['bhote-koshi-locator'],
|
||||
});
|
||||
await strict.run({}, async (b) => {
|
||||
const { page: p, errors } = await openPage(b, { shaderErrors: true });
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(async () => {
|
||||
const { tileset, dataManager } = window.__godsEyeView;
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Keyless preview required');
|
||||
window.qaLocator = dataManager.layers.get('bhote-koshi-locator').module;
|
||||
window.qaLocator.setParams({ presentation: 'bhote-koshi-path-overview' });
|
||||
await dataManager.setEnabled('bhote-koshi-locator', true, {
|
||||
origin: 'user',
|
||||
});
|
||||
});
|
||||
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
|
||||
await strict.switchTo(p, kind);
|
||||
}
|
||||
const { same, stats, params, at } = await p.evaluate(
|
||||
(world) => {
|
||||
const { engine, dataManager } = window.__godsEyeView,
|
||||
mod = dataManager.layers.get('bhote-koshi-locator').module;
|
||||
const pos = engine.projector.worldToWindow(world, {}),
|
||||
box = engine.host.canvas.getBoundingClientRect();
|
||||
return {
|
||||
same: mod === window.qaLocator,
|
||||
stats: mod.getStats(),
|
||||
params: mod.getParams(),
|
||||
at: { x: pos.x + box.left, y: pos.y + box.top },
|
||||
};
|
||||
},
|
||||
fromDegrees(85.47741, 28.33299, 1600),
|
||||
);
|
||||
assert.equal(same, true);
|
||||
assert.equal(stats.count, 1);
|
||||
assert.equal(params.presentation, 'bhote-koshi-path-overview');
|
||||
// Look for the cyan river in a scene crop away from the UI rails.
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const crop = async () =>
|
||||
sharp(await p.screenshot())
|
||||
.extract({ left: 400, top: 320, width: 230, height: 180 })
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
const rendered = await crop();
|
||||
await p.evaluate(() =>
|
||||
window.qaLocator.setParams({ presentation: 'nepal-context' }),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const baseline = await crop();
|
||||
let added = 0;
|
||||
for (let i = 0; i < rendered.length; i += 3) {
|
||||
const contrast = rendered[i + 1] - rendered[i],
|
||||
previous = baseline[i + 1] - baseline[i];
|
||||
if (
|
||||
rendered[i + 1] > 140 &&
|
||||
rendered[i + 2] > 140 &&
|
||||
contrast > 35 &&
|
||||
contrast - previous > 18
|
||||
)
|
||||
added++;
|
||||
}
|
||||
console.log(
|
||||
kind,
|
||||
stats.coverage,
|
||||
'cyan pixels added by the path',
|
||||
added,
|
||||
at,
|
||||
);
|
||||
await strict.check(
|
||||
p,
|
||||
'bhote-koshi-locator',
|
||||
'same model; the flood path adds more than 50 cyan pixels to the same scene without it',
|
||||
added > 50,
|
||||
{ added },
|
||||
);
|
||||
await p.evaluate(() =>
|
||||
window.qaLocator.setParams({ presentation: 'bhote-koshi-path-overview' }),
|
||||
);
|
||||
}
|
||||
await p.evaluate(() => {
|
||||
window.qaLocator.setParams({ presentation: 'bhote-koshi-incident-places' });
|
||||
window.qaLocator.setParams({ presentation: 'bhote-koshi-city-context' });
|
||||
window.qaLocator.setParams({
|
||||
presentation: 'bhote-koshi-trigger-record',
|
||||
preview: true,
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 2500));
|
||||
// The last of three quick presentation changes is the one that stays.
|
||||
const last = await p.evaluate(() => ({
|
||||
params: window.qaLocator.getParams(),
|
||||
stats: window.qaLocator.getStats(),
|
||||
}));
|
||||
await strict.check(
|
||||
p,
|
||||
'bhote-koshi-locator',
|
||||
'the last of three quick presentation changes is the one applied',
|
||||
last.params.presentation === 'bhote-koshi-trigger-record' &&
|
||||
last.stats.count === 1 &&
|
||||
!last.stats.error,
|
||||
{ presentation: last.params.presentation, count: last.stats.count },
|
||||
);
|
||||
await p.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('bhote-koshi-locator', false, {
|
||||
origin: 'user',
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS Nepal locator flood path and presentation retained, trigger and places update; both engines',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import sharp from 'sharp';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
openPage,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
// Drone airspace, OSM places, sea marks and ski pistes on both engines, over
|
||||
// Navara → Cesium → Navara: each draws over the view centre, and the switch
|
||||
// keeps the same layer models with their data (no refetch, the places and
|
||||
// the overlays still on).
|
||||
//
|
||||
// node scripts/qa-open-layers-switch.mjs http://127.0.0.1:4208
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0';
|
||||
base.hash =
|
||||
'v=2&lat=38.9000&lon=-77.0300&alt=30000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
|
||||
|
||||
// One prohibited zone over the view centre (P-56's place, enlarged).
|
||||
const RING = [
|
||||
[-77.06, 38.88],
|
||||
[-77.0, 38.88],
|
||||
[-77.0, 38.92],
|
||||
[-77.06, 38.92],
|
||||
[-77.06, 38.88],
|
||||
];
|
||||
const PLACES = [
|
||||
{ id: 'node/1', name: 'QA Clinic', lat: 38.9, lon: -77.03, tags: {} },
|
||||
{ id: 'node/2', name: 'QA Hospital', lat: 38.905, lon: -77.02, tags: {} },
|
||||
{ id: 'node/3', name: '', lat: 38.895, lon: -77.04, tags: {} },
|
||||
];
|
||||
// The overlays' tiles: half-transparent tints, one per overlay, so the
|
||||
// view centre takes the colour of whichever is on.
|
||||
const tint = (r, g, b) =>
|
||||
sharp({
|
||||
create: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
channels: 4,
|
||||
background: { r, g, b, alpha: 0.6 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const TILES = {
|
||||
'sea-marks': await tint(255, 0, 255),
|
||||
'ski-pistes': await tint(0, 255, 0),
|
||||
};
|
||||
const tileRoute = (pattern, id) => ({
|
||||
pattern,
|
||||
respond: () => ({ status: 200, contentType: 'image/png', body: TILES[id] }),
|
||||
});
|
||||
const LAYERS = ['drone-airspace', 'osm-places', 'sea-marks', 'ski-pistes'];
|
||||
const strict = createStrict({
|
||||
origin: base.origin,
|
||||
layers: LAYERS,
|
||||
routes: [
|
||||
tileRoute('https://tiles.openseamap.org/*', 'sea-marks'),
|
||||
tileRoute('https://www.opensnowmap.org/*', 'ski-pistes'),
|
||||
],
|
||||
});
|
||||
await strict.run({}, async (b) => {
|
||||
const { page: p, errors } = await openPage(b);
|
||||
await p.evaluateOnNewDocument((ring) => {
|
||||
const fetch = window.fetch.bind(window);
|
||||
window.qaZoneReads = 0;
|
||||
window.fetch = (input, options) => {
|
||||
const { pathname } = new URL(input.url ?? input, document.baseURI);
|
||||
if (pathname === '/api/drone-airspace') {
|
||||
window.qaZoneReads++;
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
rows: [
|
||||
{
|
||||
stableId: '310',
|
||||
name: 'QA Zone',
|
||||
typeCode: 'P',
|
||||
type: 'Prohibited',
|
||||
lowerAltitude: 'SFC',
|
||||
upperAltitude: '18,000 ft MSL',
|
||||
timesOfUse: 'CONTINUOUS',
|
||||
agency: null,
|
||||
city: 'WASHINGTON',
|
||||
state: 'DC',
|
||||
polygons: [[ring]],
|
||||
},
|
||||
],
|
||||
stale: false,
|
||||
fetchedAt: Date.now(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
// OSM Places asks whether an Overpass is configured.
|
||||
if (pathname === '/api/overpass/status')
|
||||
return Promise.resolve(Response.json({ configured: true }));
|
||||
return fetch(input, options);
|
||||
};
|
||||
}, RING);
|
||||
await p.goto(base.href);
|
||||
const setEnabled = (id, on) =>
|
||||
p.evaluate(
|
||||
(layerId, show) =>
|
||||
window.__godsEyeView.dataManager.setEnabled(layerId, show, {
|
||||
origin: 'user',
|
||||
}),
|
||||
id,
|
||||
on,
|
||||
);
|
||||
// The view centre once the globe has settled and drawn a frame.
|
||||
const sample = async () => {
|
||||
await p.waitForFunction(
|
||||
() => {
|
||||
const d = window.__godsEyeView;
|
||||
if (!d?.engine) return false;
|
||||
d.engine.render.request('qa-open-settle');
|
||||
if (!d.engine.surface.tilesLoaded()) {
|
||||
window.__qaSettledSince = null;
|
||||
return false;
|
||||
}
|
||||
window.__qaSettledSince ??= performance.now();
|
||||
return performance.now() - window.__qaSettledSince > 1200;
|
||||
},
|
||||
{ polling: 100, timeout: 30000 },
|
||||
);
|
||||
await p.evaluate(() => {
|
||||
window.__qaSettledSince = null;
|
||||
return new Promise((resolve) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
remove();
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
engine.render.request('qa-open');
|
||||
});
|
||||
});
|
||||
// stats() reads the whole input, so the patch is cut out first.
|
||||
const patch = await sharp(await p.screenshot())
|
||||
.extract({ left: 492, top: 369, width: 40, height: 30 })
|
||||
.removeAlpha()
|
||||
.png()
|
||||
.toBuffer();
|
||||
const { channels } = await sharp(patch).stats();
|
||||
return channels.map((channel) => channel.mean);
|
||||
};
|
||||
// Until the layer shows over `off` by `test`, for at most 20 s (the
|
||||
// engines draw clamped polygons and imagery a few frames after their data).
|
||||
const shows = async (off, test) => {
|
||||
const deadline = Date.now() + 20000;
|
||||
let on = await sample();
|
||||
while (!test(on, off) && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
on = await sample();
|
||||
}
|
||||
return on;
|
||||
};
|
||||
const redder = (on, off) => on[0] - on[2] > off[0] - off[2] + 5;
|
||||
const magenta = (on, off) =>
|
||||
on[0] + on[2] - 2 * on[1] > off[0] + off[2] - 2 * off[1] + 10;
|
||||
const greener = (on, off) =>
|
||||
2 * on[1] - on[0] - on[2] > 2 * off[1] - off[0] - off[2] + 10;
|
||||
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(() => {
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
window.qaModels = Object.fromEntries(
|
||||
['drone-airspace', 'osm-places', 'sea-marks', 'ski-pistes'].map((id) => [
|
||||
id,
|
||||
dataManager.layers.get(id).module,
|
||||
]),
|
||||
);
|
||||
});
|
||||
for (const [leg, kind] of ['navara', 'cesium', 'navara'].entries()) {
|
||||
const readsBeforeSwitch = await p.evaluate(() => window.qaZoneReads);
|
||||
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
|
||||
await strict.switchTo(p, kind);
|
||||
const same = await p.evaluate(() =>
|
||||
Object.entries(window.qaModels).every(
|
||||
([id, model]) =>
|
||||
window.__godsEyeView.dataManager.layers.get(id).module === model,
|
||||
),
|
||||
);
|
||||
await strict.check(p, LAYERS, 'same layer models after the switch', same);
|
||||
// The zone stayed on across the switch: the next globe draws the rows
|
||||
// it already had, with no new request.
|
||||
const kept = await p.evaluate(() => ({
|
||||
reads: window.qaZoneReads,
|
||||
records: window.qaModels['drone-airspace'].getAnalystRecords().length,
|
||||
}));
|
||||
assert.deepEqual(
|
||||
kept,
|
||||
{ reads: readsBeforeSwitch, records: 1 },
|
||||
`${kind}: the switch keeps the zone rows without a refetch`,
|
||||
);
|
||||
}
|
||||
|
||||
// Drone airspace: the zone tints the centre red; after a switch it is
|
||||
// drawn from the rows already fetched.
|
||||
await setEnabled('drone-airspace', false);
|
||||
const zoneOff = await sample();
|
||||
await setEnabled('drone-airspace', true);
|
||||
if (leg === 0)
|
||||
await p.evaluate(() => window.qaModels['drone-airspace'].update());
|
||||
const zoneOn = await shows(zoneOff, redder);
|
||||
assert.ok(redder(zoneOn, zoneOff), `${kind} must draw the zone`);
|
||||
const zone = await p.evaluate(() => ({
|
||||
reads: window.qaZoneReads,
|
||||
records: window.qaModels['drone-airspace'].getAnalystRecords(),
|
||||
}));
|
||||
assert.equal(zone.records.length, 1);
|
||||
await strict.passed(p, 'drone-airspace', 'zone drawn over the centre', {
|
||||
off: zoneOff,
|
||||
on: zoneOn,
|
||||
reads: zone.reads,
|
||||
});
|
||||
console.log('PASS', kind, 'drone airspace zone');
|
||||
|
||||
// The zone stays on for the switch; the overlays sample without it.
|
||||
// OSM places: the results stay with the model across the switch, and
|
||||
// the engine draws a point for each.
|
||||
if (leg === 0)
|
||||
await p.evaluate((places) => {
|
||||
window.qaModels['osm-places'].setResults(places, {
|
||||
kind: 'clinic',
|
||||
label: 'Clinics',
|
||||
area: 'Washington',
|
||||
});
|
||||
}, PLACES);
|
||||
await setEnabled('osm-places', true);
|
||||
const places = await p.evaluate(() => {
|
||||
const d = window.__godsEyeView;
|
||||
const drawn =
|
||||
d.engine.kind === 'navara'
|
||||
? (d.engine.movers.list().find((set) => set.id === 'osm-places')
|
||||
?.count ?? 0)
|
||||
: (d.viewer.dataSources.getByName('osm-places')[0]?.entities.values
|
||||
.length ?? 0);
|
||||
return {
|
||||
records: window.qaModels['osm-places'].getAnalystRecords().length,
|
||||
drawn,
|
||||
};
|
||||
});
|
||||
assert.deepEqual(places, { records: 3, drawn: 3 }, `${kind} places`);
|
||||
await strict.passed(
|
||||
p,
|
||||
'osm-places',
|
||||
'three places drawn as points',
|
||||
places,
|
||||
);
|
||||
console.log('PASS', kind, 'OSM places', JSON.stringify(places));
|
||||
await setEnabled('osm-places', false);
|
||||
|
||||
// The overlays tint the centre with their tile; each on alone.
|
||||
await setEnabled('drone-airspace', false);
|
||||
for (const [id, test] of [
|
||||
['sea-marks', magenta],
|
||||
['ski-pistes', greener],
|
||||
]) {
|
||||
const off = await sample();
|
||||
await setEnabled(id, true);
|
||||
const on = await shows(off, test);
|
||||
assert.ok(test(on, off), `${kind} must drape ${id}`);
|
||||
const stats = await p.evaluate(
|
||||
(layerId) => window.qaModels[layerId].getStats(),
|
||||
id,
|
||||
);
|
||||
assert.equal(stats.error, null);
|
||||
await strict.passed(p, id, 'overlay tiles draped over the centre', {
|
||||
off,
|
||||
on,
|
||||
});
|
||||
console.log('PASS', kind, id);
|
||||
await setEnabled(id, false);
|
||||
}
|
||||
await setEnabled('drone-airspace', true);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
@@ -15,6 +15,12 @@
|
||||
* node scripts/qa-overlay-baseline.mjs --screenshots-dir /tmp/overlay-shots
|
||||
* node scripts/qa-overlay-baseline.mjs --hardware-gpu --headful
|
||||
* node scripts/qa-overlay-baseline.mjs --dist-dir /tmp/gev-dist
|
||||
* node scripts/qa-overlay-baseline.mjs --engine=navara
|
||||
*
|
||||
* Engine-neutral: the camera and frames go through the globe engine and the
|
||||
* layers through the QA probe (window.__godsEyeView.layers). A scene whose
|
||||
* layers the engine does not have is SKIPPED; without a Cesium viewer the
|
||||
* entity inventory reports the engine's mover sets instead.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
@@ -263,7 +269,10 @@ function printScene(result) {
|
||||
}
|
||||
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}`);
|
||||
if (totals.entities === undefined)
|
||||
console.log(` scene total : mover sets ${totals.moverSets} · instances ${totals.moverInstances}`);
|
||||
else
|
||||
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];
|
||||
@@ -345,33 +354,29 @@ async function installPageInstrumentation(page) {
|
||||
async function waitForApp(page) {
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
await page.waitForFunction(
|
||||
() => Boolean(window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager),
|
||||
() => Boolean(window.__godsEyeView?.layers && 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 */ }
|
||||
const { engine, viewer } = window.__godsEyeView;
|
||||
try { engine.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,
|
||||
}),
|
||||
await page.evaluate(async ([lon, lat, height, heading, pitch]) => {
|
||||
const { engine, viewer } = window.__godsEyeView;
|
||||
const geo = await import('/src/geo/index.js');
|
||||
engine.camera.cancelFlight();
|
||||
viewer?.scene.tweens?.removeAll?.();
|
||||
engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(lon, lat, height),
|
||||
orientation: { heading, pitch, roll: 0 },
|
||||
});
|
||||
viewer.scene.requestRender?.();
|
||||
engine.render.request('qa-overlay-baseline');
|
||||
}, camera);
|
||||
}
|
||||
|
||||
@@ -411,7 +416,19 @@ async function waitForLayer(page, layerId) {
|
||||
|
||||
async function readEntityInventory(page) {
|
||||
return page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const { viewer, layers } = window.__godsEyeView;
|
||||
// Without a Cesium viewer (Navara): the engine's mover sets.
|
||||
if (!viewer) {
|
||||
const sets = layers.movers();
|
||||
return {
|
||||
totals: {
|
||||
moverSets: sets.length,
|
||||
moverInstances: sets.reduce((sum, set) => sum + set.count, 0),
|
||||
},
|
||||
collections: sets,
|
||||
primitiveTypes: {},
|
||||
};
|
||||
}
|
||||
const time = viewer.clock.currentTime;
|
||||
const collections = [];
|
||||
const totals = {
|
||||
@@ -605,7 +622,7 @@ async function prepareTrackedFlight(page, cockpit) {
|
||||
});
|
||||
if (!tracked.ok) return `Civil tracking unavailable (${tracked.reason})`;
|
||||
try {
|
||||
await page.waitForFunction(() => Boolean(window.__godsEyeView.viewer.trackedEntity?.position), { timeout: 10_000 });
|
||||
await page.waitForFunction(() => Boolean(window.__godsEyeView.layers.following()), { timeout: 10_000 });
|
||||
} catch {
|
||||
return 'Civil tracking did not create a tracked entity';
|
||||
}
|
||||
@@ -638,15 +655,21 @@ async function prepareMission(page) {
|
||||
});
|
||||
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 });
|
||||
await page.waitForFunction(() => Boolean(window.__godsEyeView.layers.drawn('rocket-launches')?.selected), { timeout: 10_000 });
|
||||
} catch {
|
||||
return 'Mission selection did not reach the Cesium selected entity';
|
||||
return 'Mission selection did not reach the layer probe';
|
||||
}
|
||||
await sleep(2_000);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function prepareScene(page, scene) {
|
||||
const missing = await page.evaluate(
|
||||
(ids) => ids.filter((id) => !window.__godsEyeView.layers.available(id)),
|
||||
scene.layers,
|
||||
);
|
||||
if (missing.length)
|
||||
return { layerActivations: [], skipReason: `no ${missing.join(', ')} on ${ENGINE}` };
|
||||
if (scene.camera) await setCamera(page, scene.camera);
|
||||
const layerActivations = await activateLayers(page, scene.layers);
|
||||
let skipReason = null;
|
||||
@@ -678,7 +701,28 @@ async function readCanvasSummary(page) {
|
||||
|
||||
async function samplePhase(page, moving) {
|
||||
const raw = await page.evaluate(async ({ durationMs, movingCamera }) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const { engine } = window.__godsEyeView;
|
||||
// Cesium's rotateRight / rotateUp: the camera turned about the axis
|
||||
// through the globe centre along its up (right) vector.
|
||||
const rotate = (axis, angle) => {
|
||||
const pose = engine.camera.pose();
|
||||
const len = Math.hypot(axis.x, axis.y, axis.z);
|
||||
const [ux, uy, uz] = [axis.x / len, axis.y / len, axis.z / len];
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const turn = (v) => {
|
||||
const dot = ux * v.x + uy * v.y + uz * v.z;
|
||||
return {
|
||||
x: v.x * cos + (uy * v.z - uz * v.y) * sin + ux * dot * (1 - cos),
|
||||
y: v.y * cos + (uz * v.x - ux * v.z) * sin + uy * dot * (1 - cos),
|
||||
z: v.z * cos + (ux * v.y - uy * v.x) * sin + uz * dot * (1 - cos),
|
||||
};
|
||||
};
|
||||
engine.camera.setView({
|
||||
destination: turn(pose.position),
|
||||
orientation: { direction: turn(pose.direction), up: turn(pose.up) },
|
||||
});
|
||||
};
|
||||
const instrumentation = window.__overlayBaselineInstrumentation;
|
||||
instrumentation?.reset?.();
|
||||
const intervals = [];
|
||||
@@ -690,10 +734,11 @@ async function samplePhase(page, moving) {
|
||||
lastFrameAt = now;
|
||||
const elapsed = now - startedAt;
|
||||
if (movingCamera) {
|
||||
viewer.camera.rotateRight(0.00035);
|
||||
viewer.camera.rotateUp(0.00008 * Math.sin(elapsed / 600));
|
||||
const pose = engine.camera.pose();
|
||||
rotate(pose.up, -0.00035);
|
||||
rotate(engine.camera.pose().right, 0.00008 * Math.sin(elapsed / 600));
|
||||
}
|
||||
viewer.scene.requestRender?.();
|
||||
engine.render.request('qa-overlay-baseline');
|
||||
if (elapsed >= durationMs) resolve();
|
||||
else requestAnimationFrame(step);
|
||||
};
|
||||
@@ -813,7 +858,12 @@ async function runScene(browser, scene, shotsDir) {
|
||||
}
|
||||
}
|
||||
|
||||
const APP_URL = getOpt('--url', DEFAULT_URL);
|
||||
const ENGINE = process.argv.find((value) => value.startsWith('--engine='))?.slice(9) ?? 'cesium';
|
||||
const APP_URL = (() => {
|
||||
const url = new URL(getOpt('--url', DEFAULT_URL));
|
||||
url.searchParams.set('engine', ENGINE);
|
||||
return url.href;
|
||||
})();
|
||||
const DPR = Number(getOpt('--dpr', '1'));
|
||||
const JSON_PATH = getOpt('--json');
|
||||
const SCREENSHOTS_DIR = getOpt('--screenshots-dir');
|
||||
@@ -847,7 +897,7 @@ async function main() {
|
||||
executablePath: CHROME_EXECUTABLE,
|
||||
headless: HEADFUL ? false : 'new',
|
||||
args: [
|
||||
...(HARDWARE_GPU ? [] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']),
|
||||
...(process.env.QA_ANGLE ? ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE}`] : HARDWARE_GPU ? [] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']),
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
@@ -900,9 +950,11 @@ async function main() {
|
||||
webglRenderer: browserContext.webglRenderer,
|
||||
},
|
||||
viewport: { ...VIEWPORT, dpr: browserContext.dpr },
|
||||
gpuMode: HARDWARE_GPU ? 'hardware' : 'swiftshader',
|
||||
gpuMode: process.env.QA_ANGLE || (HARDWARE_GPU ? 'hardware' : 'swiftshader'),
|
||||
chromeExecutable: CHROME_EXECUTABLE,
|
||||
chromeArgs: HARDWARE_GPU
|
||||
chromeArgs: process.env.QA_ANGLE
|
||||
? ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE}`, '--no-sandbox']
|
||||
: HARDWARE_GPU
|
||||
? ['--no-sandbox']
|
||||
: ['--enable-unsafe-swiftshader', '--use-gl=swiftshader', '--no-sandbox'],
|
||||
sampleMs: SAMPLE_MS,
|
||||
|
||||
+80
-57
@@ -68,26 +68,41 @@
|
||||
* "world-overlay honours occluder churn as paint work" in
|
||||
* the project roadmap.
|
||||
*
|
||||
* Usage: node scripts/qa-perf.mjs [--url http://localhost:4173]
|
||||
* Usage: node scripts/qa-perf.mjs [--url http://localhost:4173] [--engine=cesium|navara]
|
||||
* Engine-neutral: the camera, frames and layers go through the globe engine
|
||||
* and the QA probe (window.__godsEyeView.layers); on an engine without a
|
||||
* layer (Navara today) that layer's checks are SKIPPED, not failed.
|
||||
* 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 baseUrl = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4173';
|
||||
const engineKind = argv.find((value) => value.startsWith('--engine='))?.slice(9) ?? 'cesium';
|
||||
const url = (() => {
|
||||
const parsed = new URL(baseUrl);
|
||||
parsed.searchParams.set('engine', engineKind);
|
||||
return parsed.href;
|
||||
})();
|
||||
|
||||
const results = [];
|
||||
let skipped = 0;
|
||||
function check(name, pass, detail) {
|
||||
results.push({ name, pass });
|
||||
const tag = pass ? 'PASS' : 'FAIL';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${JSON.stringify(detail)}` : ''}`);
|
||||
}
|
||||
function skip(name, reason) {
|
||||
skipped += 1;
|
||||
console.log(` [SKIP] ${name} — ${reason}`);
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
protocolTimeout: 300_000,
|
||||
args: [
|
||||
...(process.env.QA_ANGLE ? [`--use-angle=${process.env.QA_ANGLE}`] : []),
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--window-size=1440,900',
|
||||
@@ -103,20 +118,18 @@ 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 page.waitForFunction(() => !!window.__godsEyeView?.layers, { timeout: 90_000 });
|
||||
const layerAvailable = (id) => page.evaluate((layerId) => window.__godsEyeView.layers.available(layerId), id);
|
||||
// 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,
|
||||
}),
|
||||
const geo = await import('/src/geo/index.js');
|
||||
gev.engine.camera.cancelFlight();
|
||||
gev.engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(-97.74, 30.27, 60_000),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
for (const [id, entry] of gev.dataManager.layers) {
|
||||
@@ -128,9 +141,9 @@ try {
|
||||
|
||||
/** Count scene postRender fires and rAF ticks over windowMs. */
|
||||
const countFrames = (windowMs) => page.evaluate((ms) => new Promise((resolve) => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
const { frames } = window.__godsEyeView.engine;
|
||||
let renders = 0; let rafs = 0;
|
||||
const remove = scene.postRender.addEventListener(() => { renders += 1; });
|
||||
const remove = frames.postRender.addEventListener(() => { renders += 1; });
|
||||
const t0 = performance.now();
|
||||
const tick = () => {
|
||||
rafs += 1;
|
||||
@@ -255,10 +268,10 @@ try {
|
||||
const detectMove = await Promise.all([
|
||||
countFrames(2_500),
|
||||
page.evaluate(() => new Promise((resolve) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const { camera } = window.__godsEyeView.engine;
|
||||
let steps = 0;
|
||||
const id = setInterval(() => {
|
||||
v.camera.moveForward(50);
|
||||
camera.zoomIn(50);
|
||||
steps += 1;
|
||||
if (steps >= 20) { clearInterval(id); resolve(true); }
|
||||
}, 60);
|
||||
@@ -281,13 +294,13 @@ try {
|
||||
// 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 remove = gev.engine.frames.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);
|
||||
gev.engine.camera.zoomIn(50);
|
||||
steps += 1;
|
||||
if (steps >= 20) {
|
||||
clearInterval(id);
|
||||
@@ -325,9 +338,15 @@ try {
|
||||
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);
|
||||
// The animated styles are Cesium post-process stages; Navara draws its own
|
||||
// looks and has no style animation (a known gap, not a regression).
|
||||
if (engineKind === 'cesium') {
|
||||
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);
|
||||
} else {
|
||||
skip('animated style takes the style-anim hold and keeps frames flowing', `the visual styles are Cesium post stages; ${engineKind} has none`);
|
||||
}
|
||||
// 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
|
||||
@@ -349,26 +368,26 @@ try {
|
||||
);
|
||||
|
||||
// ── 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);
|
||||
if (await layerAvailable('satellites')) {
|
||||
await page.evaluate(() => window.__godsEyeView.layers.enable('satellites'));
|
||||
const dSat = await diag();
|
||||
check('satellites enable registers its holder', dSat?.holds.includes('satellites'), dSat);
|
||||
await page.evaluate(() => window.__godsEyeView.layers.disable('satellites'));
|
||||
await new Promise((r) => setTimeout(r, 2_000));
|
||||
const dSatOff = await diag();
|
||||
check('satellites disable releases its holder', !dSatOff?.holds.includes('satellites'), dSatOff);
|
||||
} else {
|
||||
skip('satellites holder enters and leaves diagnostics', `no satellites layer on ${engineKind}`);
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
const { camera } = window.__godsEyeView.engine;
|
||||
let steps = 0;
|
||||
const id = setInterval(() => {
|
||||
v.camera.moveForward(50);
|
||||
camera.zoomIn(50);
|
||||
steps += 1;
|
||||
if (steps >= 20) { clearInterval(id); resolve(true); }
|
||||
}, 60);
|
||||
@@ -378,36 +397,40 @@ try {
|
||||
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 });
|
||||
if (await layerAvailable('flights')) {
|
||||
await page.evaluate(async () => {
|
||||
await window.__godsEyeView.layers.enable('flights');
|
||||
});
|
||||
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);
|
||||
// ── 5. flights disabled → idle again ──────────────────────────────────
|
||||
await page.evaluate(async () => {
|
||||
await window.__godsEyeView.layers.disable('flights');
|
||||
});
|
||||
// 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);
|
||||
} else {
|
||||
skip('flights on → continuous, off → idle again', `no flights layer on ${engineKind}`);
|
||||
}
|
||||
} 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`);
|
||||
console.log(`RESULT: ${passed} passed, ${results.length - passed} failed, ${skipped} skipped (engine ${engineKind})`);
|
||||
process.exit(passed === results.length ? 0 : 1);
|
||||
@@ -0,0 +1,88 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { stations, installRadioFixture } from './fixtures/w2/radio.mjs';
|
||||
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||
const url = localPreview(process.argv[2]);
|
||||
// The long soak and switch timings live in qa-strict-soak.mjs.
|
||||
url.search = '?engine=navara&welcome=0';
|
||||
url.hash = 'v=2&lat=30.27&lon=-97.74&alt=12000&pitch=-90&map=sentinel2&l=';
|
||||
const strict = createStrict({ origin: url.origin, layers: ['radio'] });
|
||||
await strict.run({}, async (browser) => {
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await installRadioFixture(page);
|
||||
await page.goto(url.href);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.engine?.kind === 'navara' &&
|
||||
document.querySelector('#loading-screen.hidden'),
|
||||
);
|
||||
await page.evaluate(async (id) => {
|
||||
const { dataManager, styleManager, tileset } = window.__godsEyeView;
|
||||
await styleManager.initialRestorePromise;
|
||||
if (window.__GOOGLE_MAPS_API_KEY__ || tileset)
|
||||
throw Error('Expected keyless preview');
|
||||
window.qaLayer = dataManager.layers.get('radio').module;
|
||||
await dataManager.setEnabled('radio', true, { origin: 'user' });
|
||||
}, stations[0].id);
|
||||
await page.waitForFunction(() => window.qaLayer?.getStats().count === 750);
|
||||
await page.evaluate((id) => {
|
||||
window.qaLayer.selectStation(id, {
|
||||
autoplay: true,
|
||||
focus: false,
|
||||
origin: 'user',
|
||||
});
|
||||
window.qaLayer.setRadioVolume(0.36);
|
||||
}, stations[10].id);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__qaRadio.plays.length === 1 &&
|
||||
window.qaLayer.getUIState().audioState === 'playing',
|
||||
);
|
||||
const first = await page.evaluate(() => {
|
||||
window.qaAudio = window.qaPlayer;
|
||||
window.qaSnapshot = window.qaLayer.getAcceptedCatalogSnapshot();
|
||||
return window.__qaRadio.reads;
|
||||
});
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
await strict.switchTo(page, kind);
|
||||
const state = await page.evaluate(() => ({
|
||||
same:
|
||||
window.qaLayer ===
|
||||
window.__godsEyeView.dataManager.layers.get('radio').module,
|
||||
player: window.qaAudio === window.qaPlayer,
|
||||
snapshot:
|
||||
window.qaSnapshot === window.qaLayer.getAcceptedCatalogSnapshot(),
|
||||
plays: window.__qaRadio.plays.length,
|
||||
reads: window.__qaRadio.reads,
|
||||
state: window.qaLayer.getUIState(),
|
||||
count: window.qaLayer.getStats().count,
|
||||
}));
|
||||
console.log(
|
||||
kind,
|
||||
state.count,
|
||||
state.state.selected?.id,
|
||||
state.state.audioState,
|
||||
state.plays,
|
||||
state.reads,
|
||||
);
|
||||
assert.ok(state.same && state.player && state.snapshot);
|
||||
assert.equal(state.plays, 1);
|
||||
assert.equal(state.reads, first);
|
||||
assert.equal(state.count, 750);
|
||||
assert.equal(state.state.audioState, 'playing');
|
||||
assert.equal(state.state.selected.id, stations[10].id);
|
||||
assert.equal(state.state.volume, 0.36);
|
||||
// The fixture's HTMLMediaElement.prototype.play counts play() calls: the
|
||||
// switch must not start the stream again.
|
||||
await strict.passed(
|
||||
page,
|
||||
'radio',
|
||||
'same model, player element and catalog snapshot; 750 stations, one catalog read, no second play(), same station playing at volume 0.36',
|
||||
);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS one live radio player, one station directory and the same selection',
|
||||
);
|
||||
});
|
||||
+27
-41
@@ -9,12 +9,14 @@
|
||||
* independence, restoration without autoplay, responsive UI, and a clean
|
||||
* console. Screenshots are written under the gitignored `qa-shots/radio/`.
|
||||
*
|
||||
* Run: node scripts/qa-radio.mjs --url http://localhost:4173
|
||||
* Run: node scripts/qa-radio.mjs --url http://localhost:4193
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { stations } from './fixtures/w2/radio.mjs';
|
||||
import { isExpectedPreviewSetupError } from './qa-browserEvidence.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
@@ -24,8 +26,10 @@ const option = (name, fallback) => {
|
||||
const index = args.indexOf(name);
|
||||
return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
|
||||
};
|
||||
const APP_URL = option('--url', process.env.QA_BASE_URL || 'http://localhost:4173');
|
||||
const APP_URL = option('--url', process.env.QA_BASE_URL);
|
||||
if (!APP_URL) throw new Error('Radio QA requires an explicit --url or QA_BASE_URL');
|
||||
const APP_ORIGIN = new URL(APP_URL).origin;
|
||||
const expectedPreviewErrors = [];
|
||||
// Radio owns this harness's keyboard and pointer actions; the welcome dialog
|
||||
// has its own acceptance harness and must not intercept those gestures.
|
||||
const RADIO_URL = new URL(APP_URL);
|
||||
@@ -42,35 +46,6 @@ const chrome = chromeCandidates.find((candidate) => {
|
||||
try { return fs.existsSync(candidate); } catch { return false; }
|
||||
});
|
||||
|
||||
const tags = [
|
||||
'news', 'talk', 'weather,emergency', 'public safety,scanner',
|
||||
'aviation,atc', 'marine,maritime', 'traffic,transit',
|
||||
'music,jazz', 'music,rock', 'community',
|
||||
];
|
||||
const stations = Array.from({ length: 750 }, (_, index) => {
|
||||
const row = Math.floor(index / 30);
|
||||
const column = index % 30;
|
||||
return {
|
||||
id: `00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`,
|
||||
name: index === 360
|
||||
? '100.3 The River - WQRV - Meridianville/Huntsville, AL'
|
||||
: index === 390
|
||||
? 'La Zeta (Hermosillo) - 93.9 FM - XHHY - Uniradio - Hermosillo, Sonora'
|
||||
: `QA Radio ${String(index + 1).padStart(3, '0')}`,
|
||||
lat: -72 + row * 6,
|
||||
lon: -174 + column * 12,
|
||||
streamUrl: `https://audio.example.test/${index}.mp3`,
|
||||
homepage: `https://station.example.test/${index}`,
|
||||
tags: tags[index % tags.length].split(','),
|
||||
languages: index === 360 ? ['Spanish'] : ['English'],
|
||||
state: `Region ${row + 1}`,
|
||||
country: 'United States',
|
||||
countryCode: 'US',
|
||||
metadataTrust: 'untrusted-community',
|
||||
codec: index % 2 ? 'AAC' : 'MP3',
|
||||
bitrate: 128,
|
||||
};
|
||||
});
|
||||
|
||||
const results = [];
|
||||
function check(name, ok, detail = '') {
|
||||
@@ -274,6 +249,10 @@ async function main() {
|
||||
const failedResponses = [];
|
||||
const externalCesiumEndpointFailures = [];
|
||||
page.on('console', (message) => {
|
||||
if (isExpectedPreviewSetupError(message, { preview: args.includes('--preview'), baseUrl: APP_URL })) {
|
||||
expectedPreviewErrors.push('/api/setup/status: 404');
|
||||
return;
|
||||
}
|
||||
if (message.type() === 'error') {
|
||||
const sourceUrl = message.location()?.url || '';
|
||||
consoleErrors.push(sourceUrl ? `${message.text()} [${sourceUrl}]` : message.text());
|
||||
@@ -689,7 +668,7 @@ async function main() {
|
||||
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
||||
const dataSource = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index))
|
||||
.find((item) => item.name === 'Radio stations');
|
||||
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
|
||||
const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay;
|
||||
const { radioStationIdFromPick } = await import('/src/data/radio.js');
|
||||
return new Promise((resolve) => {
|
||||
let removePostRender = null;
|
||||
@@ -1275,7 +1254,9 @@ async function main() {
|
||||
);
|
||||
const clusterBadge = await page.evaluate(async () => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
|
||||
const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay;
|
||||
const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js');
|
||||
const { radioStationIdFromPick } = await import('/src/data/radio.js');
|
||||
const baselineSolveRevision = getWorldOverlayDiagnostics().solveRevision;
|
||||
const source = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index))
|
||||
.find((item) => item.name === 'Radio stations');
|
||||
@@ -1384,7 +1365,7 @@ async function main() {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
||||
const ellipsoid = viewer.scene.globe.ellipsoid;
|
||||
const { getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
|
||||
const { getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay;
|
||||
const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js');
|
||||
const source = radio.getOverlayDiagnostics();
|
||||
const cameraPosition = viewer.camera.positionWC;
|
||||
@@ -1446,7 +1427,7 @@ async function main() {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
||||
const ellipsoid = viewer.scene.globe.ellipsoid;
|
||||
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
|
||||
const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay;
|
||||
const paintedClusters = () => new Promise((resolve) => {
|
||||
let removePostRender = null;
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -3589,7 +3570,7 @@ async function main() {
|
||||
radio.setFilter('news');
|
||||
viewer.scene.requestRender();
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
|
||||
const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay;
|
||||
return new Promise((resolve) => {
|
||||
let removePostRender = null;
|
||||
let timeout = null;
|
||||
@@ -3759,7 +3740,7 @@ async function main() {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const selectedEntity = window.__godsEyeView.viewer.entities.values
|
||||
.find((entity) => String(entity.id).startsWith('radio:selected:'));
|
||||
const { getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
|
||||
const { getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay;
|
||||
const flightTargets = window.__qaRadioFlyToCalls.map(({ destination }) => {
|
||||
const position = viewer.scene.globe.ellipsoid.cartesianToCartographic(destination);
|
||||
return {
|
||||
@@ -3942,7 +3923,7 @@ async function main() {
|
||||
});
|
||||
const selectedEntity = viewer.entities.values
|
||||
.find((entity) => String(entity.id) === `radio:selected:${selected.id}`);
|
||||
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
|
||||
const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay;
|
||||
const expectedEntryId = `selected:${selected.id}`;
|
||||
let painted = null;
|
||||
// Radio's horizon pass reveals the selected entity on its own timer
|
||||
@@ -4001,7 +3982,7 @@ async function main() {
|
||||
});
|
||||
const selectedEntity = viewer.entities.values
|
||||
.find((entity) => String(entity.id) === `radio:selected:${selected.id}`);
|
||||
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
|
||||
const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay;
|
||||
const expectedEntryId = `selected:${selected.id}`;
|
||||
let rect = null;
|
||||
for (let attempt = 0; attempt < 20 && !rect; attempt += 1) {
|
||||
@@ -4038,11 +4019,14 @@ async function main() {
|
||||
selectedNativePick = picks.find((picked) => picked?.id === selectedEntity);
|
||||
selectedPointPick = picks.find((picked) => (
|
||||
picked?.id === selectedEntity
|
||||
&& String(picked?.primitive?.constructor?.name || '').includes('PointPrimitive')
|
||||
// Public point properties survive production minification; class names do not.
|
||||
&& Number.isFinite(picked?.primitive?.pixelSize)
|
||||
&& Number.isFinite(picked?.primitive?.outlineWidth)
|
||||
));
|
||||
selectedBillboardPick = picks.find((picked) => (
|
||||
picked?.id === selectedEntity
|
||||
&& String(picked?.primitive?.constructor?.name || '').includes('Billboard')
|
||||
&& Number.isFinite(picked?.primitive?.width)
|
||||
&& Number.isFinite(picked?.primitive?.height)
|
||||
));
|
||||
if (!selectedPointPick) {
|
||||
await new Promise((resolve) => {
|
||||
@@ -4544,6 +4528,8 @@ async function main() {
|
||||
}
|
||||
|
||||
const failures = results.filter((result) => !result.ok).length;
|
||||
const reportPath = option('--report');
|
||||
if (reportPath) fs.writeFileSync(reportPath, JSON.stringify({ pass: failures === 0, checks: results, expectedPreviewErrors }, null, 2) + '\n');
|
||||
console.log(`\nRadio QA: ${results.length - failures}/${results.length} passed`);
|
||||
console.log(`RESULT: ${results.length - failures} passed, ${failures} failed, 0 skipped`);
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { launchFixture } from '../src/bench/launchFixture.js';
|
||||
import {
|
||||
satelliteFixture,
|
||||
SATELLITE_FIXTURE_EPOCH,
|
||||
} from '../src/bench/satelliteFixture.js';
|
||||
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||
const url = localPreview(process.argv[2]),
|
||||
missions = process.argv.includes('--missions');
|
||||
url.search = '?engine=navara&welcome=0';
|
||||
url.hash = 'v=2&lat=5&lon=5&alt=18000000&pitch=-90&heading=0&map=sentinel2&l=';
|
||||
const layer = missions ? 'rocket-launches' : 'satellites';
|
||||
const strict = createStrict({
|
||||
origin: url.origin,
|
||||
layers: [missions ? 'rocket-launches' : 'satellites'],
|
||||
});
|
||||
await strict.run({}, async (b) => {
|
||||
const p = await b.newPage();
|
||||
const errors = [];
|
||||
p.on('pageerror', (e) => {
|
||||
errors.push(e.message);
|
||||
console.log('error', e.message);
|
||||
});
|
||||
const groups = Object.fromEntries(
|
||||
[
|
||||
'stations',
|
||||
'visual',
|
||||
'gps-ops',
|
||||
'glo-ops',
|
||||
'galileo',
|
||||
'geo',
|
||||
'starlink',
|
||||
].map((g) => [g, satelliteFixture(g)]),
|
||||
);
|
||||
await p.evaluateOnNewDocument(
|
||||
(groups, launches, epoch) => {
|
||||
const fetch = window.fetch.bind(window);
|
||||
window.spaceReads = {};
|
||||
const mono = performance.now();
|
||||
Date.now = () => epoch + performance.now() - mono;
|
||||
window.fetch = (input, opt) => {
|
||||
const path = new URL(input.url ?? input, location.href).pathname;
|
||||
if (path.startsWith('/api/celestrak/') || path === '/api/launches') {
|
||||
window.spaceReads[path] = (window.spaceReads[path] || 0) + 1;
|
||||
const g = path.split('/').pop();
|
||||
return Promise.resolve(
|
||||
g === 'launches'
|
||||
? Response.json(launches)
|
||||
: new Response(groups[g] ?? groups.stations),
|
||||
);
|
||||
}
|
||||
return fetch(input, opt);
|
||||
};
|
||||
},
|
||||
groups,
|
||||
launchFixture(),
|
||||
SATELLITE_FIXTURE_EPOCH,
|
||||
);
|
||||
await p.goto(url.href);
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.engine?.kind === 'navara' &&
|
||||
document.querySelector('#loading-screen.hidden'),
|
||||
);
|
||||
await p.evaluate(async (missions) => {
|
||||
const { dataManager, styleManager, tileset } = window.__godsEyeView;
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Expected keyless preview');
|
||||
await styleManager.initialRestorePromise;
|
||||
window.sa = dataManager.layers.get('satellites').module;
|
||||
window.la = dataManager.layers.get('rocket-launches').module;
|
||||
if (missions) {
|
||||
const result = await styleManager.setContextMode('space-missions');
|
||||
if (!result.ok) throw Error('Missions mode unavailable: ' + result.error);
|
||||
} else await dataManager.setEnabled('satellites', true, { origin: 'user' });
|
||||
}, missions);
|
||||
if (!missions) {
|
||||
await p.waitForFunction(() => window.sa.getStats().count === 839, {
|
||||
timeout: 40000,
|
||||
});
|
||||
console.log('core catalog ready');
|
||||
await p.evaluate(() => window.sa.setParams({ catalog: 'dense' }));
|
||||
await p.waitForFunction(() => window.sa.getStats().count === 6839, {
|
||||
timeout: 60000,
|
||||
});
|
||||
console.log('dense catalog ready');
|
||||
assert.ok(
|
||||
await p.evaluate(() => window.sa.trackById(25544, { origin: 'user' })),
|
||||
);
|
||||
} else {
|
||||
await p.waitForFunction(() => window.la.getStats().count === 3, {
|
||||
timeout: 40000,
|
||||
});
|
||||
await p.evaluate(() => {
|
||||
window.la.selectMissionAt(0);
|
||||
window.la.startSelectedReplay();
|
||||
window.la.setReplaySpeed(4);
|
||||
});
|
||||
await p.waitForFunction(() => window.la.qaDrawn()?.replay?.active, {
|
||||
timeout: 20000,
|
||||
});
|
||||
await p.waitForFunction(
|
||||
() => window.la.qaDrawn()?.replay?.phaseProgress > 0.001,
|
||||
{ timeout: 35000 },
|
||||
);
|
||||
await p.evaluate(() => window.la.toggleReplayPause());
|
||||
}
|
||||
const before = await p.evaluate(() => ({
|
||||
reads: window.spaceReads,
|
||||
stamp: (window.la.getStats().count ? window.la : window.sa).getStats()
|
||||
.lastUpdate,
|
||||
mission: window.la.qaDrawn()?.selected,
|
||||
replay: window.la.qaDrawn()?.replay,
|
||||
}));
|
||||
console.log(
|
||||
'before switch',
|
||||
before.reads,
|
||||
before.mission,
|
||||
before.replay?.active,
|
||||
);
|
||||
// Each source read exactly once: the seven TLE groups of the dense
|
||||
// catalog; in Space Missions the launch list and every TLE group the
|
||||
// mission view asks for.
|
||||
const groupPaths = Object.keys(groups).map((g) => '/api/celestrak/' + g);
|
||||
const readsOnce = async () => {
|
||||
const reads = await p.evaluate(() => ({ ...window.spaceReads }));
|
||||
await strict.check(
|
||||
p,
|
||||
layer,
|
||||
missions
|
||||
? 'the launch list and each TLE group read exactly once'
|
||||
: 'each of the seven TLE groups read exactly once',
|
||||
Object.values(reads).every((n) => n === 1) &&
|
||||
(missions
|
||||
? reads['/api/launches'] === 1
|
||||
: Object.keys(reads).sort().join() === [...groupPaths].sort().join()),
|
||||
reads,
|
||||
);
|
||||
};
|
||||
await readsOnce();
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
await strict.switchTo(p, kind);
|
||||
const state = await p.evaluate(() => {
|
||||
const { dataManager, engine, styleManager } = window.__godsEyeView;
|
||||
return {
|
||||
ids: [
|
||||
dataManager.layers.get('satellites').module === window.sa,
|
||||
dataManager.layers.get('rocket-launches').module === window.la,
|
||||
],
|
||||
reads: window.spaceReads,
|
||||
sat: window.sa.getStats(),
|
||||
params: window.sa.getParams(),
|
||||
follow: !!engine.camera.following(),
|
||||
ring: window.sa.qaDrawn()?.rings.length,
|
||||
mission: window.la.qaDrawn(),
|
||||
mode: styleManager.getContextModeState().mode,
|
||||
missionStats: window.la.getStats(),
|
||||
};
|
||||
});
|
||||
console.log(
|
||||
kind,
|
||||
state.ids,
|
||||
state.sat.count,
|
||||
state.params.selectedSatTrackingId,
|
||||
state.follow,
|
||||
state.mode,
|
||||
state.mission?.selected,
|
||||
state.mission?.replay?.phaseProgress,
|
||||
);
|
||||
assert.ok(state.ids.every(Boolean));
|
||||
assert.deepEqual(state.reads, before.reads);
|
||||
if (missions) {
|
||||
assert.equal(state.mode, 'space-missions');
|
||||
assert.equal(state.mission.selected, before.mission);
|
||||
assert.ok(state.mission.paused && state.mission.replay.active);
|
||||
assert.equal(state.mission.speed, 4);
|
||||
assert.equal(state.missionStats.lastUpdate, before.stamp);
|
||||
assert.equal(
|
||||
state.mission.replay.phaseProgress,
|
||||
before.replay.phaseProgress,
|
||||
);
|
||||
} else {
|
||||
assert.equal(state.sat.count, 6839);
|
||||
assert.equal(state.params.selectedSatTrackingId, 25544);
|
||||
assert.ok(state.follow && state.ring > 0);
|
||||
assert.equal(state.sat.lastUpdate, before.stamp);
|
||||
}
|
||||
await strict.passed(
|
||||
p,
|
||||
layer,
|
||||
missions
|
||||
? 'same models; Space Missions mode, the same mission paused at the same progress, speed 4'
|
||||
: 'same models; 6 839 objects, the ISS tracked with its ring, same update stamp',
|
||||
);
|
||||
await readsOnce();
|
||||
}
|
||||
if (missions) {
|
||||
await p.evaluate(() => window.la.toggleReplayPause());
|
||||
await p.waitForFunction(() => window.la.qaDrawn()?.paused === false);
|
||||
await p.waitForFunction(
|
||||
(progress) => window.la.qaDrawn()?.replay?.phaseProgress > progress,
|
||||
{},
|
||||
before.replay.phaseProgress,
|
||||
);
|
||||
await strict.switchTo(p, 'cesium');
|
||||
const moving = await p.evaluate(() => window.la.qaDrawn());
|
||||
await strict.check(
|
||||
p,
|
||||
layer,
|
||||
'a running replay keeps advancing through the switch',
|
||||
moving.replay?.active &&
|
||||
!moving.paused &&
|
||||
moving.replay.phaseProgress > before.replay.phaseProgress,
|
||||
);
|
||||
const left = await p.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setContextMode(null),
|
||||
);
|
||||
assert.ok(left.ok);
|
||||
assert.equal(
|
||||
await p.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.isEnabled('rocket-launches'),
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS retained space data and active camera across both planets');
|
||||
});
|
||||
+66
-37
@@ -53,9 +53,15 @@ const getOpt = (name, dflt) => {
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const BASE_URL = getOpt('--url', 'http://localhost:4173');
|
||||
// --engine=cesium|navara: without a viewer the layer is read through
|
||||
// the QA probe (window.__godsEyeView.layers.drawn) and the military checks are
|
||||
// skipped where the engine has no military layer.
|
||||
const ENGINE = (argv.find((arg) => arg.startsWith('--engine=')) ?? '--engine=cesium').slice(9);
|
||||
const APP_URL =
|
||||
ENGINE === 'cesium' ? BASE_URL : `${BASE_URL.replace(/\/$/, '')}/?engine=${ENGINE}`;
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const SHOT_DIR = path.resolve('qa-shots/b5');
|
||||
const SHOT_DIR = path.resolve(ENGINE === 'cesium' ? 'qa-shots/b5' : `qa-shots/b5-${ENGINE}`);
|
||||
|
||||
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
|
||||
@@ -173,7 +179,7 @@ async function main() {
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
`--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`,
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
@@ -200,6 +206,7 @@ async function main() {
|
||||
|
||||
// ---- Straight-flight fetch shim (installed before any app code runs) ----
|
||||
await page.evaluateOnNewDocument((spec) => {
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
window.__SPR = spec;
|
||||
window.__SPR.epochMs = Date.now();
|
||||
window.__SPR_HITS = { opensky: 0, mil: 0 };
|
||||
@@ -275,9 +282,12 @@ async function main() {
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
|
||||
() => window.__godsEyeView && window.__godsEyeView.engine && window.__godsEyeView.dataManager && window.__godsEyeView.layers,
|
||||
{ timeout: 60000, polling: 200 }
|
||||
);
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.initialRestorePromise);
|
||||
await page.waitForFunction(() => document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 180000 });
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.orbitController.stop());
|
||||
console.log(' App globals ready.');
|
||||
|
||||
// ---- In-page probes: billboard walk, model walk, tracked-model finder ---
|
||||
@@ -285,6 +295,10 @@ async function main() {
|
||||
// Every billboard (image + alignedAxis + id) in every collection.
|
||||
window.__collectBillboards = function () {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
if (!v)
|
||||
return ['flights', 'military'].flatMap((id) => window.__godsEyeView.layers.drawn(id) ?? [])
|
||||
.filter((row) => !row.trackedModel)
|
||||
.map(({ id, image, scale, show }) => ({ id, image, scale, show }));
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
@@ -304,6 +318,10 @@ async function main() {
|
||||
// Every glTF model primitive (modelMatrix + ready flag), with id + scale.
|
||||
window.__collectModels = function () {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
if (!v)
|
||||
return ['flights', 'military'].flatMap((id) => window.__godsEyeView.layers.drawn(id) ?? [])
|
||||
.filter((row) => row.model && (row.trackedModel || !row.tracked))
|
||||
.map(({ id, model }) => ({ id, ...model, radius: null }));
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
@@ -331,14 +349,14 @@ async function main() {
|
||||
};
|
||||
// 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;
|
||||
window.__planeCanvasCoords = async function (layer, renderDelaySec, altOf) {
|
||||
const geo = await import('/src/geo/index.js');
|
||||
const { engine } = window.__godsEyeView;
|
||||
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);
|
||||
const pos = geo.cartesian3.fromDegrees(s.lon, s.lat, altOf === 'ft' ? p.altFt * 0.3048 : p.altM);
|
||||
const c = engine.projector.worldToWindow(pos);
|
||||
return `${p.icao || p.hex}=(${c ? Math.round(c.x) : '-'},${c ? Math.round(c.y) : '-'})`;
|
||||
}).join(' ');
|
||||
};
|
||||
@@ -348,26 +366,29 @@ async function main() {
|
||||
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;
|
||||
const v = window.__godsEyeView.viewer ?? null;
|
||||
const hasMil = dm.layers.has('military');
|
||||
window.__SPR.timeOffsetSec = -32;
|
||||
await dm.setEnabled('flights', true);
|
||||
await dm.setEnabled('military', true);
|
||||
if (hasMil) await dm.setEnabled('military', true);
|
||||
const fl = dm.layers.get('flights').module;
|
||||
const mil = dm.layers.get('military').module;
|
||||
const mil = hasMil ? dm.layers.get('military').module : null;
|
||||
for (const off of [-24, -16, -8, 0]) {
|
||||
window.__SPR.timeOffsetSec = off;
|
||||
await fl.update(v);
|
||||
await mil.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 };
|
||||
window.__SPR_DRIVER = setInterval(() => { fl.update(v); mil?.update(v); }, 15000);
|
||||
return { fl: fl.getStats().count, mil: mil ? mil.getStats().count : null, hits: window.__SPR_HITS };
|
||||
});
|
||||
console.log(` flights count=${primed.fl} military count=${primed.mil} | shim hits opensky=${primed.hits.opensky} mil=${primed.hits.mil}`);
|
||||
const hasMilitary = primed.mil !== null;
|
||||
if (!hasMilitary) console.log(` [SKIP] the military rows — no military layer on ${ENGINE}`);
|
||||
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; }
|
||||
primed.fl === SPRITES.flights.length && (!hasMilitary || primed.mil === SPRITES.military.length),
|
||||
`flights=${primed.fl}/${SPRITES.flights.length} military=${hasMilitary ? `${primed.mil}/${SPRITES.military.length}` : 'n/a'}`);
|
||||
if (!(primed.fl > 0 && (!hasMilitary || primed.mil > 0))) { finish(); return; }
|
||||
|
||||
// ========================================================================
|
||||
// B2 — billboard glyph + scale per class (machine check)
|
||||
@@ -379,37 +400,38 @@ async function main() {
|
||||
let scaleBad = [];
|
||||
for (const [id, klass] of expectedFlights) {
|
||||
const bb = bbById.get(id);
|
||||
const wantImg = aircraftIcon(klass);
|
||||
const wantImg = [aircraftIcon(klass), aircraftIcon(klass, 192)];
|
||||
const wantScale = CLASS_SCALE_2D[klass] || 1;
|
||||
if (!bb || bb.image !== wantImg) glyphBad.push(`${id}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||
if (!bb || !wantImg.includes(bb.image)) 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) {
|
||||
for (const [id, klass] of hasMilitary ? expectedMilitary : []) {
|
||||
const bb = bbById.get(id);
|
||||
const wantImg = aircraftIcon(klass);
|
||||
const wantImg = [aircraftIcon(klass), aircraftIcon(klass, 192)];
|
||||
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 || !wantImg.includes(bb.image)) 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`);
|
||||
glyphBad.length === 0, glyphBad.length ? glyphBad.join(' ') : `${expectedFlights.size + (hasMilitary ? expectedMilitary.size : 0)} 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`);
|
||||
const distinctGlyphs = new Set([...expectedFlights.values(), ...(hasMilitary ? expectedMilitary.values() : [])].map((k) => aircraftIcon(k)));
|
||||
const wantGlyphs = hasMilitary ? 8 : new Set(expectedFlights.values()).size;
|
||||
record(`B2c: all ${wantGlyphs} classes exercised with ${wantGlyphs} distinct glyphs`,
|
||||
distinctGlyphs.size === wantGlyphs, `${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),
|
||||
await page.evaluate(async ({ lat, lon, height }) => {
|
||||
const camera = window.__godsEyeView.engine.camera;
|
||||
camera.cancelFlight(); // the boot fly-to-Austin otherwise stomps setView
|
||||
const geo = await import('/src/geo/index.js');
|
||||
camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(lon, lat, height),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
}, { lat, lon, height });
|
||||
@@ -422,6 +444,7 @@ async function main() {
|
||||
const zoomToRange = async (want) => {
|
||||
await page.evaluate((want) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
if (!v) return; // the engine follow keeps its own range (screenshots only)
|
||||
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
|
||||
@@ -441,10 +464,12 @@ async function main() {
|
||||
|
||||
// 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'))}`);
|
||||
if (hasMilitary) {
|
||||
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') });
|
||||
@@ -507,6 +532,9 @@ async function main() {
|
||||
// ========================================================================
|
||||
// B3 + 3D screenshots — military cluster
|
||||
// ========================================================================
|
||||
if (!hasMilitary) {
|
||||
console.log(` [SKIP] 3D military — no military layer on ${ENGINE}`);
|
||||
} else {
|
||||
console.log('\n3D — military cluster (jet.glb, per-class scale)');
|
||||
await page.evaluate(() => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
@@ -551,6 +579,7 @@ async function main() {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
import { serveFixtures, newFixture } from './qa-street-level.mjs';
|
||||
import { KV_LINE, KV_SEQUENCE_ID } from './fixtures/street-level/kartaView.mjs';
|
||||
import {
|
||||
PNX_PARK,
|
||||
PNX_SEQUENCES,
|
||||
panoramaxPictures,
|
||||
} from './fixtures/street-level/panoramax.mjs';
|
||||
const panorama = process.argv.includes('--panoramax');
|
||||
const provider = panorama ? 'panoramax' : 'kartaview';
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0';
|
||||
const at = panorama
|
||||
? PNX_PARK
|
||||
: { lat: 38.5831, lon: KV_LINE.lon, height: 850 };
|
||||
base.hash = `v=2&lat=${at.lat}&lon=${at.lon}&alt=${at.height}&heading=0&pitch=-90&roll=0&map=sentinel2&l=`;
|
||||
// serveFixtures (qa-street-level.mjs) answers these provider hosts.
|
||||
const strict = createStrict({
|
||||
origin: base.origin,
|
||||
layers: ['street-level'],
|
||||
fixtures: [
|
||||
'*://*.mapillary.com/*',
|
||||
'https://api.kartaview.org/*',
|
||||
'https://cdn.kartaview.org/*',
|
||||
'*://*.openstreetcam.org/*',
|
||||
'https://api.panoramax.xyz/*',
|
||||
'https://panoramax.openstreetmap.fr/*',
|
||||
],
|
||||
});
|
||||
await strict.run({}, async (browser) => {
|
||||
const p = await browser.newPage();
|
||||
await p.setViewport({ width: 1280, height: 900 });
|
||||
const errors = [],
|
||||
fixture = newFixture({ configured: true });
|
||||
p.on('pageerror', (e) => {
|
||||
errors.push(e.message);
|
||||
console.error(e.message.split('?')[0]);
|
||||
});
|
||||
p.on('console', (m) => {
|
||||
if (/shader|registerMeshPicker/i.test(m.text()) && m.type() === 'error')
|
||||
errors.push(m.text());
|
||||
});
|
||||
await serveFixtures(p, fixture);
|
||||
let photoRequests = 0;
|
||||
p.on('request', (req) => {
|
||||
if (
|
||||
/\.jpg(?:$|\?)/i.test(new URL(req.url()).pathname) &&
|
||||
/openstreetcam|kartaview|panoramax/.test(new URL(req.url()).hostname)
|
||||
)
|
||||
photoRequests++;
|
||||
});
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(async (provider) => {
|
||||
const { dataManager, tileset } = window.__godsEyeView;
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Expected keyless preview');
|
||||
window.qaStreet = dataManager.layers.get('street-level').module;
|
||||
await dataManager.setEnabled('street-level', true, { origin: 'user' });
|
||||
window.qaStreet.setProviderEnabled(provider, true);
|
||||
}, provider);
|
||||
const until = async (fn) => {
|
||||
try {
|
||||
await p.waitForFunction(fn, { timeout: 20000 });
|
||||
} catch (e) {
|
||||
console.log(await p.evaluate(() => window.qaStreet.getUIState()));
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
await until(() => window.qaStreet.getUIState().coverage.count > 0);
|
||||
// The fixture drive passes under the middle of the canvas. Click its actual picked line.
|
||||
const pickLine = async () => {
|
||||
const xy = await p.evaluate(
|
||||
(prefix) => {
|
||||
const { engine } = window.__godsEyeView,
|
||||
canvas = engine.host.canvas,
|
||||
rect = canvas.getBoundingClientRect();
|
||||
for (let dy = -30; dy <= 30; dy += 3)
|
||||
for (let dx = -40; dx <= 40; dx++) {
|
||||
const at = { x: rect.width / 2 + dx, y: rect.height / 2 + dy },
|
||||
pick = engine.picker.pick(at),
|
||||
id = pick?.id?.id ?? pick?.id;
|
||||
if (typeof id === 'string' && id.startsWith(prefix))
|
||||
return { x: at.x + rect.x, y: at.y + rect.y, id };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
panorama ? 'pnx:seq:' : 'kv:line:',
|
||||
);
|
||||
assert.ok(xy, 'native ground line was pickable');
|
||||
console.log('pick', xy);
|
||||
await p.mouse.click(xy.x, xy.y);
|
||||
await until(
|
||||
() =>
|
||||
window.qaStreet.getUIState().street.open &&
|
||||
window.qaStreet.getUIState().sequence.images > 0 &&
|
||||
(document.querySelector('#sl-viewer img')?.naturalWidth > 0 ||
|
||||
document.querySelector('#sl-viewer canvas:not([hidden])')?.width > 0),
|
||||
);
|
||||
await strict.passed(
|
||||
p,
|
||||
'street-level',
|
||||
'a click on the coverage line opens its sequence and photo',
|
||||
xy,
|
||||
);
|
||||
return xy;
|
||||
};
|
||||
const xy = await pickLine();
|
||||
if (panorama) {
|
||||
const expected = panoramaxPictures().filter(
|
||||
(p) => p.sequenceId === xy.id.slice('pnx:seq:'.length),
|
||||
).length;
|
||||
await p.waitForFunction(
|
||||
(n) => window.qaStreet.getUIState().sequence.images === n,
|
||||
{ timeout: 20000 },
|
||||
expected,
|
||||
);
|
||||
}
|
||||
const first = await p.evaluate(() => {
|
||||
window.streetPhoto =
|
||||
document.querySelector('#sl-viewer canvas:not([hidden])') ??
|
||||
document.querySelector('#sl-viewer img');
|
||||
return window.qaStreet.getUIState();
|
||||
});
|
||||
assert.ok(
|
||||
(panorama ? PNX_SEQUENCES : [KV_SEQUENCE_ID]).includes(
|
||||
first.street.sequenceId,
|
||||
),
|
||||
);
|
||||
const reads = photoRequests;
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
await strict.switchTo(p, kind);
|
||||
// The new globe reloads coverage right after it attaches: compare once
|
||||
// that load is over.
|
||||
await until(
|
||||
() =>
|
||||
!window.qaStreet.getUIState().coverage.loading &&
|
||||
window.qaStreet.getUIState().coverage.count > 0,
|
||||
);
|
||||
const state = await p.evaluate(() => ({
|
||||
model:
|
||||
window.__godsEyeView.dataManager.layers.get('street-level').module ===
|
||||
window.qaStreet,
|
||||
samePhoto:
|
||||
document.querySelector('#sl-viewer')?.contains(window.streetPhoto) &&
|
||||
window.streetPhoto.isConnected,
|
||||
ui: window.qaStreet.getUIState(),
|
||||
}));
|
||||
console.log(
|
||||
kind,
|
||||
state.model,
|
||||
state.samePhoto,
|
||||
state.ui.street.imageId,
|
||||
state.ui.sequence.images,
|
||||
state.ui.coverage.count,
|
||||
);
|
||||
assert.equal(state.model, true);
|
||||
assert.equal(state.samePhoto, true);
|
||||
assert.ok(state.ui.street.open);
|
||||
assert.equal(state.ui.street.imageId, first.street.imageId);
|
||||
assert.equal(state.ui.sequence.images, first.sequence.images);
|
||||
assert.deepEqual(state.ui.filter, first.filter);
|
||||
assert.ok(state.ui.coverage.count > 0);
|
||||
await strict.passed(
|
||||
p,
|
||||
'street-level',
|
||||
'same model, the same mounted photo, image and sequence; coverage kept',
|
||||
);
|
||||
}
|
||||
assert.equal(photoRequests, reads);
|
||||
await strict.passed(
|
||||
p,
|
||||
'street-level',
|
||||
'no photo requested again by the switches',
|
||||
);
|
||||
// Click-picking on Cesium: close the viewer, switch, click the line again.
|
||||
await p.evaluate(() => window.qaStreet.closeViewer());
|
||||
await until(() => !window.qaStreet.getUIState().street.open);
|
||||
await strict.switchTo(p, 'cesium');
|
||||
await until(
|
||||
() =>
|
||||
!window.qaStreet.getUIState().coverage.loading &&
|
||||
window.qaStreet.getUIState().coverage.count > 0,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
await pickLine();
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(fixture.unknown, []);
|
||||
console.log(
|
||||
'PASS native line pick, same mounted photo and sequence across two switches',
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import { blockPaidQaMaps } from './qa-map-policy.mjs';
|
||||
/**
|
||||
* Browser QA for the Street Level layer against a running server:
|
||||
* `npm run qa:street-level -- --url http://localhost:4173`.
|
||||
@@ -124,7 +125,7 @@ const KEYLESS_HOSTS = [
|
||||
* `fixture`, which steps may change mid-run. `configured: undefined` lets the
|
||||
* server's real status route answer.
|
||||
*/
|
||||
async function serveFixtures(page, fixture) {
|
||||
export async function serveFixtures(page, fixture) {
|
||||
// CDP Fetch on just these URLs: page-wide interception stalls Cesium workers.
|
||||
const cdp = await page.createCDPSession();
|
||||
await cdp.send('Fetch.enable', {
|
||||
@@ -205,7 +206,7 @@ async function serveFixtures(page, fixture) {
|
||||
});
|
||||
}
|
||||
|
||||
function newFixture(overrides = {}) {
|
||||
export function newFixture(overrides = {}) {
|
||||
return {
|
||||
configured: undefined,
|
||||
rejected: false,
|
||||
@@ -426,6 +427,7 @@ async function main() {
|
||||
// The layer only warns on listener exceptions, so count those as errors.
|
||||
const open = async (name, fixture) => {
|
||||
const page = await browser.newPage();
|
||||
await blockPaidQaMaps(page);
|
||||
monitors.push(
|
||||
watchPage(page, {
|
||||
name,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// Contract: a cold start loads only the engine it starts. From the network
|
||||
// log of a fresh page:
|
||||
// - a cold Navara start requests nothing of the Cesium library (/cesium/…:
|
||||
// Cesium.js, its workers and assets);
|
||||
// - a cold Cesium start requests no chunk that carries three or Navara code
|
||||
// (the FORBIDDEN_MARKERS of src/tooling/mainChunk.js) and no Navara wasm.
|
||||
// Control: after a pick to the other engine, its code is requested, so the
|
||||
// detection sees what it is looking for.
|
||||
//
|
||||
// node scripts/qa-strict-cold-chunks.mjs http://127.0.0.1:4208
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { forbiddenMarkersIn } from '../src/tooling/mainChunk.js';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
import { installSessionFixtures, openApp } from './qa-strict-session.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
const strict = createStrict({ origin: base.origin, layers: [] });
|
||||
const contents = new Map();
|
||||
// The preview's own file: three or Navara markers in it?
|
||||
const markersOf = async (url) => {
|
||||
if (!contents.has(url))
|
||||
contents.set(
|
||||
url,
|
||||
fetch(url)
|
||||
.then((r) => r.text())
|
||||
.then(forbiddenMarkersIn),
|
||||
);
|
||||
return contents.get(url);
|
||||
};
|
||||
const cesiumCode = (url) => new URL(url).pathname.startsWith('/cesium/');
|
||||
const navaraCode = async (url) => {
|
||||
const { pathname } = new URL(url);
|
||||
if (/navara.*\.wasm$/.test(pathname)) return ['navara wasm'];
|
||||
if (!pathname.endsWith('.js') || pathname.startsWith('/cesium/')) return [];
|
||||
return markersOf(url);
|
||||
};
|
||||
const results = [];
|
||||
strict.extra.contracts = results;
|
||||
await strict.run({}, async (b) => {
|
||||
for (const start of ['navara', 'cesium']) {
|
||||
const p = await b.newPage();
|
||||
await p.setViewport({ width: 1280, height: 800 });
|
||||
const urls = [];
|
||||
p.on('request', (request) => {
|
||||
if (request.url().startsWith(base.origin)) urls.push(request.url());
|
||||
});
|
||||
await installSessionFixtures(p);
|
||||
await openApp(p, base.origin, start);
|
||||
// Let lazy loads that follow the start (workers, idle prefetch) happen.
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
const before = [...urls];
|
||||
const other = start === 'navara' ? 'cesium' : 'navara';
|
||||
await strict.pick(p, other);
|
||||
await waitForEngine(p, other);
|
||||
const after = urls.slice(before.length);
|
||||
const otherCode = async (list) => {
|
||||
const found = [];
|
||||
for (const url of list)
|
||||
if (
|
||||
other === 'cesium'
|
||||
? cesiumCode(url)
|
||||
: (await navaraCode(url)).length > 0
|
||||
)
|
||||
found.push(new URL(url).pathname);
|
||||
return [...new Set(found)];
|
||||
};
|
||||
const row = {
|
||||
start,
|
||||
otherEngine: other,
|
||||
loadedAtColdStart: await otherCode(before),
|
||||
loadedAfterPick: (await otherCode(after)).length,
|
||||
};
|
||||
results.push(row);
|
||||
console.log(
|
||||
row.loadedAtColdStart.length ? 'FAIL' : 'PASS',
|
||||
JSON.stringify(row),
|
||||
);
|
||||
await p.close();
|
||||
}
|
||||
for (const row of results)
|
||||
assert.ok(
|
||||
row.loadedAfterPick > 0,
|
||||
`control: picking ${row.otherEngine} loads its code`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
results.filter((row) => row.loadedAtColdStart.length),
|
||||
[],
|
||||
'a cold start loaded the other engine',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
// Contract: a WebGL context loss during a switch (WEBGL_lose_context, the
|
||||
// context coming back 1 s later with restoreContext(), as after a GPU
|
||||
// reset). After each case the page runs one engine on the same session, the
|
||||
// history matches the outcome (one entry for a completed switch, none for a
|
||||
// rollback), the running engine draws a non-blank frame, and a later pick
|
||||
// back to the engine that lost its context draws a non-blank frame too.
|
||||
//
|
||||
// - outgoing: the running engine's context is lost by the same input that
|
||||
// starts the switch (cold Navara→Cesium, then warm Cesium→Navara);
|
||||
// - incoming: the parked target engine's context is lost by the input that
|
||||
// asks for it (warm, both directions).
|
||||
//
|
||||
// node scripts/qa-strict-context-loss.mjs http://127.0.0.1:4208
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
import {
|
||||
installFirstFrameProbe,
|
||||
installSessionFixtures,
|
||||
openApp,
|
||||
rememberEngine,
|
||||
sessionState,
|
||||
startSession,
|
||||
} from './qa-strict-session.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
const other = (kind) => (kind === 'navara' ? 'cesium' : 'navara');
|
||||
const strict = createStrict({ origin: base.origin, layers: [] });
|
||||
const results = [];
|
||||
strict.extra.contracts = results;
|
||||
await strict.run({}, async (b) => {
|
||||
let p = null;
|
||||
const errors = [];
|
||||
// A fresh page on `from`; warm visits the other engine first.
|
||||
const fresh = async (from, warm) => {
|
||||
await p?.close().catch(() => {});
|
||||
p = await b.newPage();
|
||||
await p.setViewport({ width: 1280, height: 800 });
|
||||
p.on('pageerror', (e) => errors.push(e.message));
|
||||
await installSessionFixtures(p, { flights: 20 });
|
||||
await installFirstFrameProbe(p);
|
||||
await openApp(p, base.origin, from);
|
||||
await startSession(p, ['earthquakes', 'flights']);
|
||||
if (!warm) return;
|
||||
for (const kind of [other(from), from]) {
|
||||
await strict.pick(p, kind);
|
||||
await waitForEngine(p, kind);
|
||||
await rememberEngine(p);
|
||||
}
|
||||
};
|
||||
|
||||
// The running engine draws a non-blank frame within 15 s.
|
||||
const draws = (kind) =>
|
||||
p.evaluate(
|
||||
(kind) =>
|
||||
Promise.race([
|
||||
window.__qaFirstFrame(kind).then(() => true),
|
||||
new Promise((resolve) => setTimeout(() => resolve(false), 15000)),
|
||||
]),
|
||||
kind,
|
||||
);
|
||||
const settle = async () => {
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
(window.__godsEyeView &&
|
||||
!document.querySelector('#globe-engine')?.disabled &&
|
||||
!document.querySelector('.engine-switch-frame') &&
|
||||
document.querySelector('#loading-screen.hidden')) ||
|
||||
document.querySelector('.engine-switch-retry'),
|
||||
{ polling: 100, timeout: 90000 },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
};
|
||||
const intact = (state) =>
|
||||
state.running &&
|
||||
state.manager &&
|
||||
state.models &&
|
||||
state.enabled &&
|
||||
state.address === state.engine &&
|
||||
state.picker === state.engine;
|
||||
|
||||
for (const [loser, from, temperature] of [
|
||||
['outgoing', 'navara', 'cold'],
|
||||
['outgoing', 'cesium', 'warm'],
|
||||
['incoming', 'navara', 'warm'],
|
||||
['incoming', 'cesium', 'warm'],
|
||||
]) {
|
||||
const to = other(from);
|
||||
const label = `${temperature} ${from}→${to}, ${loser} context lost`;
|
||||
// Cold cases, and any case after a failed one, start on a fresh page.
|
||||
if (!p || temperature === 'cold' || results.at(-1)?.failed.length)
|
||||
await fresh(from, temperature === 'warm');
|
||||
else if ((await sessionState(p)).engine !== from) {
|
||||
await strict.pick(p, from);
|
||||
await waitForEngine(p, from);
|
||||
await rememberEngine(p);
|
||||
}
|
||||
const before = await sessionState(p);
|
||||
// The input that starts the switch also loses the context; it comes
|
||||
// back a second later.
|
||||
await p.evaluate(
|
||||
(loser, from, to) => {
|
||||
const select = document.querySelector('#globe-engine');
|
||||
select.addEventListener(
|
||||
'change',
|
||||
() => {
|
||||
const engine =
|
||||
loser === 'outgoing'
|
||||
? window.__godsEyeView.engine
|
||||
: window.__qaEngines.get(to);
|
||||
const canvas = engine.host.canvas;
|
||||
const context =
|
||||
canvas.getContext('webgl2') ?? canvas.getContext('webgl');
|
||||
const extension = context.getExtension('WEBGL_lose_context');
|
||||
window.__qaLoss = {
|
||||
kind: engine.kind,
|
||||
lost: false,
|
||||
restored: false,
|
||||
};
|
||||
canvas.addEventListener(
|
||||
'webglcontextrestored',
|
||||
() => (window.__qaLoss.restoredEvent = true),
|
||||
{ once: true },
|
||||
);
|
||||
extension.loseContext();
|
||||
window.__qaLoss.lost = context.isContextLost();
|
||||
setTimeout(() => {
|
||||
extension.restoreContext();
|
||||
window.__qaLoss.restored = true;
|
||||
}, 1000);
|
||||
},
|
||||
{ once: true, capture: true },
|
||||
);
|
||||
},
|
||||
loser,
|
||||
from,
|
||||
to,
|
||||
);
|
||||
try {
|
||||
await strict.pick(p, to, { settle: false });
|
||||
await settle();
|
||||
await p.waitForFunction(() => window.__qaLoss?.restored, {
|
||||
polling: 100,
|
||||
timeout: 10000,
|
||||
});
|
||||
await settle();
|
||||
} catch (error) {
|
||||
results.push({
|
||||
case: label,
|
||||
failed: [
|
||||
'the switch could not be made: ' +
|
||||
String(error.message).slice(0, 200),
|
||||
],
|
||||
});
|
||||
console.log('FAIL', JSON.stringify(results.at(-1)));
|
||||
continue;
|
||||
}
|
||||
const after = await sessionState(p);
|
||||
const loss = await p.evaluate(() => window.__qaLoss);
|
||||
const outcome = after.engine === to ? 'switched' : 'rolled back';
|
||||
if (after.running) await rememberEngine(p);
|
||||
const drawn = after.running ? await draws(after.engine) : false;
|
||||
// Why no frame passed, when none did: frames drawn in 3 s by the engine
|
||||
// the page runs and by the one the probe watched, and how many of 48x48
|
||||
// samples of the last frame differ from the first one.
|
||||
const noFrame =
|
||||
after.running && !drawn
|
||||
? await p.evaluate(async (kind) => {
|
||||
const running = window.__godsEyeView.engine;
|
||||
const watched = window.__qaEngines.get(kind);
|
||||
const probe = document.createElement('canvas');
|
||||
probe.width = probe.height = 48;
|
||||
const context = probe.getContext('2d', {
|
||||
willReadFrequently: true,
|
||||
});
|
||||
const count = { running: 0, watched: 0, differing: null };
|
||||
const offs = [
|
||||
running.frames.postRender.addEventListener(() => {
|
||||
count.running++;
|
||||
context.drawImage(running.host.canvas, 0, 0, 48, 48);
|
||||
const d = context.getImageData(0, 0, 48, 48).data;
|
||||
let differing = 0;
|
||||
for (let i = 4; i < d.length; i += 4)
|
||||
if (
|
||||
Math.abs(d[i] - d[0]) +
|
||||
Math.abs(d[i + 1] - d[1]) +
|
||||
Math.abs(d[i + 2] - d[2]) >
|
||||
24
|
||||
)
|
||||
differing++;
|
||||
count.differing = differing;
|
||||
}),
|
||||
watched === running
|
||||
? () => {}
|
||||
: watched?.frames.postRender.addEventListener(
|
||||
() => count.watched++,
|
||||
),
|
||||
];
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
offs.forEach((off) => off?.());
|
||||
return {
|
||||
...count,
|
||||
sameEngine: watched === running,
|
||||
connected: running.host.canvas.isConnected,
|
||||
};
|
||||
}, after.engine)
|
||||
: null;
|
||||
console.log(
|
||||
'after',
|
||||
label,
|
||||
JSON.stringify({ after, loss, drawn }),
|
||||
JSON.stringify(
|
||||
await p.evaluate(() => ({
|
||||
toggle: document
|
||||
.querySelector('#control-panel-toggle')
|
||||
?.getAttribute('aria-expanded'),
|
||||
retry: !!document.querySelector('.engine-switch-retry'),
|
||||
status: document.querySelector('#loading-screen .loader-status')
|
||||
?.textContent,
|
||||
loading: document.querySelector('#loading-screen')?.className,
|
||||
})),
|
||||
),
|
||||
);
|
||||
// Back to the engine that lost its context, with real input.
|
||||
const back = after.engine === loss.kind ? other(loss.kind) : loss.kind;
|
||||
let backDrawn = null,
|
||||
backState = null;
|
||||
let backError = null;
|
||||
if (after.running) {
|
||||
try {
|
||||
await strict.pick(p, back);
|
||||
await waitForEngine(p, back, { timeout: 60000 });
|
||||
} catch (error) {
|
||||
backError = String(error.message).slice(0, 300);
|
||||
}
|
||||
backState = await sessionState(p);
|
||||
if (backState.running) await rememberEngine(p);
|
||||
backDrawn = backState.engine === back ? await draws(back) : false;
|
||||
}
|
||||
const row = {
|
||||
case: label,
|
||||
lostKind: loss.kind,
|
||||
lost: loss.lost,
|
||||
restoredEvent: Boolean(loss.restoredEvent),
|
||||
outcome,
|
||||
sessionIntact: intact(after),
|
||||
history: after.history - before.history,
|
||||
drawn,
|
||||
...(noFrame ? { noFrame } : {}),
|
||||
back,
|
||||
backEngine: backState?.engine ?? null,
|
||||
backSessionIntact: backState ? intact(backState) : false,
|
||||
backDrawn,
|
||||
backError,
|
||||
pageErrors: errors.splice(0),
|
||||
};
|
||||
row.failed = [
|
||||
row.lost || 'the context was not lost',
|
||||
row.sessionIntact || 'the session is not intact',
|
||||
row.history === (outcome === 'switched' ? 1 : 0) ||
|
||||
'the history does not match the outcome',
|
||||
row.drawn || 'no non-blank frame from the running engine',
|
||||
row.backEngine === back || `the pick back to ${back} did not run it`,
|
||||
row.backSessionIntact || 'the session is not intact after the pick back',
|
||||
row.backDrawn || `no non-blank frame from ${back} after the pick back`,
|
||||
row.backError === null || 'the pick back failed: ' + row.backError,
|
||||
row.pageErrors.length === 0 || 'uncaught page errors',
|
||||
].filter((v) => v !== true);
|
||||
results.push(row);
|
||||
console.log(row.failed.length ? 'FAIL' : 'PASS', JSON.stringify(row));
|
||||
}
|
||||
assert.deepEqual(
|
||||
results.filter((row) => row.failed.length),
|
||||
[],
|
||||
'context-loss cases that broke the contract',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
// Contract: failure injection in both directions, cold and warm, and a slow
|
||||
// first frame. Each case starts on a fresh page with a session (earthquakes
|
||||
// and flights, one aircraft selected) and switches with real input on the
|
||||
// picker.
|
||||
//
|
||||
// - A failed switch rolls back to the engine that was running, with the
|
||||
// same session (manager, every layer model, enabled layers, selection),
|
||||
// the address still naming it and no history entry; an ordinary pick
|
||||
// afterwards succeeds and adds exactly one entry.
|
||||
// cold: the incoming engine cannot get a WebGL context;
|
||||
// warm: the parked incoming engine fails to resume.
|
||||
// - A first frame slower than 10 s (animation frames held for 12 s) does not
|
||||
// roll back: the switch completes, the cover goes after the 10 s wait, one
|
||||
// history entry, the session intact, and the frame arrives.
|
||||
//
|
||||
// node scripts/qa-strict-failure-matrix.mjs http://127.0.0.1:4208
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
import {
|
||||
assertSession,
|
||||
installFirstFrameProbe,
|
||||
installSessionFixtures,
|
||||
openApp,
|
||||
rememberEngine,
|
||||
SESSION_HASH,
|
||||
sessionState,
|
||||
startSession,
|
||||
} from './qa-strict-session.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
const other = (kind) => (kind === 'navara' ? 'cesium' : 'navara');
|
||||
const strict = createStrict({ origin: base.origin, layers: [] });
|
||||
const results = [];
|
||||
strict.extra.contracts = results;
|
||||
await strict.run({}, async (b) => {
|
||||
// A fresh page on `start` with the session; `warm` visits the other
|
||||
// engine first so it is parked.
|
||||
const open = async (start, warm) => {
|
||||
const p = await b.newPage();
|
||||
await p.setViewport({ width: 1280, height: 800 });
|
||||
p.errors = [];
|
||||
p.on('pageerror', (e) => p.errors.push(e.message));
|
||||
await installSessionFixtures(p, { flights: 20 });
|
||||
await installFirstFrameProbe(p);
|
||||
// Flights on with the first fixture aircraft tracked (the address's
|
||||
// layer options), so the session carries a selection.
|
||||
await openApp(
|
||||
p,
|
||||
base.origin,
|
||||
start,
|
||||
SESSION_HASH.replace(/&l=$/, '&l=f&lo=f.e.1_f.t.a00000'),
|
||||
);
|
||||
await startSession(p, ['earthquakes', 'flights']);
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.styleManager.getAircraftTrackingTarget()?.id ===
|
||||
'a00000',
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
if (warm) {
|
||||
await strict.pick(p, other(start));
|
||||
await waitForEngine(p, other(start));
|
||||
await rememberEngine(p);
|
||||
await strict.pick(p, start);
|
||||
await waitForEngine(p, start);
|
||||
await rememberEngine(p);
|
||||
// The warm-up's Navara→Cesium switch drops the tracked aircraft (a
|
||||
// defect of its own, reported); track it
|
||||
// again so this case tests the failure path with a selection.
|
||||
await p.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('flights')
|
||||
.module.trackById('a00000', { origin: 'user' }),
|
||||
);
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.styleManager.getAircraftTrackingTarget()?.id ===
|
||||
'a00000',
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
}
|
||||
return p;
|
||||
};
|
||||
const selection = (p) =>
|
||||
p.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager.getAircraftTrackingTarget()?.id ??
|
||||
null,
|
||||
);
|
||||
|
||||
for (const [start, temperature] of [
|
||||
['navara', 'cold'],
|
||||
['cesium', 'cold'],
|
||||
['cesium', 'warm'],
|
||||
['navara', 'warm'],
|
||||
]) {
|
||||
const target = other(start);
|
||||
const label = `${temperature} ${start}→${target} failure`;
|
||||
const p = await open(start, temperature === 'warm');
|
||||
const before = await sessionState(p);
|
||||
const selected = await selection(p);
|
||||
if (temperature === 'cold')
|
||||
await p.evaluate(() => {
|
||||
const original = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = function (type, ...args) {
|
||||
// The running globe already holds its context; only a new
|
||||
// globe asks for one.
|
||||
if (/webgl/i.test(String(type))) {
|
||||
window.__qaRefused = (window.__qaRefused ?? 0) + 1;
|
||||
throw new Error('QA: no WebGL context for the new globe');
|
||||
}
|
||||
return original.call(this, type, ...args);
|
||||
};
|
||||
window.__qaRestore = () => {
|
||||
HTMLCanvasElement.prototype.getContext = original;
|
||||
};
|
||||
});
|
||||
else
|
||||
await p.evaluate((target) => {
|
||||
const parked = window.__qaEngines.get(target);
|
||||
const request = parked.render.request;
|
||||
parked.render.request = function (reason, ...args) {
|
||||
if (reason === 'engine-resume' && !window.__qaRestored) {
|
||||
window.__qaRefused = (window.__qaRefused ?? 0) + 1;
|
||||
throw new Error('QA: the parked globe failed to resume');
|
||||
}
|
||||
return request.call(this, reason, ...args);
|
||||
};
|
||||
window.__qaRestore = () => {
|
||||
window.__qaRestored = true;
|
||||
};
|
||||
}, target);
|
||||
await strict.pick(p, target);
|
||||
await waitForEngine(p, start);
|
||||
const refused = await p.evaluate(() => window.__qaRefused ?? 0);
|
||||
const rolled = await assertSession(p, start, label + ': rollback');
|
||||
// The tracked aircraft, given up to 10 s to be re-acquired.
|
||||
const kept = await p
|
||||
.waitForFunction(
|
||||
(id) =>
|
||||
window.__godsEyeView?.styleManager.getAircraftTrackingTarget()?.id ===
|
||||
id,
|
||||
{ timeout: 10000, polling: 100 },
|
||||
selected,
|
||||
)
|
||||
.then(() => selected)
|
||||
.catch(() => selection(p));
|
||||
await p.evaluate(() => window.__qaRestore());
|
||||
// An ordinary pick afterwards.
|
||||
await strict.pick(p, target);
|
||||
await waitForEngine(p, target);
|
||||
const retried = await assertSession(p, target, label + ': retry');
|
||||
const afterRetry = await p
|
||||
.waitForFunction(
|
||||
(id) =>
|
||||
window.__godsEyeView?.styleManager.getAircraftTrackingTarget()?.id ===
|
||||
id,
|
||||
{ timeout: 10000, polling: 100 },
|
||||
selected,
|
||||
)
|
||||
.then(() => selected)
|
||||
.catch(() => selection(p));
|
||||
const row = {
|
||||
case: label,
|
||||
refused,
|
||||
rollbackTarget: rolled.engine,
|
||||
historyAfterFailure: rolled.history - before.history,
|
||||
selectionKept: kept === selected,
|
||||
selectionAfterRollback: kept,
|
||||
historyAfterRetry: retried.history - before.history,
|
||||
selectionAfterRetry: afterRetry,
|
||||
errors: p.errors.length,
|
||||
};
|
||||
row.failed = [
|
||||
refused > 0 || 'the injected failure did not happen',
|
||||
row.rollbackTarget === start || 'rolled back elsewhere',
|
||||
row.historyAfterFailure === 0 || 'the failed switch changed the history',
|
||||
selected === 'a00000' || 'no tracked aircraft before the failure',
|
||||
row.selectionKept || 'the tracked aircraft is lost after the rollback',
|
||||
row.historyAfterRetry === 1 || 'the retry did not add one entry',
|
||||
row.selectionAfterRetry === selected ||
|
||||
'the tracked aircraft is lost after the retry',
|
||||
].filter((v) => v !== true);
|
||||
results.push(row);
|
||||
console.log(row.failed.length ? 'FAIL' : 'PASS', JSON.stringify(row));
|
||||
await p.close();
|
||||
}
|
||||
|
||||
// Slow first frame: animation frames held for 12 s from the person's
|
||||
// input (a stalled GPU or compositor), cold and warm.
|
||||
for (const [start, temperature] of [
|
||||
['cesium', 'cold'],
|
||||
['navara', 'warm'],
|
||||
]) {
|
||||
const target = other(start);
|
||||
const label = `${temperature} ${start}→${target} first frame after 12 s`;
|
||||
const p = await open(start, temperature === 'warm');
|
||||
const before = await sessionState(p);
|
||||
await p.evaluate((target) => {
|
||||
window.__qaSlow = { frame: window.__qaFirstFrame(target) };
|
||||
window.__qaSlow.frame.then((t) => (window.__qaSlow.drawn = t));
|
||||
const select = document.querySelector('#globe-engine');
|
||||
// From the change on, every animation frame waits until 12 s after it.
|
||||
select.addEventListener(
|
||||
'change',
|
||||
() => {
|
||||
const until = performance.now() + 12000;
|
||||
window.__qaSlow.at = performance.now();
|
||||
const raf = window.requestAnimationFrame.bind(window);
|
||||
window.requestAnimationFrame = (callback) =>
|
||||
raf((time) => {
|
||||
const wait = until - performance.now();
|
||||
if (wait <= 0) return callback(time);
|
||||
setTimeout(() => raf(callback), wait);
|
||||
});
|
||||
window.__qaSlow.release = () => {
|
||||
window.requestAnimationFrame = raf;
|
||||
};
|
||||
},
|
||||
{ once: true, capture: true },
|
||||
);
|
||||
new MutationObserver((_, observer) => {
|
||||
if (
|
||||
window.__qaSlow.at &&
|
||||
!window.__qaSlow.coverGone &&
|
||||
!document.querySelector('.engine-switch-frame') &&
|
||||
!document.querySelector('#globe-engine')?.disabled
|
||||
) {
|
||||
window.__qaSlow.coverGone = performance.now();
|
||||
observer.disconnect();
|
||||
}
|
||||
}).observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['disabled'],
|
||||
});
|
||||
}, target);
|
||||
await strict.pick(p, target, { settle: false });
|
||||
await p.waitForFunction(
|
||||
(target) =>
|
||||
window.__godsEyeView?.engine?.kind === target &&
|
||||
!document.querySelector('#globe-engine')?.disabled &&
|
||||
!document.querySelector('.engine-switch-frame'),
|
||||
{ polling: 200, timeout: 60000 },
|
||||
target,
|
||||
);
|
||||
const done = await assertSession(p, target, label);
|
||||
const timing = await p.evaluate(async () => {
|
||||
const frame = await window.__qaSlow.frame;
|
||||
return {
|
||||
coverGoneMs: Math.round(window.__qaSlow.coverGone - window.__qaSlow.at),
|
||||
firstFrameMs: Math.round(frame.presented - window.__qaSlow.at),
|
||||
};
|
||||
});
|
||||
const row = {
|
||||
case: label,
|
||||
engine: done.engine,
|
||||
history: done.history - before.history,
|
||||
...timing,
|
||||
errors: p.errors.length,
|
||||
};
|
||||
results.push(row);
|
||||
assert.equal(row.history, 1, 'one history entry, no rollback');
|
||||
assert.ok(
|
||||
row.coverGoneMs >= 9500 && row.coverGoneMs < 12500,
|
||||
'the cover goes after the 10 s first-frame wait ' + JSON.stringify(row),
|
||||
);
|
||||
assert.ok(row.firstFrameMs >= 12000, 'the frame arrives after the stall');
|
||||
console.log('PASS', JSON.stringify(row));
|
||||
await p.close();
|
||||
}
|
||||
assert.deepEqual(
|
||||
results.filter((row) => row.failed?.length),
|
||||
[],
|
||||
'failure-injection cases that broke the contract',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,824 @@
|
||||
// The strict acceptance harness shared by every browser scenario that
|
||||
// qa-all-layer-switches.mjs runs.
|
||||
//
|
||||
// - Network: a default-deny request policy on every page, frame and worker
|
||||
// of the browser, installed before the target runs (CDP auto-attach with
|
||||
// waitForDebuggerOnStart). Paid hosts and paid preview routes are blocked,
|
||||
// then only the preview origin, the scenario's declared fixture URLs and
|
||||
// the free tile hosts (answered from a disk cache) are allowed. A blocked
|
||||
// request, a fixture or tile URL answered by the real network, or a socket
|
||||
// to another origin is a violation and fails the scenario with its URL.
|
||||
// - Coverage: the runner passes the layer ids (--layers=a,b). The scenario
|
||||
// records each assertion it made per layer id and engine; finish() fails
|
||||
// when a declared layer has no assertion on either engine, and writes the
|
||||
// manifest (--evidence=FILE) the runner cross-checks against the catalog.
|
||||
// - Engine picks: pickEngine() switches with real input on the picker (a
|
||||
// pointer click on its label, then keyboard type-ahead), never page.select.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import os from 'node:os';
|
||||
import {
|
||||
KEYLESS_PROBES,
|
||||
probePattern,
|
||||
strictBlockPatterns,
|
||||
STRICT_TILE_HOSTS,
|
||||
} from './qa-map-policy.mjs';
|
||||
import { createMissLoader, tileCacheFile } from './qa/tileCache.mjs';
|
||||
|
||||
export const ENGINES = Object.freeze(['cesium', 'navara']);
|
||||
/** What the Globe Engine picker offers: the engines and Navara HD. */
|
||||
const CHOICES = Object.freeze([...ENGINES, 'navara-hd']);
|
||||
/** Local ports that belong to the user's own servers: never a QA preview. */
|
||||
export const RESERVED_PORTS = Object.freeze([
|
||||
'',
|
||||
'4173',
|
||||
'4174',
|
||||
'4185',
|
||||
'4186',
|
||||
'4187',
|
||||
'4188',
|
||||
'4189',
|
||||
'4194',
|
||||
'4196',
|
||||
]);
|
||||
/** Chrome flags every scenario's browser starts with (the GPU path). */
|
||||
const CHROME_ARGS = Object.freeze(['--use-angle=metal', '--enable-gpu']);
|
||||
|
||||
/** The values of --layers=a,b and --evidence=FILE (null when absent). */
|
||||
export function strictArguments(argv = process.argv) {
|
||||
const value = (name) =>
|
||||
argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const layers = value('layers');
|
||||
return {
|
||||
layers: layers ? layers.split(',').filter(Boolean) : null,
|
||||
evidence: value('evidence') ?? process.env.QA_STRICT_EVIDENCE ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Refuse anything but an unreserved local preview. */
|
||||
export function localPreview(href) {
|
||||
const url = new URL(href);
|
||||
if (
|
||||
!['localhost', '127.0.0.1'].includes(url.hostname) ||
|
||||
RESERVED_PORTS.includes(url.port)
|
||||
)
|
||||
throw Error('Supply an unreserved local keyless preview URL');
|
||||
return url;
|
||||
}
|
||||
|
||||
/** The URL with no remote IP: fulfilled by a fixture or a cache, or a data URL. */
|
||||
const live = (response) => Boolean(response.remoteIPAddress);
|
||||
|
||||
/**
|
||||
* Follow every request of every target of `browser` under the strict policy.
|
||||
* Returns the ledger the policy fills in.
|
||||
*/
|
||||
export async function installStrictNetwork(
|
||||
browser,
|
||||
{ origin, fixtures = [], routes = [], tileHosts = STRICT_TILE_HOSTS, ledger },
|
||||
) {
|
||||
const preview = new URL(origin);
|
||||
fixtures = [...fixtures, ...routes.map((route) => route.pattern)];
|
||||
const urlPatterns = strictBlockPatterns(origin, { fixtures, tileHosts });
|
||||
const answers = routes.map((route) => ({
|
||||
...route,
|
||||
test: new URLPattern(route.pattern),
|
||||
}));
|
||||
const fixturePatterns = fixtures.map((p) => new URLPattern(p));
|
||||
const isFixture = (url) => fixturePatterns.some((p) => p.test(url));
|
||||
const root = await browser.target().createCDPSession();
|
||||
const connection = root.connection();
|
||||
const probes = KEYLESS_PROBES.map((probe) => ({
|
||||
...probe,
|
||||
test: new URLPattern(probePattern(origin, probe)),
|
||||
}));
|
||||
const misses = createMissLoader();
|
||||
const cacheDir = path.join(os.tmpdir(), 'gev-qa-tiles');
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const tiles = ledger.tileCache;
|
||||
// One CDP Fetch handler per target: the keyless probes and the tile hosts
|
||||
// (from the disk cache, filled on a miss). Nothing else is paused, so
|
||||
// workers' own requests are never held.
|
||||
const intercept = async (session, type) => {
|
||||
session.on('Fetch.requestPaused', async ({ requestId, request }) => {
|
||||
// Every answer allows any origin; `noStore` keeps it out of the cache.
|
||||
const fulfil = (status, contentType, body, { noStore = false } = {}) =>
|
||||
session.send('Fetch.fulfillRequest', {
|
||||
requestId,
|
||||
responseCode: status,
|
||||
responseHeaders: [
|
||||
...(contentType
|
||||
? [{ name: 'Content-Type', value: contentType }]
|
||||
: []),
|
||||
...(noStore ? [{ name: 'Cache-Control', value: 'no-store' }] : []),
|
||||
{ name: 'Access-Control-Allow-Origin', value: '*' },
|
||||
],
|
||||
...(body == null
|
||||
? {}
|
||||
: { body: Buffer.from(body).toString('base64') }),
|
||||
});
|
||||
try {
|
||||
const route = answers.find((r) => r.test.test(request.url));
|
||||
if (route) {
|
||||
ledger.routes[route.pattern] =
|
||||
(ledger.routes[route.pattern] ?? 0) + 1;
|
||||
const answer = await route.respond(request);
|
||||
return await fulfil(
|
||||
answer.status ?? 200,
|
||||
answer.contentType,
|
||||
answer.body ?? '',
|
||||
);
|
||||
}
|
||||
const probe = probes.find((p) => p.test.test(request.url));
|
||||
if (probe) {
|
||||
if (request.method !== probe.method && request.method !== 'HEAD') {
|
||||
violation('probe-method', request.url, { method: request.method });
|
||||
return await session.send('Fetch.failRequest', {
|
||||
requestId,
|
||||
errorReason: 'BlockedByClient',
|
||||
});
|
||||
}
|
||||
ledger.probes[probe.pattern] =
|
||||
(ledger.probes[probe.pattern] ?? 0) + 1;
|
||||
return await fulfil(
|
||||
probe.status,
|
||||
probe.contentType,
|
||||
probe.body ?? null,
|
||||
{ noStore: true },
|
||||
);
|
||||
}
|
||||
const accept = Object.entries(request.headers ?? {}).find(
|
||||
([name]) => name.toLowerCase() === 'accept',
|
||||
)?.[1];
|
||||
const file =
|
||||
request.method === 'GET'
|
||||
? tileCacheFile(request.url, accept, cacheDir, tileHosts)
|
||||
: null;
|
||||
if (!file) {
|
||||
violation('unhandled-intercept', request.url, { target: type });
|
||||
return await session.send('Fetch.failRequest', {
|
||||
requestId,
|
||||
errorReason: 'BlockedByClient',
|
||||
});
|
||||
}
|
||||
let entry;
|
||||
if (fs.existsSync(`${file}.json`)) {
|
||||
entry = {
|
||||
...JSON.parse(fs.readFileSync(`${file}.json`, 'utf8')),
|
||||
body: fs.readFileSync(file),
|
||||
};
|
||||
tiles.hits++;
|
||||
} else {
|
||||
entry = await misses.load(request.url, accept);
|
||||
if (entry.status === 200) {
|
||||
fs.writeFileSync(file, entry.body);
|
||||
fs.writeFileSync(
|
||||
`${file}.json`,
|
||||
JSON.stringify({ status: 200, contentType: entry.contentType }),
|
||||
);
|
||||
tiles.fetched++;
|
||||
} else tiles.failed++;
|
||||
const host = new URL(request.url).host;
|
||||
tiles.fetchedByHost[host] = (tiles.fetchedByHost[host] ?? 0) + 1;
|
||||
}
|
||||
await fulfil(entry.status, entry.contentType, entry.body);
|
||||
} catch (error) {
|
||||
if (
|
||||
/Invalid InterceptionId|closed|detached/i.test(String(error?.message))
|
||||
)
|
||||
return void tiles.cancelled++;
|
||||
tiles.failed++;
|
||||
await session
|
||||
.send('Fetch.failRequest', { requestId, errorReason: 'Failed' })
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
await session
|
||||
.send('Fetch.enable', {
|
||||
patterns: [
|
||||
...probes.map((probe) => ({
|
||||
urlPattern: probePattern(origin, probe).replace(/\*$/, '') + '*',
|
||||
})),
|
||||
...tileHosts.map((host) => ({ urlPattern: `https://${host}/*` })),
|
||||
...routes.map((route) => ({ urlPattern: route.pattern })),
|
||||
],
|
||||
})
|
||||
.catch((error) =>
|
||||
ledger.setupErrors.push(
|
||||
type + ' Fetch.enable: ' + String(error?.message).slice(0, 120),
|
||||
),
|
||||
);
|
||||
};
|
||||
const violation = (kind, url, detail = {}) => {
|
||||
ledger.violations.push({ kind, url: String(url).slice(0, 300), ...detail });
|
||||
};
|
||||
const setup = async (session, info) => {
|
||||
const type = info.type;
|
||||
const urls = new Map();
|
||||
session.on('Network.requestWillBeSent', (event) => {
|
||||
const { url } = event.request;
|
||||
urls.set(event.requestId, url);
|
||||
if (!/^https?:/.test(url)) return;
|
||||
ledger.requests++;
|
||||
const address = new URL(url);
|
||||
if (address.origin === preview.origin) {
|
||||
ledger.preview++;
|
||||
if (address.pathname.startsWith('/api/')) {
|
||||
const route = address.pathname.split('/').slice(0, 4).join('/');
|
||||
ledger.previewApi[route] = (ledger.previewApi[route] ?? 0) + 1;
|
||||
}
|
||||
} else {
|
||||
ledger.external[address.host] =
|
||||
(ledger.external[address.host] ?? 0) + 1;
|
||||
}
|
||||
});
|
||||
session.on('Network.loadingFailed', (event) => {
|
||||
if (!event.blockedReason) return;
|
||||
violation('blocked', urls.get(event.requestId) ?? '?', {
|
||||
reason: event.blockedReason,
|
||||
target: type,
|
||||
resource: event.type,
|
||||
});
|
||||
});
|
||||
session.on('Network.responseReceived', ({ response, type: resource }) => {
|
||||
if (!/^https?:/.test(response.url)) return;
|
||||
const address = new URL(response.url);
|
||||
if (probes.some((p) => p.test.test(response.url)) && live(response))
|
||||
violation('probe-live', response.url, { target: type });
|
||||
if (address.origin === preview.origin) return;
|
||||
if (!live(response)) {
|
||||
if (isFixture(response.url)) ledger.fixtureResponses++;
|
||||
return;
|
||||
}
|
||||
// A fixture or tile URL answered by the real network, or any other
|
||||
// external host that slipped past the block (a frame navigation).
|
||||
violation('live', response.url, {
|
||||
remote: response.remoteIPAddress,
|
||||
target: type,
|
||||
resource,
|
||||
});
|
||||
});
|
||||
session.on('Network.webSocketCreated', ({ url }) => {
|
||||
const address = new URL(url);
|
||||
if (address.host !== preview.host)
|
||||
violation('socket', url, { target: type });
|
||||
});
|
||||
session.on('Target.attachedToTarget', (event) =>
|
||||
attach(event.sessionId, event.targetInfo, event.waitingForDebugger),
|
||||
);
|
||||
const steps = [
|
||||
session.send('Network.enable'),
|
||||
session.send('Network.setBlockedURLs', { urlPatterns }),
|
||||
session
|
||||
.send('Target.setAutoAttach', {
|
||||
autoAttach: true,
|
||||
waitForDebuggerOnStart: true,
|
||||
flatten: true,
|
||||
})
|
||||
.catch(() => {}),
|
||||
];
|
||||
// Workers have no Fetch domain; the page's interception answers their
|
||||
// requests, and a live answer would still show up as a violation.
|
||||
if (type === 'page' || type === 'iframe')
|
||||
steps.push(intercept(session, type));
|
||||
if (type === 'page') steps.push(trapCesiumGlobal(session, ledger));
|
||||
await Promise.all(steps);
|
||||
};
|
||||
const attach = async (sessionId, info, waiting) => {
|
||||
const session = connection.session(sessionId);
|
||||
if (!session) return;
|
||||
try {
|
||||
await setup(session, info);
|
||||
} catch (error) {
|
||||
// A target that closes while it attaches has nothing left to load.
|
||||
if (!/closed|detached|not found/i.test(String(error?.message)))
|
||||
ledger.setupErrors.push(String(error?.message ?? error).slice(0, 200));
|
||||
} finally {
|
||||
if (waiting)
|
||||
await session.send('Runtime.runIfWaitingForDebugger').catch(() => {});
|
||||
}
|
||||
};
|
||||
root.on('Target.attachedToTarget', (event) =>
|
||||
attach(event.sessionId, event.targetInfo, event.waitingForDebugger),
|
||||
);
|
||||
await root.send('Target.setAutoAttach', {
|
||||
autoAttach: true,
|
||||
waitForDebuggerOnStart: true,
|
||||
flatten: true,
|
||||
filter: [
|
||||
{ type: 'page' },
|
||||
{ type: 'iframe' },
|
||||
{ type: 'worker' },
|
||||
{ type: 'shared_worker' },
|
||||
{ type: 'service_worker' },
|
||||
{ type: 'tab', exclude: true },
|
||||
{ type: 'browser', exclude: true },
|
||||
],
|
||||
});
|
||||
return ledger;
|
||||
}
|
||||
|
||||
/**
|
||||
* The production build reads every `cesium` import from the global that
|
||||
* /cesium/Cesium.js defines, and the page loads that script only when a
|
||||
* Cesium globe first starts. A read of the global before then throws in the
|
||||
* app (a Navara page has no Cesium at all), wherever the app catches it, so
|
||||
* each page gets an accessor in place of the global: reads before the script
|
||||
* has assigned it are logged with their stack in `ledger.cesiumBeforeLoad`
|
||||
* and answer `undefined`, as production would.
|
||||
*/
|
||||
async function trapCesiumGlobal(session, ledger) {
|
||||
const binding = '__gevStrictCesiumRead';
|
||||
session.on('Runtime.bindingCalled', (event) => {
|
||||
if (event.name !== binding) return;
|
||||
const stack = String(event.payload).slice(0, 1500);
|
||||
const known = ledger.cesiumBeforeLoad.find((read) => read.stack === stack);
|
||||
if (known) known.count++;
|
||||
else if (ledger.cesiumBeforeLoad.length < 50)
|
||||
ledger.cesiumBeforeLoad.push({ stack, count: 1 });
|
||||
});
|
||||
await session.send('Runtime.enable');
|
||||
await session.send('Runtime.addBinding', { name: binding });
|
||||
await session.send('Page.enable');
|
||||
await session.send('Page.addScriptToEvaluateOnNewDocument', {
|
||||
source: `(() => {
|
||||
if (window.top !== window) return;
|
||||
const seen = new Set();
|
||||
let sdk;
|
||||
const report = () => {
|
||||
const limit = Error.stackTraceLimit;
|
||||
Error.stackTraceLimit = 12;
|
||||
const stack = String(new Error('Cesium read before it loaded').stack);
|
||||
Error.stackTraceLimit = limit;
|
||||
const site = stack.split('\\n').slice(2, 6).join('\\n');
|
||||
if (seen.has(site)) return;
|
||||
seen.add(site);
|
||||
window.${binding}?.(stack);
|
||||
};
|
||||
Object.defineProperty(window, 'Cesium', {
|
||||
configurable: true,
|
||||
get() {
|
||||
if (sdk === undefined) report();
|
||||
return sdk;
|
||||
},
|
||||
set(value) {
|
||||
sdk = value;
|
||||
},
|
||||
});
|
||||
})();`,
|
||||
});
|
||||
}
|
||||
|
||||
/** A new, empty network ledger. */
|
||||
export function networkLedger() {
|
||||
return {
|
||||
requests: 0,
|
||||
preview: 0,
|
||||
previewApi: {},
|
||||
external: {},
|
||||
fixtureResponses: 0,
|
||||
probes: {},
|
||||
routes: {},
|
||||
tileCache: {
|
||||
hits: 0,
|
||||
fetched: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
fetchedByHost: {},
|
||||
},
|
||||
violations: [],
|
||||
setupErrors: [],
|
||||
cesiumBeforeLoad: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to `kind` with real input on the Globe Engine picker: a pointer
|
||||
* click on its label (which focuses the select without opening its menu)
|
||||
* and keyboard type-ahead. Opens the control panel with a pointer click if
|
||||
* it is collapsed and collapses it again once the switch has settled
|
||||
* (`settle: false` returns as soon as the picker reports the change;
|
||||
* `afterInput` runs right after the key, before anything is read back).
|
||||
*/
|
||||
export async function pickEngine(
|
||||
page,
|
||||
kind,
|
||||
{ settle = true, afterInput = null } = {},
|
||||
) {
|
||||
assert.ok(CHOICES.includes(kind), kind);
|
||||
const expanded = () =>
|
||||
page.$eval(
|
||||
'#control-panel-toggle',
|
||||
(el) => el.getAttribute('aria-expanded') === 'true',
|
||||
);
|
||||
const opened = !(await expanded());
|
||||
if (opened) {
|
||||
await page.click('#control-panel-toggle').catch((error) => {
|
||||
throw Error(`cannot click the control panel toggle: ${error.message}`);
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
.querySelector('#control-panel-toggle')
|
||||
?.getAttribute('aria-expanded') === 'true',
|
||||
);
|
||||
}
|
||||
await page.waitForSelector('#globe-engine:not([disabled])', {
|
||||
visible: true,
|
||||
timeout: 90000,
|
||||
});
|
||||
const from = await page.$eval('#globe-engine', (el) => el.value);
|
||||
assert.notEqual(from, kind, `the picker already shows ${kind}`);
|
||||
// The key typed below is `kind`'s first letter: it selects the next option
|
||||
// starting with it (after `from` when `from` starts with it too). Each key
|
||||
// is a change that starts a switch, so `kind` must be one key away
|
||||
// (Cesium → Navara HD goes through Navara).
|
||||
const values = await page.$$eval('#globe-engine option', (options) =>
|
||||
options.map((option) => option.value),
|
||||
);
|
||||
const matches = values.filter((value) => value[0] === kind[0]);
|
||||
assert.equal(
|
||||
from[0] === kind[0]
|
||||
? matches[(matches.indexOf(from) + 1) % matches.length]
|
||||
: matches[0],
|
||||
kind,
|
||||
`${kind} is not one key away from ${from}`,
|
||||
);
|
||||
// Where a pointer can reach the picker: the label's own text (focuses the
|
||||
// select without opening its menu), else the select itself.
|
||||
const target = await page.$eval('#globe-engine', (el) => {
|
||||
const box = el.closest('label').getBoundingClientRect();
|
||||
const select = el.getBoundingClientRect();
|
||||
// at: the change event (the person's input reached the page);
|
||||
// idleAt: the picker enabled again after the switch.
|
||||
window.__qaPick = { busy: false, changed: false, at: null, idleAt: null };
|
||||
el.addEventListener(
|
||||
'change',
|
||||
() => {
|
||||
window.__qaPick.changed = true;
|
||||
window.__qaPick.at = performance.now();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
window.__qaPickObserver?.disconnect();
|
||||
window.__qaPickObserver = new MutationObserver(() => {
|
||||
const disabled = document.querySelector('#globe-engine')?.disabled;
|
||||
if (disabled) window.__qaPick.busy = true;
|
||||
else if (window.__qaPick.busy && window.__qaPick.idleAt == null)
|
||||
window.__qaPick.idleAt = performance.now();
|
||||
});
|
||||
window.__qaPickObserver.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['disabled'],
|
||||
subtree: true,
|
||||
});
|
||||
const describe = (hit) =>
|
||||
hit
|
||||
? `${hit.tagName.toLowerCase()}#${hit.id}.${String(hit.className).slice(0, 60)}`
|
||||
: 'nothing';
|
||||
const label = {
|
||||
x: box.left + Math.max(4, (select.left - box.left) / 2),
|
||||
y: box.top + box.height / 2,
|
||||
};
|
||||
const onLabel = document.elementFromPoint(label.x, label.y);
|
||||
if (
|
||||
onLabel === el.closest('label') ||
|
||||
(onLabel?.closest('label') === el.closest('label') && onLabel !== el)
|
||||
)
|
||||
return { ...label, via: 'label' };
|
||||
const middle = {
|
||||
x: select.left + select.width / 2,
|
||||
y: select.top + select.height / 2,
|
||||
};
|
||||
const onSelect = document.elementFromPoint(middle.x, middle.y);
|
||||
if (onSelect === el)
|
||||
return { ...middle, via: 'select', labelCoveredBy: describe(onLabel) };
|
||||
return { covered: describe(onLabel) + ' / ' + describe(onSelect) };
|
||||
});
|
||||
assert.ok(!target.covered, 'the picker is covered by ' + target.covered);
|
||||
await page.mouse.click(target.x, target.y);
|
||||
assert.equal(
|
||||
await page.evaluate(() => document.activeElement?.id),
|
||||
'globe-engine',
|
||||
'the click focuses the picker',
|
||||
);
|
||||
await page.keyboard.type(kind[0]);
|
||||
// Whatever the person does next, right after the key (no round trip to
|
||||
// the page in between).
|
||||
if (afterInput) await afterInput();
|
||||
await page.waitForFunction(() => window.__qaPick?.changed, {
|
||||
timeout: 5000,
|
||||
polling: 50,
|
||||
});
|
||||
// Headless Chrome opens no menu for a click on the select: the key
|
||||
// events above reached the page, so there is nothing to dismiss.
|
||||
const how = {
|
||||
opened,
|
||||
clicked: target.via,
|
||||
...(target.labelCoveredBy ? { labelCoveredBy: target.labelCoveredBy } : {}),
|
||||
};
|
||||
if (!settle) return how;
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
(window.__qaPick.busy &&
|
||||
!document.querySelector('#globe-engine')?.disabled &&
|
||||
document.querySelector('#loading-screen.hidden') &&
|
||||
!document.querySelector('.engine-switch-frame')) ||
|
||||
document.querySelector('.engine-switch-retry'),
|
||||
{ timeout: 120000, polling: 50 },
|
||||
);
|
||||
await page.evaluate(() => window.__qaPickObserver?.disconnect());
|
||||
Object.assign(
|
||||
how,
|
||||
await page.evaluate(() => ({
|
||||
at: window.__qaPick.at,
|
||||
idleAt: window.__qaPick.idleAt,
|
||||
})),
|
||||
);
|
||||
if (opened && (await expanded().catch(() => false))) {
|
||||
await page.click('#control-panel-toggle');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
.querySelector('#control-panel-toggle')
|
||||
?.getAttribute('aria-expanded') !== 'true',
|
||||
);
|
||||
}
|
||||
return how;
|
||||
}
|
||||
|
||||
/** Wait until `kind` runs, its picker is idle and its view is restored. */
|
||||
export async function waitForEngine(page, kind, { timeout = 120000 } = {}) {
|
||||
await page.waitForFunction(
|
||||
(expected) =>
|
||||
// Navara HD runs the Navara engine.
|
||||
window.__godsEyeView?.engine?.kind ===
|
||||
(expected === 'cesium' ? 'cesium' : 'navara') &&
|
||||
document.querySelector('#globe-engine')?.value === expected &&
|
||||
!document.querySelector('#globe-engine')?.disabled &&
|
||||
!document.querySelector('.engine-switch-frame') &&
|
||||
document.querySelector('#loading-screen.hidden'),
|
||||
{ timeout },
|
||||
kind,
|
||||
);
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A new 1024×768 page of `browser` whose uncaught errors (and, with
|
||||
* `shaderErrors`, shader and WebGL program errors on the console) land in
|
||||
* `errors`; each uncaught error is also logged without its query string.
|
||||
*/
|
||||
export async function openPage(browser, { shaderErrors = false } = {}) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1024, height: 768 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => {
|
||||
errors.push(e.message);
|
||||
console.log('error', e.message.split('?')[0]);
|
||||
});
|
||||
if (shaderErrors)
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error' && /Shader|WebGLProgram/i.test(msg.text()))
|
||||
errors.push(msg.text());
|
||||
});
|
||||
return { page, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* The strict context of one scenario run.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.origin The preview origin.
|
||||
* @param {string[]} options.layers The layer ids this scenario covers (the
|
||||
* runner's --layers wins when given; both must agree).
|
||||
* @param {string[]} [options.fixtures] URLPattern strings for external
|
||||
* requests the scenario answers itself.
|
||||
* @param {{pattern: string, respond: (request: object) => ({status?: number,
|
||||
* contentType?: string, body?: string|Buffer}|Promise<object>)}[]} [options.routes]
|
||||
* External URLs the harness answers for the scenario (the pattern is both
|
||||
* a URLPattern and a CDP Fetch glob, e.g. `https://i.ytimg.com/*`).
|
||||
* @param {readonly string[]} [options.tileHosts] Hosts the disk cache
|
||||
* answers (default STRICT_TILE_HOSTS).
|
||||
* @param {string} [options.name] Scenario name for the manifest.
|
||||
*/
|
||||
export function createStrict({
|
||||
origin,
|
||||
layers,
|
||||
fixtures = [],
|
||||
routes = [],
|
||||
tileHosts = STRICT_TILE_HOSTS,
|
||||
name = path.basename(process.argv[1] ?? 'scenario', '.mjs'),
|
||||
}) {
|
||||
const args = strictArguments();
|
||||
if (args.layers)
|
||||
assert.deepEqual(
|
||||
[...args.layers].sort(),
|
||||
[...layers].sort(),
|
||||
`${name} covers ${layers.join(',')}, the runner passed ${args.layers.join(',')}`,
|
||||
);
|
||||
const declared = args.layers ?? layers;
|
||||
const ledger = networkLedger();
|
||||
const assertions = [];
|
||||
const picks = [];
|
||||
const timings = [];
|
||||
const extra = {};
|
||||
let finished = false;
|
||||
|
||||
const record = (ids, engine, claim, detail) => {
|
||||
for (const id of [ids].flat()) {
|
||||
assert.ok(declared.includes(id), `${id} is not declared by ${name}`);
|
||||
assert.ok(ENGINES.includes(engine), `unknown engine ${engine}`);
|
||||
assertions.push({
|
||||
layer: id,
|
||||
engine,
|
||||
claim,
|
||||
...(detail === undefined ? {} : { detail }),
|
||||
});
|
||||
}
|
||||
};
|
||||
const engineOf = (page) =>
|
||||
page.evaluate(() => window.__godsEyeView?.engine?.kind ?? null);
|
||||
|
||||
const strict = {
|
||||
name,
|
||||
layers: declared,
|
||||
ledger,
|
||||
extra,
|
||||
timings,
|
||||
/**
|
||||
* Launch headless Chrome with the policy on every target; `args` adds
|
||||
* flags to CHROME_ARGS.
|
||||
*/
|
||||
async launch({ args = [], ...options } = {}) {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
...options,
|
||||
args: [...CHROME_ARGS, ...args],
|
||||
});
|
||||
await installStrictNetwork(browser, {
|
||||
origin,
|
||||
fixtures,
|
||||
routes,
|
||||
tileHosts,
|
||||
ledger,
|
||||
});
|
||||
return browser;
|
||||
},
|
||||
/** Launch with `options`, run `body(browser)`, close, then finish(). */
|
||||
async run(options, body) {
|
||||
const browser = await strict.launch(options);
|
||||
let failed = null;
|
||||
try {
|
||||
await body(browser);
|
||||
} catch (error) {
|
||||
failed = error;
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
await strict.finish({ failed });
|
||||
if (failed) throw failed;
|
||||
},
|
||||
/** Record an assertion that already passed. */
|
||||
record,
|
||||
/** Record a passed assertion on the engine the page runs now. */
|
||||
async passed(page, ids, claim, detail) {
|
||||
const engine = await engineOf(page);
|
||||
record(ids, engine, claim, detail);
|
||||
},
|
||||
/** Assert `value` and record it on the engine the page runs now. */
|
||||
async check(page, ids, claim, value, detail) {
|
||||
assert.ok(
|
||||
value,
|
||||
claim + (detail === undefined ? '' : ' ' + JSON.stringify(detail)),
|
||||
);
|
||||
await strict.passed(page, ids, claim, detail);
|
||||
},
|
||||
/** Switch with real input and log the pick. */
|
||||
async pick(page, kind, options) {
|
||||
const result = await pickEngine(page, kind, options);
|
||||
picks.push({ to: kind, via: 'pointer+keyboard', ...result });
|
||||
return result;
|
||||
},
|
||||
/**
|
||||
* Switch through the select element without input, only where no
|
||||
* person can reach the picker (`reason` says why); logged as such.
|
||||
*/
|
||||
async selectEngine(page, kind, reason) {
|
||||
assert.ok(reason, 'say why the picker cannot be used');
|
||||
await page.select('#globe-engine', kind);
|
||||
picks.push({ to: kind, via: 'page.select', reason });
|
||||
},
|
||||
/**
|
||||
* Switch with real input in the same document and wait until `kind`
|
||||
* runs.
|
||||
*/
|
||||
async switchTo(page, kind) {
|
||||
const origin = await page.evaluate(() => performance.timeOrigin);
|
||||
await strict.pick(page, kind);
|
||||
await waitForEngine(page, kind);
|
||||
assert.equal(
|
||||
await page.evaluate(() => performance.timeOrigin),
|
||||
origin,
|
||||
'same document after switching globes',
|
||||
);
|
||||
},
|
||||
/** Write the manifest; fail on violations or missing coverage. */
|
||||
async finish({ failed = null } = {}) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
const coverage = Object.fromEntries(
|
||||
declared.map((id) => [
|
||||
id,
|
||||
Object.fromEntries(
|
||||
ENGINES.map((engine) => [
|
||||
engine,
|
||||
assertions.filter((a) => a.layer === id && a.engine === engine)
|
||||
.length,
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
const missing = Object.entries(coverage).flatMap(([id, byEngine]) =>
|
||||
ENGINES.filter((engine) => !byEngine[engine]).map(
|
||||
(engine) => `${id} on ${engine}`,
|
||||
),
|
||||
);
|
||||
const network = {
|
||||
requests: ledger.requests,
|
||||
preview: ledger.preview,
|
||||
previewApi: ledger.previewApi,
|
||||
external: ledger.external,
|
||||
fixtureResponses: ledger.fixtureResponses,
|
||||
probes: ledger.probes,
|
||||
routes: ledger.routes,
|
||||
tileCache: ledger.tileCache,
|
||||
violations: ledger.violations,
|
||||
setupErrors: ledger.setupErrors,
|
||||
cesiumBeforeLoad: ledger.cesiumBeforeLoad,
|
||||
fixtures,
|
||||
tileHosts: [...tileHosts],
|
||||
};
|
||||
const manifest = {
|
||||
scenario: name,
|
||||
argv: process.argv.slice(2),
|
||||
status: failed ? 'fail' : 'pass',
|
||||
error: failed ? String(failed?.stack ?? failed).slice(0, 2000) : null,
|
||||
layers: declared,
|
||||
coverage,
|
||||
missing,
|
||||
picks,
|
||||
assertions,
|
||||
network,
|
||||
timings,
|
||||
...extra,
|
||||
};
|
||||
if (args.evidence) {
|
||||
fs.mkdirSync(path.dirname(args.evidence), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
args.evidence,
|
||||
JSON.stringify(manifest, null, 2) + '\n',
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
'STRICT',
|
||||
JSON.stringify({
|
||||
scenario: name,
|
||||
assertions: assertions.length,
|
||||
coverage,
|
||||
picks: picks.length,
|
||||
requests: ledger.requests,
|
||||
violations: ledger.violations.length,
|
||||
cesiumBeforeLoad: ledger.cesiumBeforeLoad.length,
|
||||
}),
|
||||
);
|
||||
for (const v of ledger.violations.slice(0, 20))
|
||||
console.log('NETWORK VIOLATION', JSON.stringify(v));
|
||||
for (const read of ledger.cesiumBeforeLoad.slice(0, 10))
|
||||
console.log('CESIUM BEFORE LOAD', JSON.stringify(read));
|
||||
if (failed) return;
|
||||
assert.equal(
|
||||
ledger.violations.length,
|
||||
0,
|
||||
'network policy violations: ' +
|
||||
JSON.stringify(ledger.violations.slice(0, 5)),
|
||||
);
|
||||
assert.deepEqual(
|
||||
ledger.cesiumBeforeLoad,
|
||||
[],
|
||||
'the page read the Cesium global before Cesium.js loaded',
|
||||
);
|
||||
assert.deepEqual(missing, [], 'layers without an assertion');
|
||||
assert.ok(
|
||||
picks.some((pick) => pick.via === 'pointer+keyboard'),
|
||||
'switch at least once with real input on the picker',
|
||||
);
|
||||
},
|
||||
};
|
||||
return strict;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Contract: a switch picked just before the tab is hidden finishes in the
|
||||
// hidden tab without waiting for a frame (a hidden tab draws nothing) and
|
||||
// without a rollback, keeps the session and adds one history entry; once the
|
||||
// tab is shown again the new engine draws a non-blank first frame and the
|
||||
// layers' timers poll again. Both directions, cold and then warm.
|
||||
//
|
||||
// node scripts/qa-strict-hidden-switch.mjs http://127.0.0.1:4208
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||
import {
|
||||
assertSession,
|
||||
installFirstFrameProbe,
|
||||
installSessionFixtures,
|
||||
openApp,
|
||||
rememberEngine,
|
||||
sessionState,
|
||||
startSession,
|
||||
} from './qa-strict-session.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
const strict = createStrict({ origin: base.origin, layers: [] });
|
||||
await strict.run({}, async (b) => {
|
||||
const p = await b.newPage();
|
||||
await p.setViewport({ width: 1280, height: 800 });
|
||||
const errors = [];
|
||||
p.on('pageerror', (e) => errors.push(e.message));
|
||||
await installSessionFixtures(p, { flights: 20 });
|
||||
await installFirstFrameProbe(p);
|
||||
await openApp(p, base.origin, 'navara');
|
||||
await startSession(p, ['earthquakes', 'flights']);
|
||||
const cdp = await p.createCDPSession();
|
||||
// Another tab of the same window: bringing it to the front hides ours.
|
||||
const other = await b.newPage();
|
||||
await other.goto('about:blank');
|
||||
await p.bringToFront();
|
||||
const visibility = () => p.evaluate(() => document.visibilityState);
|
||||
assert.equal(await visibility(), 'visible');
|
||||
const results = [];
|
||||
for (const [kind, temperature] of [
|
||||
['cesium', 'cold'],
|
||||
['navara', 'warm'],
|
||||
['cesium', 'warm'],
|
||||
]) {
|
||||
const before = await sessionState(p);
|
||||
await p.evaluate((kind) => {
|
||||
window.__qaHidden = { frame: window.__qaFirstFrame(kind), shown: [] };
|
||||
window.__qaHidden.frame.then((t) => (window.__qaHidden.drawn = t));
|
||||
// When the tab is hidden, as the page sees it: the time and whether
|
||||
// the switch (busy picker) still runs.
|
||||
document.addEventListener('visibilitychange', () =>
|
||||
window.__qaHidden.shown.push({
|
||||
state: document.visibilityState,
|
||||
at: performance.now(),
|
||||
busy: !!document.querySelector('#globe-engine')?.disabled,
|
||||
}),
|
||||
);
|
||||
}, kind);
|
||||
// The person picks, then switches tabs before the globe changes: the
|
||||
// other tab comes to the front right after the key. A slow CPU (20x)
|
||||
// keeps the switch running past the tab change.
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 20 });
|
||||
await strict.pick(p, kind, {
|
||||
settle: false,
|
||||
afterInput: () => other.bringToFront(),
|
||||
});
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 });
|
||||
// rAF does not run in a hidden tab: poll on a timer.
|
||||
await p.waitForFunction(
|
||||
(kind) =>
|
||||
window.__godsEyeView?.engine?.kind === kind &&
|
||||
!document.querySelector('#globe-engine')?.disabled &&
|
||||
!document.querySelector('.engine-switch-frame') &&
|
||||
document.querySelector('#loading-screen.hidden'),
|
||||
{ polling: 250, timeout: 60000 },
|
||||
kind,
|
||||
);
|
||||
const hidden = await p.evaluate(() => ({
|
||||
state: document.visibilityState,
|
||||
hide: window.__qaHidden.shown.find((e) => e.state === 'hidden') ?? null,
|
||||
pick: { at: window.__qaPick.at, idleAt: window.__qaPick.idleAt },
|
||||
drawn: window.__qaHidden.drawn ?? null,
|
||||
}));
|
||||
await assertSession(
|
||||
p,
|
||||
kind,
|
||||
`${temperature} switch to ${kind} while hidden`,
|
||||
);
|
||||
const after = await sessionState(p);
|
||||
// Shown again: the first frame arrives, the timers poll again.
|
||||
const reads = await p.evaluate(() => ({ ...window.__qaReads }));
|
||||
await p.bringToFront();
|
||||
assert.equal(await visibility(), 'visible');
|
||||
// A non-blank frame of the new engine once the tab shows again.
|
||||
const shown = await p.evaluate(async (kind) => {
|
||||
const at = performance.now();
|
||||
const frame = await Promise.race([
|
||||
window.__qaFirstFrame(kind),
|
||||
new Promise((resolve) => setTimeout(() => resolve(null), 10000)),
|
||||
]);
|
||||
return frame && Math.round(frame.presented - at);
|
||||
}, kind);
|
||||
await p.waitForFunction(
|
||||
(reads) => window.__qaReads.flights > reads.flights,
|
||||
{ polling: 250, timeout: 30000 },
|
||||
reads,
|
||||
);
|
||||
await rememberEngine(p);
|
||||
const row = {
|
||||
kind,
|
||||
temperature,
|
||||
hiddenWhileBusy: Boolean(hidden.hide?.busy),
|
||||
settledWhileHidden: hidden.state === 'hidden',
|
||||
// From the tab being hidden to the picker being idle again.
|
||||
settleAfterHideMs:
|
||||
hidden.hide && hidden.pick.idleAt
|
||||
? Math.round(hidden.pick.idleAt - hidden.hide.at)
|
||||
: null,
|
||||
// Observed: an engine may draw while hidden (Navara renders a requested
|
||||
// frame without an animation frame); not part of the contract.
|
||||
frameWhileHidden: hidden.drawn !== null,
|
||||
firstFrameAfterShownMs: shown,
|
||||
history: after.history - before.history,
|
||||
};
|
||||
results.push(row);
|
||||
assert.ok(row.hiddenWhileBusy, 'the tab is hidden while the switch runs');
|
||||
assert.ok(row.settledWhileHidden, 'the switch settles in the hidden tab');
|
||||
// A hidden tab draws nothing: no first-frame wait (10 s) there.
|
||||
assert.ok(
|
||||
row.settleAfterHideMs !== null && row.settleAfterHideMs < 9000,
|
||||
JSON.stringify(row),
|
||||
);
|
||||
assert.equal(row.history, 1, 'one history entry');
|
||||
assert.ok(
|
||||
row.firstFrameAfterShownMs !== null,
|
||||
'a non-blank frame within 10 s of the tab showing',
|
||||
);
|
||||
console.log('PASS', JSON.stringify(row));
|
||||
}
|
||||
strict.extra.contracts = results;
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
// Contract: the two Navara choices on a 2x screen. Navara draws at CSS
|
||||
// pixels, Navara HD at the device pixel ratio. A pick between them builds
|
||||
// the other variant on the same session and releases the one it leaves (its
|
||||
// WebGL context is lost: only one Navara holds memory), the new one draws a
|
||||
// non-blank frame, the address and the stored choice follow the pick, and
|
||||
// Cesium stays the warm, parked engine across them.
|
||||
//
|
||||
// node scripts/qa-strict-navara-hd.mjs http://127.0.0.1:4208
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
import {
|
||||
installFirstFrameProbe,
|
||||
installSessionFixtures,
|
||||
openApp,
|
||||
rememberEngine,
|
||||
sessionState,
|
||||
startSession,
|
||||
} from './qa-strict-session.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
const strict = createStrict({ origin: base.origin, layers: [] });
|
||||
await strict.run({}, async (b) => {
|
||||
const p = await b.newPage();
|
||||
await p.setViewport({ width: 1280, height: 800, deviceScaleFactor: 2 });
|
||||
const errors = [];
|
||||
p.on('pageerror', (e) => errors.push(e.message));
|
||||
await installSessionFixtures(p, { flights: 20 });
|
||||
await installFirstFrameProbe(p);
|
||||
await openApp(p, base.origin, 'navara');
|
||||
await startSession(p, ['earthquakes', 'flights']);
|
||||
const contract = (name, value, detail) => {
|
||||
assert.ok(value, name + ' ' + JSON.stringify(detail ?? ''));
|
||||
strict.extra.contracts ??= [];
|
||||
strict.extra.contracts.push({ name, pass: true, detail });
|
||||
console.log('PASS', name);
|
||||
};
|
||||
// The running engine's canvas: its pixel ratio, and whether the engine
|
||||
// remembered under `kind` still has a live context.
|
||||
const look = (kind) =>
|
||||
p.evaluate((kind) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const canvas = engine.host.canvas;
|
||||
const old = window.__qaEngines.get(kind);
|
||||
const oldCanvas = old?.host.canvas;
|
||||
return {
|
||||
engine: engine.kind,
|
||||
ratio: Math.round((canvas.width / canvas.clientWidth) * 100) / 100,
|
||||
same: old === engine,
|
||||
oldLost:
|
||||
(
|
||||
oldCanvas?.getContext('webgl2') ?? oldCanvas?.getContext('webgl')
|
||||
)?.isContextLost() ?? null,
|
||||
stored: localStorage.getItem('gev.engine'),
|
||||
};
|
||||
}, kind);
|
||||
const draws = (kind) =>
|
||||
p.evaluate(
|
||||
(kind) =>
|
||||
Promise.race([
|
||||
window.__qaFirstFrame(kind).then(() => true),
|
||||
new Promise((resolve) => setTimeout(() => resolve(false), 15000)),
|
||||
]),
|
||||
kind,
|
||||
);
|
||||
const session = async () => {
|
||||
const state = await sessionState(p);
|
||||
return state.manager && state.models && state.enabled ? state : null;
|
||||
};
|
||||
const pick = async (choice) => {
|
||||
await strict.pick(p, choice);
|
||||
await waitForEngine(p, choice);
|
||||
};
|
||||
|
||||
const start = await look('navara');
|
||||
contract(
|
||||
'Navara draws at CSS pixels on a 2x screen',
|
||||
start.ratio === 1,
|
||||
start,
|
||||
);
|
||||
|
||||
// Navara → Navara HD: a new engine at the device pixel ratio.
|
||||
await pick('navara-hd');
|
||||
const hd = await look('navara');
|
||||
const hdState = await session();
|
||||
contract(
|
||||
'Navara HD is a new Navara engine at the device pixel ratio; the Navara it left is released',
|
||||
hd.engine === 'navara' &&
|
||||
hd.ratio === 2 &&
|
||||
!hd.same &&
|
||||
hd.oldLost === true &&
|
||||
hdState?.address === 'navara-hd' &&
|
||||
hdState?.picker === 'navara-hd' &&
|
||||
hd.stored === 'navara-hd',
|
||||
{ ...hd, address: hdState?.address, picker: hdState?.picker },
|
||||
);
|
||||
await rememberEngine(p);
|
||||
contract('Navara HD draws a non-blank frame', await draws('navara'));
|
||||
|
||||
// Navara HD → Cesium → Navara HD: Cesium is built once, HD stays warm.
|
||||
await pick('cesium');
|
||||
await rememberEngine(p);
|
||||
await pick('navara');
|
||||
await pick('cesium');
|
||||
const cesium = await look('cesium');
|
||||
contract(
|
||||
'Cesium stays the warm parked engine across the Navara variants',
|
||||
cesium.same && cesium.ratio === 1,
|
||||
cesium,
|
||||
);
|
||||
|
||||
// Back to Navara HD through Navara: each pick releases the other variant.
|
||||
await pick('navara');
|
||||
await rememberEngine(p);
|
||||
await pick('navara-hd');
|
||||
const again = await look('navara');
|
||||
contract(
|
||||
'a pick from Navara to Navara HD releases Navara and keeps the session',
|
||||
!again.same &&
|
||||
again.oldLost === true &&
|
||||
again.ratio === 2 &&
|
||||
(await session()) !== null,
|
||||
again,
|
||||
);
|
||||
await rememberEngine(p);
|
||||
await pick('navara');
|
||||
const back = await look('navara');
|
||||
const backState = await session();
|
||||
contract(
|
||||
'a pick from Navara HD to Navara draws at CSS pixels again, on the same session',
|
||||
!back.same &&
|
||||
back.oldLost === true &&
|
||||
back.ratio === 1 &&
|
||||
backState?.address === 'navara' &&
|
||||
back.stored === 'navara',
|
||||
back,
|
||||
);
|
||||
await rememberEngine(p);
|
||||
contract('Navara draws a non-blank frame again', await draws('navara'));
|
||||
contract('no uncaught page errors', errors.length === 0, errors);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// Contract: a parked globe is released after ?parkedMs= (3 s here; three
|
||||
// minutes by default). A switch back in time is warm (the same engine
|
||||
// object); after the delay the parked engine's WebGL context is lost and a
|
||||
// switch back builds a new engine on the same session that draws a
|
||||
// non-blank frame. Both directions.
|
||||
//
|
||||
// node scripts/qa-strict-parked-release.mjs http://127.0.0.1:4208
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
import {
|
||||
assertSession,
|
||||
installFirstFrameProbe,
|
||||
installSessionFixtures,
|
||||
openApp,
|
||||
rememberEngine,
|
||||
startSession,
|
||||
} from './qa-strict-session.mjs';
|
||||
|
||||
const PARKED_MS = 3000;
|
||||
const base = localPreview(process.argv[2]);
|
||||
const other = (kind) => (kind === 'navara' ? 'cesium' : 'navara');
|
||||
const strict = createStrict({ origin: base.origin, layers: [] });
|
||||
await strict.run({}, async (b) => {
|
||||
const p = await b.newPage();
|
||||
await p.setViewport({ width: 1280, height: 800 });
|
||||
const errors = [];
|
||||
p.on('pageerror', (e) => errors.push(e.message));
|
||||
await installSessionFixtures(p, { flights: 20 });
|
||||
await installFirstFrameProbe(p);
|
||||
await openApp(p, base.origin, 'navara', undefined, `&parkedMs=${PARKED_MS}`);
|
||||
await startSession(p, ['earthquakes', 'flights']);
|
||||
const contract = (name, value, detail) => {
|
||||
assert.ok(value, name + ' ' + JSON.stringify(detail ?? ''));
|
||||
strict.extra.contracts ??= [];
|
||||
strict.extra.contracts.push({ name, pass: true, detail });
|
||||
console.log('PASS', name);
|
||||
};
|
||||
const pick = async (kind) => {
|
||||
await strict.pick(p, kind);
|
||||
await waitForEngine(p, kind);
|
||||
};
|
||||
// The running engine is the one remembered under its kind (warm), and
|
||||
// the context of `kind`'s remembered engine is lost or not.
|
||||
const engines = (kind) =>
|
||||
p.evaluate((kind) => {
|
||||
const engine = window.__qaEngines.get(kind);
|
||||
const canvas = engine?.host.canvas;
|
||||
const context =
|
||||
canvas?.getContext('webgl2') ?? canvas?.getContext('webgl');
|
||||
return {
|
||||
warm:
|
||||
window.__qaEngines.get(window.__godsEyeView.engine.kind) ===
|
||||
window.__godsEyeView.engine,
|
||||
lost: context?.isContextLost() ?? null,
|
||||
};
|
||||
}, kind);
|
||||
const draws = (kind) =>
|
||||
p.evaluate(
|
||||
(kind) =>
|
||||
Promise.race([
|
||||
window.__qaFirstFrame(kind).then(() => true),
|
||||
new Promise((resolve) => setTimeout(() => resolve(false), 15000)),
|
||||
]),
|
||||
kind,
|
||||
);
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
for (const parked of ['navara', 'cesium']) {
|
||||
const running = other(parked);
|
||||
// Park `parked` afresh (either engine may have been released before).
|
||||
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== parked) {
|
||||
await pick(parked);
|
||||
await rememberEngine(p);
|
||||
}
|
||||
await pick(running);
|
||||
await rememberEngine(p);
|
||||
// A switch back in time is warm.
|
||||
await wait(PARKED_MS / 3);
|
||||
await pick(parked);
|
||||
const early = await engines(parked);
|
||||
await assertSession(p, parked, `a quick switch back to ${parked}`);
|
||||
contract(
|
||||
`a switch back to ${parked} within the delay reuses the parked engine`,
|
||||
early.warm && early.lost === false,
|
||||
early,
|
||||
);
|
||||
// Parked past the delay: released, and the next switch back is cold.
|
||||
await pick(running);
|
||||
await wait(PARKED_MS + 1500);
|
||||
const released = await engines(parked);
|
||||
contract(
|
||||
`${parked} parked past the delay is released: its context is lost`,
|
||||
released.lost === true,
|
||||
released,
|
||||
);
|
||||
await pick(parked);
|
||||
const cold = await engines(parked);
|
||||
await assertSession(p, parked, `the cold switch back to ${parked}`);
|
||||
await rememberEngine(p);
|
||||
const drawn = await draws(parked);
|
||||
contract(
|
||||
`the switch back to a released ${parked} builds a new engine on the same session and draws`,
|
||||
!cold.warm && drawn,
|
||||
{ ...cold, drawn },
|
||||
);
|
||||
}
|
||||
contract('no uncaught page errors', errors.length === 0, errors);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
// Contract: the production build starts cold on each engine without a page
|
||||
// error. A fresh page per engine opens the keyless preview of `vite build`
|
||||
// (its hashed entry chunk, not the dev server), waits until the engine runs
|
||||
// and its loading screen is gone, lets late work run, then switches once to
|
||||
// the other engine with real input; no uncaught exception or unhandled
|
||||
// rejection may show at any point. The dev server serves Cesium as an ES
|
||||
// module, so module-load errors of the production bundle only show here.
|
||||
//
|
||||
// node scripts/qa-strict-prod-smoke.mjs http://127.0.0.1:4208
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ENGINES,
|
||||
createStrict,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
const strict = createStrict({ origin: base.origin, layers: [] });
|
||||
const results = [];
|
||||
strict.extra.contracts = results;
|
||||
await strict.run({}, async (b) => {
|
||||
for (const start of ENGINES) {
|
||||
const page = await b.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const url = new URL(base.origin);
|
||||
url.search = `?engine=${start}&welcome=0`;
|
||||
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
||||
const entry = await page.evaluate(() => ({
|
||||
viteClient: !!document.querySelector('script[src*="@vite/client"]'),
|
||||
scripts: [...document.querySelectorAll('script[type="module"][src]')]
|
||||
.map((s) => new URL(s.src).pathname)
|
||||
.filter((p) => p.startsWith('/assets/')),
|
||||
}));
|
||||
assert.ok(
|
||||
!entry.viteClient && entry.scripts.length > 0,
|
||||
`the preview serves a production build ${JSON.stringify(entry)}`,
|
||||
);
|
||||
try {
|
||||
await waitForEngine(page, start, { timeout: 90000 });
|
||||
} catch (error) {
|
||||
throw Error(
|
||||
`${start} did not start: ${error.message}; page errors ${JSON.stringify(errors)}`,
|
||||
);
|
||||
}
|
||||
// Late work after the start: idle prefetch, layer timers, workers.
|
||||
await new Promise((resolve) => setTimeout(resolve, 4000));
|
||||
const other = start === 'cesium' ? 'navara' : 'cesium';
|
||||
await strict.pick(page, other);
|
||||
await waitForEngine(page, other, { timeout: 90000 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
const row = { start, switchedTo: other, entry: entry.scripts, errors };
|
||||
results.push(row);
|
||||
console.log(errors.length ? 'FAIL' : 'PASS', JSON.stringify(row));
|
||||
await page.close();
|
||||
}
|
||||
assert.deepEqual(
|
||||
results.filter((row) => row.errors.length),
|
||||
[],
|
||||
'a production cold start threw',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
// Contract: rapid switches. Back-to-back picks with real input, a second
|
||||
// pointer click on the picker while it is busy, a history visit superseded
|
||||
// by the opposite visit before its engine is ready, and a pick superseded by
|
||||
// Back before its engine is ready. After each: the expected engine runs on
|
||||
// the same session, the history has exactly the expected entries, both
|
||||
// engines are the warm ones (no third engine), one engine canvas is shown.
|
||||
//
|
||||
// node scripts/qa-strict-rapid-switch.mjs http://127.0.0.1:4208
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
import {
|
||||
assertSession,
|
||||
installSessionFixtures,
|
||||
openApp,
|
||||
rememberEngine,
|
||||
sessionState,
|
||||
startSession,
|
||||
} from './qa-strict-session.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
const strict = createStrict({ origin: base.origin, layers: [] });
|
||||
await strict.run({}, async (b) => {
|
||||
const p = await b.newPage();
|
||||
await p.setViewport({ width: 1280, height: 800 });
|
||||
const errors = [];
|
||||
p.on('pageerror', (e) => errors.push(e.message));
|
||||
await installSessionFixtures(p, { flights: 20 });
|
||||
await openApp(p, base.origin, 'navara');
|
||||
await startSession(p, ['earthquakes', 'flights']);
|
||||
const engines = () =>
|
||||
p.evaluate(() => {
|
||||
const shown = [...window.__qaEngines.values()].filter(
|
||||
(engine) => engine.host.canvas.isConnected,
|
||||
).length;
|
||||
return {
|
||||
warm:
|
||||
window.__qaEngines.get(window.__godsEyeView.engine.kind) ===
|
||||
window.__godsEyeView.engine,
|
||||
known: window.__qaEngines.size,
|
||||
shown,
|
||||
};
|
||||
});
|
||||
const contract = (name, value, detail) => {
|
||||
assert.ok(value, name + ' ' + JSON.stringify(detail ?? ''));
|
||||
strict.extra.contracts ??= [];
|
||||
strict.extra.contracts.push({ name, pass: true, detail });
|
||||
console.log('PASS', name);
|
||||
};
|
||||
|
||||
// 1. Back-to-back picks; a second click on the busy picker does nothing.
|
||||
const start = (await sessionState(p)).history;
|
||||
for (const [i, kind] of ['cesium', 'navara', 'cesium', 'navara'].entries()) {
|
||||
await strict.pick(p, kind, { settle: false });
|
||||
// The picker is disabled while the engine changes: a second pointer
|
||||
// click on it (the person trying the other engine) changes nothing.
|
||||
const box = await p.$eval('#globe-engine', (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return {
|
||||
x: r.x + r.width / 2,
|
||||
y: r.y + r.height / 2,
|
||||
disabled: el.disabled,
|
||||
};
|
||||
});
|
||||
await p.mouse.click(box.x, box.y);
|
||||
await waitForEngine(p, kind);
|
||||
await rememberEngine(p);
|
||||
const state = await assertSession(p, kind, `pick ${i + 1} to ${kind}`);
|
||||
assert.equal(state.history, start + i + 1, 'one entry per pick');
|
||||
const shown = await engines();
|
||||
assert.ok(shown.warm && shown.shown === 1, JSON.stringify(shown));
|
||||
}
|
||||
contract(
|
||||
'four back-to-back picks: each adds one history entry, keeps the session, reuses the warm engines and shows one canvas; a click on the busy picker changes nothing',
|
||||
true,
|
||||
{ history: (await sessionState(p)).history - start },
|
||||
);
|
||||
|
||||
// A switch in progress, seen from the page: the engine object the page
|
||||
// exposes is the starting engine until the switch is over.
|
||||
const progress = () =>
|
||||
p.evaluate(() => {
|
||||
window.__qaSteps = [];
|
||||
const mark = (step) =>
|
||||
window.__qaSteps.push({
|
||||
step,
|
||||
engine: window.__godsEyeView?.engine?.kind ?? null,
|
||||
busy: !!document.querySelector('#globe-engine')?.disabled,
|
||||
});
|
||||
window.__qaMark = mark;
|
||||
});
|
||||
|
||||
// 2. A history visit superseded before its engine is ready: Back (to the
|
||||
// Cesium entry) and, from the page's popstate handling of that Back, at
|
||||
// once Forward (to the Navara entry), while Back's switch runs.
|
||||
const length = (await sessionState(p)).history;
|
||||
await progress();
|
||||
await p.evaluate(() => {
|
||||
window.addEventListener(
|
||||
'popstate',
|
||||
() => {
|
||||
window.__qaMark('back');
|
||||
history.forward();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
history.back();
|
||||
});
|
||||
await waitForEngine(p, 'navara');
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
await assertSession(p, 'navara', 'Forward supersedes Back');
|
||||
const visited = await sessionState(p);
|
||||
const shown = await engines();
|
||||
const backSteps = await p.evaluate(() => window.__qaSteps);
|
||||
contract(
|
||||
'Back then Forward before the Cesium entry is ready: Navara runs on the same session, no entry added, one canvas shown',
|
||||
visited.history === length && shown.warm && shown.shown === 1,
|
||||
{ steps: backSteps, history: visited.history - length, ...shown },
|
||||
);
|
||||
|
||||
// 3. A pick superseded by Back before its engine is ready: the person
|
||||
// picks Cesium and presses Back while it starts (Back is issued from the
|
||||
// page's own change handling of the pick, so it lands mid-switch). The
|
||||
// pick adds no entry and Back's entry (Cesium, the entry before) wins.
|
||||
const before = await sessionState(p);
|
||||
await progress();
|
||||
await p.evaluate(() => {
|
||||
window.addEventListener(
|
||||
'change',
|
||||
(event) => {
|
||||
if (event.target?.id !== 'globe-engine') return;
|
||||
window.__qaMark('pick');
|
||||
history.back();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
await strict.pick(p, 'cesium', { settle: false });
|
||||
await waitForEngine(p, 'cesium', { timeout: 30000 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
await assertSession(p, 'cesium', 'Back supersedes the pick');
|
||||
const after = await sessionState(p);
|
||||
const backShown = await engines();
|
||||
const pickSteps = await p.evaluate(() => window.__qaSteps);
|
||||
contract(
|
||||
'a pick superseded by Back before ready: Back’s Cesium entry runs on the same session, the pick adds no entry, one canvas shown',
|
||||
after.history === before.history &&
|
||||
backShown.warm &&
|
||||
backShown.shown === 1 &&
|
||||
pickSteps[0]?.busy,
|
||||
{ steps: pickSteps, history: after.history - before.history, ...backShown },
|
||||
);
|
||||
// The page still takes an ordinary pick afterwards.
|
||||
await strict.pick(p, 'navara');
|
||||
await waitForEngine(p, 'navara');
|
||||
await assertSession(p, 'navara', 'a pick after the races');
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
// Shared pieces of the strict contract scenarios (qa-strict-*.mjs): a fixed
|
||||
// layer session, its identity checks, the first-frame probe and the WebGL
|
||||
// object counters. Everything runs on the existing test hooks
|
||||
// (window.__godsEyeView); nothing here changes the application.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { waitForEngine } from './qa-strict-harness.mjs';
|
||||
|
||||
/** Austin, 12 km up, OpenStreetMap (answered by the tile disk cache). */
|
||||
export const SESSION_HASH =
|
||||
'v=2&lat=30.27&lon=-97.74&alt=12000&heading=0&pitch=-90&map=osm&l=';
|
||||
|
||||
/**
|
||||
* In-page fixtures for the session layers: one Austin earthquake, `flights`
|
||||
* aircraft in a grid around Austin, `military` aircraft, no vessels.
|
||||
* The counters on window.__qaReads show every source read.
|
||||
*/
|
||||
export async function installSessionFixtures(
|
||||
page,
|
||||
{ flights = 1, military = 1 } = {},
|
||||
) {
|
||||
await page.evaluateOnNewDocument(
|
||||
(flights, military) => {
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
window.__qaReads = { quakes: 0, flights: 0, military: 0 };
|
||||
const fetch = window.fetch.bind(window);
|
||||
const side = Math.ceil(Math.sqrt(flights));
|
||||
window.fetch = (input, options) => {
|
||||
const url = new URL(input?.url ?? input, document.baseURI);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (url.hostname === 'earthquake.usgs.gov') {
|
||||
window.__qaReads.quakes++;
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
id: 'qa-austin',
|
||||
properties: { mag: 4.1, time: Date.now(), place: 'Austin' },
|
||||
geometry: { type: 'Point', coordinates: [-97.74, 30.27, 8] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (url.pathname === '/api/flights') {
|
||||
window.__qaReads.flights++;
|
||||
const states = [];
|
||||
for (let i = 0; i < flights; i++)
|
||||
states.push([
|
||||
(0xa00000 + i).toString(16),
|
||||
'QA' + i,
|
||||
'QA',
|
||||
now,
|
||||
now,
|
||||
-99.5 + ((i % side) / side) * 3.5,
|
||||
28.5 + (Math.floor(i / side) / side) * 3.5,
|
||||
3000 + (i % 40) * 250,
|
||||
false,
|
||||
180 + (i % 50),
|
||||
(i * 37) % 360,
|
||||
0,
|
||||
null,
|
||||
3000 + (i % 40) * 250,
|
||||
null,
|
||||
false,
|
||||
0,
|
||||
]);
|
||||
return Promise.resolve(Response.json({ time: now, states }));
|
||||
}
|
||||
if (url.pathname === '/api/military') {
|
||||
window.__qaReads.military++;
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
now: Date.now(),
|
||||
ac: Array.from({ length: military }, (_, i) => ({
|
||||
hex: (0xae0000 + i).toString(16),
|
||||
flight: 'QAMIL' + i,
|
||||
lon: -97.9 + i * 0.01,
|
||||
lat: 30.1 + i * 0.01,
|
||||
alt_geom: 20000,
|
||||
alt_baro: 20000,
|
||||
gs: 300,
|
||||
track: 90,
|
||||
seen: 0,
|
||||
seen_pos: 0,
|
||||
dbFlags: 1,
|
||||
type: 'adsb_icao',
|
||||
})),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (url.pathname.startsWith('/api/flights/'))
|
||||
return Promise.resolve(Response.json({ path: [] }));
|
||||
if (url.pathname === '/api/vessels')
|
||||
return Promise.resolve(Response.json({ status: 'live', rows: [] }));
|
||||
return fetch(input, options);
|
||||
};
|
||||
},
|
||||
flights,
|
||||
military,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the app on `engine` with the session hash (and `search`, more query
|
||||
* parameters such as `&parkedMs=3000`) and wait until it runs.
|
||||
*/
|
||||
export async function openApp(
|
||||
page,
|
||||
origin,
|
||||
engine,
|
||||
hash = SESSION_HASH,
|
||||
search = '',
|
||||
) {
|
||||
const url = new URL(origin);
|
||||
url.search = `?engine=${engine}&welcome=0${search}`;
|
||||
url.hash = hash;
|
||||
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
||||
await waitForEngine(page, engine);
|
||||
await page.evaluate(() => {
|
||||
if (window.__GOOGLE_MAPS_API_KEY__ || window.__godsEyeView.tileset)
|
||||
throw Error('Keyless preview required');
|
||||
window.__qaEngines = new Map([
|
||||
[window.__godsEyeView.engine.kind, window.__godsEyeView.engine],
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/** Enable `layers` and remember the session's identity. */
|
||||
export async function startSession(page, layers) {
|
||||
await page.evaluate(async (layers) => {
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
for (const id of layers)
|
||||
await dataManager.setEnabled(id, true, { origin: 'user' });
|
||||
window.__qaSession = {
|
||||
manager: dataManager,
|
||||
models: new Map(
|
||||
[...dataManager.layers].map(([id, entry]) => [id, entry.module]),
|
||||
),
|
||||
enabled: [...dataManager.getEnabledLayerIds()].sort(),
|
||||
};
|
||||
}, layers);
|
||||
}
|
||||
|
||||
/**
|
||||
* The session as the page has it now against the one startSession saw:
|
||||
* the same manager, every layer model, the same enabled layers.
|
||||
*/
|
||||
export function sessionState(page) {
|
||||
return page.evaluate(() => {
|
||||
const app = window.__godsEyeView;
|
||||
const session = window.__qaSession;
|
||||
if (!app) return { running: false };
|
||||
const { dataManager } = app;
|
||||
return {
|
||||
running: true,
|
||||
engine: app.engine.kind,
|
||||
manager: dataManager === session.manager,
|
||||
models:
|
||||
session.models.size === dataManager.layers.size &&
|
||||
[...session.models].every(
|
||||
([id, model]) => dataManager.layers.get(id)?.module === model,
|
||||
),
|
||||
enabled:
|
||||
JSON.stringify([...dataManager.getEnabledLayerIds()].sort()) ===
|
||||
JSON.stringify(session.enabled),
|
||||
address: new URL(location.href).searchParams.get('engine'),
|
||||
picker: document.querySelector('#globe-engine')?.value ?? null,
|
||||
history: history.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Assert the session is intact on `engine`. */
|
||||
export async function assertSession(page, engine, message) {
|
||||
const state = await sessionState(page);
|
||||
assert.deepEqual(
|
||||
{
|
||||
running: state.running,
|
||||
engine: state.engine,
|
||||
manager: state.manager,
|
||||
models: state.models,
|
||||
enabled: state.enabled,
|
||||
address: state.address,
|
||||
picker: state.picker,
|
||||
},
|
||||
{
|
||||
running: true,
|
||||
engine,
|
||||
manager: true,
|
||||
models: true,
|
||||
enabled: true,
|
||||
address: engine,
|
||||
picker: engine,
|
||||
},
|
||||
message,
|
||||
);
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Remember the running engine object under its kind (for warm switches). */
|
||||
export function rememberEngine(page) {
|
||||
return page.evaluate(() => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
window.__qaEngines.set(engine.kind, engine);
|
||||
return engine.kind;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Install (before navigation) the first-frame probe:
|
||||
* window.__qaFirstFrame(kind) resolves with the performance.now() times of
|
||||
* the first frame of the `kind` engine that is drawn into its connected
|
||||
* canvas with a non-blank picture (postRender, the canvas read back in the
|
||||
* same task: more than 1% of a 48x48 sample differs from its first pixel),
|
||||
* and of the animation frame after it (presented).
|
||||
*/
|
||||
export async function installFirstFrameProbe(page) {
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
window.__qaFirstFrame = (kind) =>
|
||||
new Promise((resolve) => {
|
||||
const probe = document.createElement('canvas');
|
||||
probe.width = probe.height = 48;
|
||||
const context = probe.getContext('2d', { willReadFrequently: true });
|
||||
const nonBlank = (canvas) => {
|
||||
try {
|
||||
context.clearRect(0, 0, 48, 48);
|
||||
context.drawImage(canvas, 0, 0, 48, 48);
|
||||
const d = context.getImageData(0, 0, 48, 48).data;
|
||||
let differing = 0;
|
||||
for (let i = 4; i < d.length; i += 4)
|
||||
if (
|
||||
Math.abs(d[i] - d[0]) +
|
||||
Math.abs(d[i + 1] - d[1]) +
|
||||
Math.abs(d[i + 2] - d[2]) >
|
||||
24
|
||||
)
|
||||
differing++;
|
||||
return differing > 23;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let attached = null;
|
||||
let remove = () => {};
|
||||
const attach = (engine) => {
|
||||
attached = engine;
|
||||
remove = engine.frames.postRender.addEventListener(() => {
|
||||
if (!engine.host.canvas.isConnected) return;
|
||||
if (!nonBlank(engine.host.canvas)) return;
|
||||
const drawn = performance.now();
|
||||
remove();
|
||||
clearInterval(timer);
|
||||
requestAnimationFrame(() =>
|
||||
resolve({ drawn, presented: performance.now() }),
|
||||
);
|
||||
});
|
||||
};
|
||||
const known = window.__qaEngines?.get(kind);
|
||||
if (known) attach(known);
|
||||
// A cold engine: attach as soon as the page exposes it.
|
||||
const timer = setInterval(() => {
|
||||
const engine = window.__godsEyeView?.engine;
|
||||
if (!attached && engine?.kind === kind) attach(engine);
|
||||
}, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Count live WebGL objects per context (before navigation): every
|
||||
* create/delete pair of buffers, textures, programs, shaders,
|
||||
* framebuffers, renderbuffers and vertex arrays (WebGL2 and the OES
|
||||
* extension), per context, on window.__qaGl.
|
||||
*/
|
||||
export async function installWebGlCounters(page) {
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
const KINDS = {
|
||||
Buffer: 'buffers',
|
||||
Texture: 'textures',
|
||||
Program: 'programs',
|
||||
Shader: 'shaders',
|
||||
Framebuffer: 'framebuffers',
|
||||
Renderbuffer: 'renderbuffers',
|
||||
VertexArray: 'vaos',
|
||||
};
|
||||
const entries = new WeakMap();
|
||||
const live = new WeakSet();
|
||||
const contexts = [];
|
||||
window.__qaGl = { contexts };
|
||||
const fresh = () =>
|
||||
Object.fromEntries(Object.values(KINDS).map((key) => [key, 0]));
|
||||
const getContext = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = function (type, ...args) {
|
||||
const context = getContext.call(this, type, ...args);
|
||||
if (context && /webgl/i.test(type) && !entries.has(context)) {
|
||||
const entry = {
|
||||
canvas: this,
|
||||
context,
|
||||
type,
|
||||
live: fresh(),
|
||||
created: fresh(),
|
||||
};
|
||||
entries.set(context, entry);
|
||||
contexts.push(entry);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
const wrap = (target, create, destroy, key, owner = (self) => self) => {
|
||||
const make = target[create];
|
||||
const drop = target[destroy];
|
||||
if (typeof make !== 'function') return;
|
||||
target[create] = function (...args) {
|
||||
const object = make.apply(this, args);
|
||||
const entry = entries.get(owner(this));
|
||||
if (object && entry) {
|
||||
entry.live[key]++;
|
||||
entry.created[key]++;
|
||||
live.add(object);
|
||||
}
|
||||
return object;
|
||||
};
|
||||
target[destroy] = function (object, ...args) {
|
||||
const entry = entries.get(owner(this));
|
||||
if (object && entry && live.has(object)) {
|
||||
live.delete(object);
|
||||
entry.live[key]--;
|
||||
}
|
||||
return drop.call(this, object, ...args);
|
||||
};
|
||||
};
|
||||
for (const proto of [
|
||||
WebGLRenderingContext.prototype,
|
||||
globalThis.WebGL2RenderingContext?.prototype,
|
||||
].filter(Boolean))
|
||||
for (const [name, key] of Object.entries(KINDS))
|
||||
wrap(proto, 'create' + name, 'delete' + name, key);
|
||||
// WebGL1 vertex arrays come from an extension object.
|
||||
const getExtension = WebGLRenderingContext.prototype.getExtension;
|
||||
const vaoOwners = new WeakMap();
|
||||
WebGLRenderingContext.prototype.getExtension = function (name, ...args) {
|
||||
const extension = getExtension.call(this, name, ...args);
|
||||
if (
|
||||
extension &&
|
||||
name === 'OES_vertex_array_object' &&
|
||||
!vaoOwners.has(extension)
|
||||
) {
|
||||
vaoOwners.set(extension, this);
|
||||
wrap(
|
||||
extension,
|
||||
'createVertexArrayOES',
|
||||
'deleteVertexArrayOES',
|
||||
'vaos',
|
||||
(self) => vaoOwners.get(self),
|
||||
);
|
||||
}
|
||||
return extension;
|
||||
};
|
||||
window.__qaGlCounts = () => {
|
||||
const engines = window.__qaEngines ?? new Map();
|
||||
return contexts.map((entry) => {
|
||||
let owner = 'other';
|
||||
for (const [kind, engine] of engines)
|
||||
if (engine.host?.canvas === entry.canvas) owner = kind;
|
||||
return {
|
||||
owner,
|
||||
connected: entry.canvas.isConnected,
|
||||
lost: entry.context.isContextLost(),
|
||||
total: Object.values(entry.live).reduce((a, b) => a + b, 0),
|
||||
...entry.live,
|
||||
};
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Least-squares slope of y over x. */
|
||||
export function slope(points) {
|
||||
const n = points.length;
|
||||
if (n < 2) return 0;
|
||||
const mx = points.reduce((s, p) => s + p[0], 0) / n;
|
||||
const my = points.reduce((s, p) => s + p[1], 0) / n;
|
||||
let num = 0,
|
||||
den = 0;
|
||||
for (const [x, y] of points) {
|
||||
num += (x - mx) * (y - my);
|
||||
den += (x - mx) ** 2;
|
||||
}
|
||||
return den ? num / den : 0;
|
||||
}
|
||||
|
||||
/** The q-quantile (nearest rank) of numbers. */
|
||||
export function quantile(values, q) {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
if (!sorted.length) return null;
|
||||
return sorted[Math.max(0, Math.ceil(sorted.length * q) - 1)];
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { quantile, slope } from './qa-strict-session.mjs';
|
||||
import { strictArguments } from './qa-strict-harness.mjs';
|
||||
|
||||
test('slope is the least-squares growth per step', () => {
|
||||
assert.equal(slope([[0, 5]]), 0);
|
||||
assert.equal(
|
||||
slope([
|
||||
[20, 100],
|
||||
[50, 103],
|
||||
[100, 108],
|
||||
]).toFixed(3),
|
||||
'0.100',
|
||||
);
|
||||
assert.equal(
|
||||
slope([
|
||||
[1, 7],
|
||||
[2, 7],
|
||||
[3, 7],
|
||||
]),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('quantile takes the nearest rank', () => {
|
||||
assert.equal(quantile([], 0.5), null);
|
||||
assert.equal(quantile([5, 1, 3, 2, 4], 0.5), 3);
|
||||
assert.equal(
|
||||
quantile(
|
||||
[...Array(100)].map((_, i) => i + 1),
|
||||
0.95,
|
||||
),
|
||||
95,
|
||||
);
|
||||
});
|
||||
|
||||
test('the runner hands each scenario its layers and its manifest path', () => {
|
||||
assert.deepEqual(
|
||||
strictArguments(['node', 'x', '--layers=a,b', '--evidence=/tmp/e.json']),
|
||||
{ layers: ['a', 'b'], evidence: '/tmp/e.json' },
|
||||
);
|
||||
assert.deepEqual(strictArguments(['node', 'x', '--layers=']), {
|
||||
layers: null,
|
||||
evidence: process.env.QA_STRICT_EVIDENCE ?? null,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
// Soak and switch timing under a mixed workload: radio playing (750
|
||||
// stations), earthquakes, 10 000 flights, 200 military aircraft, wind with
|
||||
// its GPU particles, and the traffic simulation, all from fixtures.
|
||||
//
|
||||
// Each run starts a fresh browser on one engine and switches with real
|
||||
// input on the picker. Run r starts on Navara when r is odd and on Cesium
|
||||
// when it is even, so the first switch of each run is a cold one in either
|
||||
// direction.
|
||||
//
|
||||
// "First frame" of a switch: the first postRender of the incoming engine
|
||||
// whose picture, read back from its canvas in that callback, is not blank,
|
||||
// then the next animation frame (presented). Timed in the page from the
|
||||
// picker's change event, so no CDP round trip is included.
|
||||
//
|
||||
// Checked on every switch: the same session (manager and every layer
|
||||
// model), the radio still playing the same station with one play() call.
|
||||
// With --assert (the long run), the flights' fix history fills first (see
|
||||
// below), then after the warm-up (switch 20): live WebGL
|
||||
// objects per engine context grow by at most 0.1 per switch (least-squares
|
||||
// slope over the checkpoints) and by at most 64 overall; no new WebGL
|
||||
// context; the JS objects grow by at most 10 KiB per switch and native
|
||||
// memory by at most 32 KiB (heap snapshots at the warm-up and the end, V8's
|
||||
// compiled code left out and reported apart, with the used heap's slope); no
|
||||
// new document; the webglcontextlost/restored listener counts on both engine
|
||||
// canvases stay as they were (each canvas keeps its webglcontextlost
|
||||
// listener).
|
||||
//
|
||||
// node scripts/qa-strict-soak.mjs http://127.0.0.1:4208 --runs=6 --swaps=50
|
||||
// node scripts/qa-strict-soak.mjs http://127.0.0.1:4208 --runs=1 --swaps=500 --assert
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
import {
|
||||
installFirstFrameProbe,
|
||||
installSessionFixtures,
|
||||
installWebGlCounters,
|
||||
openApp,
|
||||
quantile,
|
||||
rememberEngine,
|
||||
slope,
|
||||
startSession,
|
||||
} from './qa-strict-session.mjs';
|
||||
import { installRadioFixture, stations } from './fixtures/w2/radio.mjs';
|
||||
import { trafficFixtureResponse } from './traffic-fixtures.mjs';
|
||||
import { POSITION_HISTORY_LIMIT } from '../src/layers/flights/recordPolicy.js';
|
||||
import { heapSizesByType } from './qa/heapSnapshotSizes.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
const option = (name, fallback) => {
|
||||
const arg = process.argv.find((a) => a.startsWith(`--${name}=`));
|
||||
return arg ? Number(arg.slice(name.length + 3)) : fallback;
|
||||
};
|
||||
const runs = option('runs', 6);
|
||||
const swaps = option('swaps', 50);
|
||||
const strictMode = process.argv.includes('--assert');
|
||||
assert.ok(
|
||||
Number.isInteger(swaps) && swaps >= 4 && swaps % 2 === 0,
|
||||
'even --swaps',
|
||||
);
|
||||
const WARM_UP = 20;
|
||||
const CHECKPOINTS = [
|
||||
2, 10, 20, 50, 100, 150, 200, 250, 300, 350, 400, 450, 500,
|
||||
];
|
||||
const HASH =
|
||||
'v=2&lat=30.2670&lon=-97.7440&alt=3000&heading=0&pitch=-90&map=osm&l=';
|
||||
const LAYERS = [
|
||||
'earthquakes',
|
||||
'flights',
|
||||
'military',
|
||||
'wind',
|
||||
'traffic',
|
||||
'radio',
|
||||
];
|
||||
|
||||
const strict = createStrict({
|
||||
origin: base.origin,
|
||||
layers: [],
|
||||
// Road tiles of the traffic simulation, from scripts/traffic-fixtures.mjs.
|
||||
fixtures: ['https://tiles.openfreemap.org/*'],
|
||||
});
|
||||
|
||||
/** Wind and traffic fixtures on their own CDP Fetch patterns. */
|
||||
async function installWorkloadFixtures(page) {
|
||||
const session = await page.createCDPSession();
|
||||
const grid = (u) => {
|
||||
const kind = u.searchParams.get('overlay');
|
||||
const count = 36 * 19;
|
||||
const values = new Float32Array(count * (kind ? 3 : 2));
|
||||
values.fill(8, 0, count);
|
||||
values.fill(6, count, count * 2);
|
||||
if (kind) values.fill(40, count * 2);
|
||||
return Buffer.from(values.buffer);
|
||||
};
|
||||
session.on('Fetch.requestPaused', async ({ requestId, request }) => {
|
||||
const u = new URL(request.url);
|
||||
let answer;
|
||||
try {
|
||||
if (u.pathname.startsWith('/api/wind/')) {
|
||||
const model = u.searchParams.get('model');
|
||||
answer = u.pathname.endsWith('/manifest')
|
||||
? {
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
overlay: 'none',
|
||||
grid: { nx: 36, ny: 19, lo1: 0, la1: 90, dx: 10, dy: 10 },
|
||||
gridUrl: '/api/wind/grid/' + model + '-qa.bin' + u.search,
|
||||
cycle: {
|
||||
validIso: '2026-11-02T12:00:00Z',
|
||||
runIso: '2026-11-02T06:00:00Z',
|
||||
},
|
||||
}),
|
||||
}
|
||||
: {
|
||||
status: 200,
|
||||
contentType: 'application/octet-stream',
|
||||
body: grid(u),
|
||||
};
|
||||
} else if (u.pathname === '/api/tomtom/status')
|
||||
answer = {
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: '{"hasKey":false}',
|
||||
};
|
||||
else answer = trafficFixtureResponse({ url: () => request.url });
|
||||
await session.send('Fetch.fulfillRequest', {
|
||||
requestId,
|
||||
responseCode: answer.status,
|
||||
responseHeaders: [
|
||||
{ name: 'Content-Type', value: answer.contentType },
|
||||
{ name: 'Access-Control-Allow-Origin', value: '*' },
|
||||
],
|
||||
body: Buffer.from(answer.body).toString('base64'),
|
||||
});
|
||||
} catch {
|
||||
await session
|
||||
.send('Fetch.failRequest', { requestId, errorReason: 'Failed' })
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
await session.send('Fetch.enable', {
|
||||
patterns: [
|
||||
{ urlPattern: `${base.origin}/api/wind/*` },
|
||||
{ urlPattern: `${base.origin}/api/tomtom/*` },
|
||||
{ urlPattern: 'https://tiles.openfreemap.org/*' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const report = { runs: [], swaps, strict: strictMode };
|
||||
strict.extra.soak = report;
|
||||
|
||||
for (let run = 1; run <= runs; run++) {
|
||||
const start = run % 2 ? 'navara' : 'cesium';
|
||||
const browser = await strict.launch();
|
||||
const row = { run, start, swaps: [], checkpoints: [], errors: [] };
|
||||
report.runs.push(row);
|
||||
try {
|
||||
const p = await browser.newPage();
|
||||
await p.setViewport({ width: 1280, height: 800 });
|
||||
p.on('pageerror', (e) => row.errors.push(e.message.slice(0, 300)));
|
||||
await installRadioFixture(p);
|
||||
await installSessionFixtures(p, { flights: 10000, military: 200 });
|
||||
await installWebGlCounters(p);
|
||||
await installFirstFrameProbe(p);
|
||||
await installWorkloadFixtures(p);
|
||||
const cdp = await p.createCDPSession();
|
||||
await openApp(p, base.origin, start, HASH);
|
||||
await startSession(p, LAYERS);
|
||||
await p.waitForFunction(
|
||||
() => {
|
||||
const layers = window.__godsEyeView.dataManager.layers;
|
||||
const stat = (id) => layers.get(id).module.getStats();
|
||||
return (
|
||||
stat('radio').count === 750 &&
|
||||
stat('flights').count > 0 &&
|
||||
stat('military').count > 0 &&
|
||||
stat('traffic').count >= 10 &&
|
||||
layers.get('wind').module.getDiagnostics().gpu?.ready
|
||||
);
|
||||
},
|
||||
{ timeout: 90000, polling: 500 },
|
||||
);
|
||||
await p.evaluate((id) => {
|
||||
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
||||
radio.selectStation(id, { autoplay: true, focus: false, origin: 'user' });
|
||||
window.__qaRadio.station = id;
|
||||
}, stations[10].id);
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.__qaRadio.plays.length === 1 &&
|
||||
window.__godsEyeView.dataManager.layers.get('radio').module.getUIState()
|
||||
.audioState === 'playing',
|
||||
);
|
||||
// Each aircraft keeps its last POSITION_HISTORY_LIMIT fixes for dead
|
||||
// reckoning, so the first polls of the flights feed grow the heap by
|
||||
// itself (about 14 MiB here, over 2.5 min; a run without switches then
|
||||
// levels off). The long run lets that history fill before it switches,
|
||||
// so the heap slope it asserts is the switches' own.
|
||||
if (strictMode) {
|
||||
const filling = Date.now();
|
||||
await p.waitForFunction(
|
||||
(polls) => window.__qaReads.flights > polls,
|
||||
{ timeout: 300000, polling: 1000 },
|
||||
POSITION_HISTORY_LIMIT,
|
||||
);
|
||||
row.historyFillSeconds = Math.round((Date.now() - filling) / 1000);
|
||||
}
|
||||
row.workload = await p.evaluate(() => {
|
||||
const layers = window.__godsEyeView.dataManager.layers;
|
||||
return Object.fromEntries(
|
||||
['flights', 'military', 'traffic', 'radio', 'earthquakes'].map((id) => [
|
||||
id,
|
||||
layers.get(id).module.getStats().count,
|
||||
]),
|
||||
);
|
||||
});
|
||||
const listeners = async () => {
|
||||
const { result } = await cdp.send('Runtime.evaluate', {
|
||||
includeCommandLineAPI: true,
|
||||
returnByValue: true,
|
||||
expression: `(() => Object.fromEntries([...window.__qaEngines].map(([kind, engine]) => {
|
||||
const events = getEventListeners(engine.host.canvas);
|
||||
return [kind, [events.webglcontextlost?.length ?? 0, events.webglcontextrestored?.length ?? 0]];
|
||||
})))()`,
|
||||
});
|
||||
return result.value;
|
||||
};
|
||||
const seen = new Set([start]);
|
||||
let kind = start;
|
||||
// The switch after a heap snapshot is slower; timings leave it out.
|
||||
let afterSnapshot = false;
|
||||
for (let i = 1; i <= swaps; i++) {
|
||||
kind = kind === 'navara' ? 'cesium' : 'navara';
|
||||
const cold = !seen.has(kind);
|
||||
seen.add(kind);
|
||||
await p.evaluate((kind) => {
|
||||
window.__qaFrame = window.__qaFirstFrame(kind);
|
||||
}, kind);
|
||||
const pick = await strict.pick(p, kind);
|
||||
await waitForEngine(p, kind);
|
||||
const frame = await p.evaluate(() => window.__qaFrame);
|
||||
await rememberEngine(p);
|
||||
const state = await p.evaluate(() => {
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
const session = window.__qaSession;
|
||||
const radio = dataManager.layers.get('radio').module;
|
||||
return {
|
||||
session:
|
||||
dataManager === session.manager &&
|
||||
[...session.models].every(
|
||||
([id, model]) => dataManager.layers.get(id)?.module === model,
|
||||
),
|
||||
radio:
|
||||
window.__qaRadio.plays.length === 1 &&
|
||||
radio.getUIState().audioState === 'playing' &&
|
||||
radio.getUIState().selected?.id === window.__qaRadio.station,
|
||||
};
|
||||
});
|
||||
assert.ok(
|
||||
state.session && state.radio,
|
||||
`swap ${i}: ${JSON.stringify(state)}`,
|
||||
);
|
||||
row.swaps.push({
|
||||
i,
|
||||
to: kind,
|
||||
cold,
|
||||
firstFrameMs: Math.round((frame.presented - pick.at) * 10) / 10,
|
||||
drawnMs: Math.round((frame.drawn - pick.at) * 10) / 10,
|
||||
settledMs:
|
||||
pick.idleAt == null
|
||||
? null
|
||||
: Math.round((pick.idleAt - pick.at) * 10) / 10,
|
||||
...(afterSnapshot ? { afterSnapshot } : {}),
|
||||
});
|
||||
afterSnapshot = false;
|
||||
if (CHECKPOINTS.includes(i) || i === swaps) {
|
||||
await cdp.send('HeapProfiler.collectGarbage');
|
||||
const heap = await cdp.send('Runtime.getHeapUsage');
|
||||
// The heap by snapshot node type, at the first checkpoint after the
|
||||
// warm-up and at the last: the asserted growth is the JS objects'
|
||||
// (V8's compiled code grows with tier-ups over a long run; native
|
||||
// memory, array buffer data included, is reported apart). The switch
|
||||
// after a snapshot is left out of the timings.
|
||||
let typesMiB = null;
|
||||
if (strictMode && (i === WARM_UP || i === swaps)) {
|
||||
const sizes = await heapSizesByType(cdp);
|
||||
typesMiB = Object.fromEntries(
|
||||
Object.entries(sizes).map(([type, bytes]) => [
|
||||
type,
|
||||
Math.round((bytes / 1048576) * 100) / 100,
|
||||
]),
|
||||
);
|
||||
afterSnapshot = true;
|
||||
}
|
||||
const dom = await cdp.send('Memory.getDOMCounters');
|
||||
row.checkpoints.push({
|
||||
swap: i,
|
||||
engine: kind,
|
||||
heapMiB: Math.round((heap.usedSize / 1048576) * 100) / 100,
|
||||
typesMiB,
|
||||
...dom,
|
||||
listeners: await listeners(),
|
||||
webgl: await p.evaluate(() => window.__qaGlCounts()),
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
const warm = (to) =>
|
||||
row.swaps
|
||||
.filter((s) => !s.cold && !s.afterSnapshot && s.to === to)
|
||||
.map((s) => s.firstFrameMs);
|
||||
row.summary = Object.fromEntries(
|
||||
['navara', 'cesium'].map((to) => [
|
||||
to,
|
||||
{
|
||||
cold:
|
||||
row.swaps.find((s) => s.cold && s.to === to)?.firstFrameMs ?? null,
|
||||
warmP50: quantile(warm(to), 0.5),
|
||||
warmP95: quantile(warm(to), 0.95),
|
||||
},
|
||||
]),
|
||||
);
|
||||
console.log(
|
||||
'RUN',
|
||||
JSON.stringify({ run, start, ...row.summary, workload: row.workload }),
|
||||
);
|
||||
}
|
||||
|
||||
// Pooled over the runs, per direction.
|
||||
const pooled = (to, cold) =>
|
||||
report.runs.flatMap((r) =>
|
||||
r.swaps
|
||||
.filter((s) => s.to === to && s.cold === cold && !s.afterSnapshot)
|
||||
.map((s) => s.firstFrameMs),
|
||||
);
|
||||
report.summary = Object.fromEntries(
|
||||
['navara', 'cesium'].map((to) => [
|
||||
to === 'navara' ? 'cesium→navara' : 'navara→cesium',
|
||||
{
|
||||
cold: pooled(to, true),
|
||||
warmP50: quantile(pooled(to, false), 0.5),
|
||||
warmP95: quantile(pooled(to, false), 0.95),
|
||||
warmSwitches: pooled(to, false).length,
|
||||
},
|
||||
]),
|
||||
);
|
||||
console.log('SOAK', JSON.stringify(report.summary));
|
||||
strict.timings.push(
|
||||
...report.runs.flatMap((r) => r.swaps.map((s) => ({ run: r.run, ...s }))),
|
||||
);
|
||||
|
||||
if (strictMode) {
|
||||
const checks = [];
|
||||
for (const row of report.runs) {
|
||||
const late = row.checkpoints.filter((c) => c.swap >= WARM_UP);
|
||||
assert.ok(late.length >= 3, 'enough checkpoints after the warm-up');
|
||||
const first = late[0];
|
||||
const last = late.at(-1);
|
||||
for (const owner of ['navara', 'cesium']) {
|
||||
const totals = late.map((c) => [
|
||||
c.swap,
|
||||
c.webgl
|
||||
.filter((g) => g.owner === owner && !g.lost)
|
||||
.reduce((s, g) => s + g.total, 0),
|
||||
]);
|
||||
checks.push({
|
||||
check: `${owner} live WebGL objects`,
|
||||
slopePerSwap: Math.round(slope(totals) * 1000) / 1000,
|
||||
from: totals[0][1],
|
||||
to: totals.at(-1)[1],
|
||||
pass: slope(totals) <= 0.1 && totals.at(-1)[1] - totals[0][1] <= 64,
|
||||
});
|
||||
}
|
||||
checks.push({
|
||||
check: 'WebGL contexts',
|
||||
from: first.webgl.length,
|
||||
to: last.webgl.length,
|
||||
pass: last.webgl.length === first.webgl.length,
|
||||
});
|
||||
const heap = late.map((c) => [c.swap, c.heapMiB * 1024]);
|
||||
const typed = late.filter((c) => c.typesMiB);
|
||||
assert.equal(typed.length, 2, 'heap snapshots at the warm-up and the end');
|
||||
const [from, to] = typed;
|
||||
const outside = new Set(['code', 'native', 'synthetic']);
|
||||
const objects = (c) =>
|
||||
Object.entries(c.typesMiB)
|
||||
.filter(([type]) => !outside.has(type))
|
||||
.reduce((sum, [, mib]) => sum + mib, 0);
|
||||
const perSwitch = (mib) =>
|
||||
Math.round(((mib * 1024) / (to.swap - from.swap)) * 100) / 100;
|
||||
const objectGrowth = perSwitch(objects(to) - objects(from));
|
||||
const nativeGrowth = perSwitch(
|
||||
(to.typesMiB.native ?? 0) - (from.typesMiB.native ?? 0),
|
||||
);
|
||||
checks.push({
|
||||
check: 'JS heap KiB per switch',
|
||||
objectsKiBPerSwitch: objectGrowth,
|
||||
fromMiB: Math.round(objects(from) * 100) / 100,
|
||||
toMiB: Math.round(objects(to) * 100) / 100,
|
||||
// Reported, not asserted: the used heap's slope (compiled code
|
||||
// included) and compiled code's growth.
|
||||
usedSlopePerSwap: Math.round(slope(heap) * 100) / 100,
|
||||
codeKiBPerSwitch: perSwitch(
|
||||
(to.typesMiB.code ?? 0) - (from.typesMiB.code ?? 0),
|
||||
),
|
||||
nativeKiBPerSwitch: nativeGrowth,
|
||||
pass: objectGrowth <= 10 && nativeGrowth <= 32,
|
||||
});
|
||||
checks.push({
|
||||
check: 'documents',
|
||||
from: first.documents,
|
||||
to: last.documents,
|
||||
pass: last.documents <= first.documents,
|
||||
});
|
||||
checks.push({
|
||||
check: 'context-loss listeners on both canvases',
|
||||
from: first.listeners,
|
||||
to: last.listeners,
|
||||
pass:
|
||||
JSON.stringify(last.listeners) === JSON.stringify(first.listeners) &&
|
||||
Object.keys(last.listeners).length === 2 &&
|
||||
Object.values(last.listeners).every(([lost]) => lost > 0),
|
||||
});
|
||||
}
|
||||
report.checks = checks;
|
||||
for (const check of checks)
|
||||
console.log(check.pass ? 'PASS' : 'FAIL', JSON.stringify(check));
|
||||
await strict.finish({
|
||||
failed: checks.some((c) => !c.pass) ? 'soak checks failed' : null,
|
||||
});
|
||||
if (checks.some((c) => !c.pass)) process.exitCode = 1;
|
||||
} else await strict.finish();
|
||||
@@ -0,0 +1,133 @@
|
||||
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
openPage,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0&look=cinematic';
|
||||
base.hash =
|
||||
'v=2&lat=55.3241&lon=10.7995&alt=25000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
|
||||
const CABLES = 'telegeography-submarine-cables';
|
||||
// Cables plus landing points in the bundled TeleGeography files.
|
||||
const FEATURES = ['cable-geo', 'landing-point-geo']
|
||||
.map(
|
||||
(name) =>
|
||||
JSON.parse(
|
||||
readFileSync(
|
||||
new URL(
|
||||
`../src/data/local_data/telegeography_submarine_cables/${name}.json`,
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
).features.length,
|
||||
)
|
||||
.reduce((a, b) => a + b, 0);
|
||||
const strict = createStrict({
|
||||
origin: base.origin,
|
||||
layers: ['telegeography-submarine-cables'],
|
||||
});
|
||||
await strict.run({}, async (b) => {
|
||||
const { page: p, errors } = await openPage(b, { shaderErrors: true });
|
||||
const reads = [];
|
||||
p.on('request', (req) => {
|
||||
const path = new URL(req.url()).pathname;
|
||||
if (/\/(cable-geo|landing-point-geo).*\.json$/.test(path)) reads.push(path);
|
||||
});
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(async () => {
|
||||
const { dataManager, tileset } = window.__godsEyeView;
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Keyless preview required');
|
||||
window.qaCables = dataManager.layers.get(
|
||||
'telegeography-submarine-cables',
|
||||
).module;
|
||||
await dataManager.setEnabled(window.qaCables.id, true, { origin: 'user' });
|
||||
});
|
||||
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
|
||||
await strict.switchTo(p, kind);
|
||||
}
|
||||
await p.waitForFunction(
|
||||
() => window.qaCables.getStats().referenceLabelCount > 0,
|
||||
{ timeout: 90000 },
|
||||
);
|
||||
const check = await p.evaluate(() => {
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
const mod = dataManager.layers.get(
|
||||
'telegeography-submarine-cables',
|
||||
).module;
|
||||
return { same: mod === window.qaCables, ...mod.getStats() };
|
||||
});
|
||||
console.log(kind, check);
|
||||
assert.equal(check.same, true);
|
||||
assert.equal(check.count, FEATURES);
|
||||
assert.ok(check.referenceLabelCount <= 160);
|
||||
assert.equal(check.error, null);
|
||||
await strict.passed(
|
||||
p,
|
||||
CABLES,
|
||||
`same model, all ${FEATURES} cables and landing points of the bundled files, at most 160 labels`,
|
||||
{ count: check.count, labels: check.referenceLabelCount },
|
||||
);
|
||||
{
|
||||
// Click-picking on this engine, from the starting view: a click on a
|
||||
// landing point flies the camera to it.
|
||||
await p.evaluate(
|
||||
(destination) => {
|
||||
const { camera, render } = window.__godsEyeView.engine;
|
||||
camera.cancelFlight();
|
||||
camera.setView({
|
||||
destination,
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
render.request('qa-home');
|
||||
},
|
||||
fromDegrees(10.7995, 55.3241, 25000),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2500));
|
||||
const screen = await p.evaluate(
|
||||
(world) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const at = engine.projector.worldToWindow(world, {});
|
||||
const rect = engine.host.canvas.getBoundingClientRect();
|
||||
return { x: rect.left + at.x, y: rect.top + at.y };
|
||||
},
|
||||
fromDegrees(10.799480647390464, 55.324102015186114),
|
||||
);
|
||||
await p.mouse.click(screen.x, screen.y);
|
||||
await p.waitForFunction(
|
||||
() => {
|
||||
const c = window.__godsEyeView.engine.camera.pose();
|
||||
return (
|
||||
Math.abs(c.cartographic.height - 6500) < 80 &&
|
||||
Math.abs(c.pitch + (52 * Math.PI) / 180) < 0.03
|
||||
);
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
await strict.passed(
|
||||
p,
|
||||
CABLES,
|
||||
'a click on a landing point flies the camera to it',
|
||||
);
|
||||
}
|
||||
}
|
||||
await strict.check(
|
||||
p,
|
||||
CABLES,
|
||||
'each bundled file read once',
|
||||
reads.length === 2,
|
||||
reads,
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS full bundled cable dataset, bounded labels, landing-point fly-to and one read per file',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
import { trafficFixtureResponse } from './traffic-fixtures.mjs';
|
||||
import sharp from 'sharp';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
openPage,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
import { STRICT_TILE_HOSTS } from './qa-map-policy.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0&look=cinematic';
|
||||
base.hash =
|
||||
'v=2&lat=30.2670&lon=-97.7440&alt=1800&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
|
||||
// The scenario answers the Sentinel-2 host itself (a fixed dark ground) and
|
||||
// the OpenFreeMap road tiles from its fixtures; the disk cache keeps the
|
||||
// other tile hosts.
|
||||
// The fixed fixture gives 1 093 vehicles and 35 congestion corridors with
|
||||
// flow and 618 simulated vehicles without it; the bands allow 5%.
|
||||
const BAND = process.argv.includes('--simulation')
|
||||
? { vehicles: [587, 649], corridors: [0, 0] }
|
||||
: { vehicles: [1038, 1148], corridors: [33, 37] };
|
||||
const strict = createStrict({
|
||||
origin: base.origin,
|
||||
layers: ['traffic'],
|
||||
fixtures: ['https://tiles.maps.eox.at/*', 'https://tiles.openfreemap.org/*'],
|
||||
tileHosts: STRICT_TILE_HOSTS.filter((host) => host !== 'tiles.maps.eox.at'),
|
||||
});
|
||||
await strict.run({}, async (b) => {
|
||||
const { page: p, errors } = await openPage(b, { shaderErrors: true });
|
||||
|
||||
const keyless = process.argv.includes('--simulation');
|
||||
const reads = new Map();
|
||||
// A uniform dark Sentinel-2 tile: live imagery is bright and blurry at this
|
||||
// altitude, so white 2 px vehicles on it gave anything from 14 to 300
|
||||
// changed pixels. A fixed dark ground makes every drawn vehicle visible.
|
||||
const ground = await sharp({
|
||||
create: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
channels: 3,
|
||||
background: { r: 28, g: 32, b: 30 },
|
||||
},
|
||||
})
|
||||
.jpeg()
|
||||
.toBuffer();
|
||||
await p.setRequestInterception(true);
|
||||
p.on('request', async (req) => {
|
||||
const u = new URL(req.url());
|
||||
if (u.hostname === 'tiles.maps.eox.at')
|
||||
return req.respond({
|
||||
status: 200,
|
||||
contentType: 'image/jpeg',
|
||||
headers: { 'access-control-allow-origin': '*' },
|
||||
body: ground,
|
||||
});
|
||||
if (
|
||||
u.hostname === 'tiles.openfreemap.org' ||
|
||||
u.pathname.startsWith('/api/tomtom/')
|
||||
) {
|
||||
reads.set(u.href, (reads.get(u.href) || 0) + 1);
|
||||
if (keyless && u.pathname === '/api/tomtom/status')
|
||||
return req.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: '{"hasKey":false}',
|
||||
});
|
||||
const response = trafficFixtureResponse(req);
|
||||
if (response) return req.respond(response);
|
||||
throw Error('unrecognized traffic URL: ' + u.pathname);
|
||||
}
|
||||
return req.continue();
|
||||
});
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(async () => {
|
||||
const { tileset, dataManager } = window.__godsEyeView;
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Keyless preview required');
|
||||
window.qaTraffic = dataManager.layers.get('traffic').module;
|
||||
window.qaTraffic.setParams({ jamViz: 'both' });
|
||||
await dataManager.setEnabled('traffic', true, { origin: 'user' });
|
||||
});
|
||||
const waitTraffic = async () => {
|
||||
try {
|
||||
// Vehicles, and with flow the congestion corridors, which the layer
|
||||
// publishes again once the next globe has attached.
|
||||
await p.waitForFunction(
|
||||
(keyless) => {
|
||||
const stats = window.qaTraffic.getStats();
|
||||
return stats.count >= 10 && (keyless || stats.heatLines > 0);
|
||||
},
|
||||
{ timeout: 40000 },
|
||||
keyless,
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(
|
||||
'no traffic',
|
||||
await p.evaluate(() => window.qaTraffic.getStats()),
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
await waitTraffic();
|
||||
// The view compared before and after the switches: a central window free of
|
||||
// panels and status toasts.
|
||||
const region = { left: 310, top: 160, right: 810, bottom: 660 };
|
||||
// Each vehicle in the window with its window position and its height above
|
||||
// the terrain Navara draws there. A vehicle below that terrain is hidden by
|
||||
// the depth test, so it cannot be expected in the picture.
|
||||
const vehiclesInView = () =>
|
||||
p.evaluate((r) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const a = 6378137,
|
||||
f = 1 / 298.257223563,
|
||||
e2 = f * (2 - f);
|
||||
const rows = [];
|
||||
for (const o of window.qaTraffic.getDetectableObjects()) {
|
||||
const w = engine.projector.worldToWindow(o.position);
|
||||
if (!w || w.x < r.left || w.y < r.top || w.x >= r.right) continue;
|
||||
if (w.y >= r.bottom) continue;
|
||||
const { x, y, z } = o.position;
|
||||
const longitude = Math.atan2(y, x),
|
||||
ring = Math.hypot(x, y);
|
||||
let latitude = Math.atan2(z, ring * (1 - e2)),
|
||||
height = 0;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const n = a / Math.sqrt(1 - e2 * Math.sin(latitude) ** 2);
|
||||
height = ring / Math.cos(latitude) - n;
|
||||
latitude = Math.atan2(z, ring * (1 - (e2 * n) / (n + height)));
|
||||
}
|
||||
const ground = engine.surface.terrainHeight({
|
||||
longitude,
|
||||
latitude,
|
||||
height: 0,
|
||||
});
|
||||
rows.push({
|
||||
id: o.id,
|
||||
x: w.x,
|
||||
y: w.y,
|
||||
clearance: Number.isFinite(ground) ? height - ground : null,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}, region);
|
||||
const settled = async () => {
|
||||
const deadline = Date.now() + 30000;
|
||||
for (;;) {
|
||||
const loading = await p.evaluate(
|
||||
() => window.qaTraffic.getStats().loading,
|
||||
);
|
||||
const rows = await vehiclesInView();
|
||||
if (!loading && rows.length >= 12) return rows;
|
||||
if (Date.now() > deadline)
|
||||
throw Error(
|
||||
'traffic did not settle with 12 vehicles in view: ' +
|
||||
JSON.stringify({ loading, inView: rows.length }),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
};
|
||||
// The population once roads, heights and dots have settled. Source counts
|
||||
// can be ready before the renderer has drawn terrain, so the camera and the
|
||||
// attached road heights get the same delay before and after the switches.
|
||||
const settledPopulation = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2200));
|
||||
await settled();
|
||||
return p.evaluate(() => window.qaTraffic.getStats().count);
|
||||
};
|
||||
const population = await settledPopulation();
|
||||
const buriedBefore = (await vehiclesInView()).filter(
|
||||
(v) => v.clearance != null && v.clearance <= -1,
|
||||
).length;
|
||||
const ids = await p.evaluate(() =>
|
||||
window.qaTraffic.getDetectableObjects().map((p) => p.id),
|
||||
);
|
||||
const first = await p.evaluate(
|
||||
() => window.qaTraffic.getStats().motion.rebuilds,
|
||||
);
|
||||
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
|
||||
await strict.switchTo(p, kind);
|
||||
}
|
||||
await waitTraffic();
|
||||
const check = await p.evaluate(() => {
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
const mod = dataManager.layers.get('traffic').module;
|
||||
return {
|
||||
same: mod === window.qaTraffic,
|
||||
...mod.getStats(),
|
||||
params: mod.getParams(),
|
||||
ids: mod.getDetectableObjects().map((p) => p.id),
|
||||
};
|
||||
});
|
||||
console.log(
|
||||
kind,
|
||||
check.mode,
|
||||
check.count,
|
||||
check.flowBuckets,
|
||||
check.heatLines,
|
||||
check.motion,
|
||||
);
|
||||
assert.equal(check.same, true);
|
||||
assert.equal(check.params.jamViz, 'both');
|
||||
assert.equal(check.error, null);
|
||||
assert.equal(
|
||||
check.motion.rebuilds,
|
||||
first,
|
||||
'traffic population was cleared',
|
||||
);
|
||||
assert.ok(
|
||||
check.ids.some((id) => ids.includes(id)),
|
||||
'original moving vehicles survived',
|
||||
);
|
||||
if (!keyless)
|
||||
assert.ok(check.heatLines > 0, 'congestion corridors are published');
|
||||
if (keyless) assert.equal(check.flowBuckets.sim, check.count);
|
||||
else assert.ok(check.flowBuckets.slow + check.flowBuckets.jam > 0);
|
||||
await strict.check(
|
||||
p,
|
||||
'traffic',
|
||||
keyless
|
||||
? `same module, parameters and vehicle ids; ${BAND.vehicles.join('-')} simulated vehicles, no flow`
|
||||
: `same module, parameters and vehicle ids; ${BAND.vehicles.join('-')} vehicles with flow, ${BAND.corridors.join('-')} congestion corridors`,
|
||||
check.count >= BAND.vehicles[0] &&
|
||||
check.count <= BAND.vehicles[1] &&
|
||||
check.heatLines >= BAND.corridors[0] &&
|
||||
check.heatLines <= BAND.corridors[1],
|
||||
{ count: check.count, corridors: check.heatLines },
|
||||
);
|
||||
}
|
||||
if (keyless)
|
||||
assert.ok(![...reads.keys()].some((u) => u.includes('/tomtom/flow/')));
|
||||
|
||||
// Compare the same view with the layer shown and hidden, vehicle by
|
||||
// vehicle.
|
||||
const raw = async () =>
|
||||
sharp(await p.screenshot())
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const settledCount = await settledPopulation();
|
||||
const before = await vehiclesInView();
|
||||
const shown = await raw();
|
||||
const after = new Map((await vehiclesInView()).map((v) => [v.id, v]));
|
||||
// Read again once the comparison is taken: the population used to fall
|
||||
// seconds after the return, once the incoming globe republished the roads.
|
||||
const finalCount = await p.evaluate(() => window.qaTraffic.getStats().count);
|
||||
await p.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('traffic', false, {
|
||||
origin: 'user',
|
||||
}),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
const hidden = await raw();
|
||||
const { width, height } = shown.info;
|
||||
const scale = width / 1024;
|
||||
const changed = (x, y) => {
|
||||
const i = (y * width + x) * 3;
|
||||
let d = 0;
|
||||
for (let c = 0; c < 3; c++)
|
||||
d += Math.abs(shown.data[i + c] - hidden.data[i + c]);
|
||||
return d > 90;
|
||||
};
|
||||
const near = new Set();
|
||||
const visible = { drawn: 0, missing: [] };
|
||||
let buried = 0,
|
||||
unclear = 0;
|
||||
for (const v of before) {
|
||||
const later = after.get(v.id);
|
||||
if (!later) continue;
|
||||
// The screenshot falls between the two projections.
|
||||
const cx = (v.x + later.x) / 2,
|
||||
cy = (v.y + later.y) / 2;
|
||||
let hit = false;
|
||||
for (let dy = -8; dy <= 8; dy++)
|
||||
for (let dx = -8; dx <= 8; dx++) {
|
||||
const x = Math.round((cx + dx) * scale),
|
||||
y = Math.round((cy + dy) * scale);
|
||||
if (x < 0 || y < 0 || x >= width || y >= height) continue;
|
||||
near.add(y * width + x);
|
||||
if (Math.abs(dx) <= 4 && Math.abs(dy) <= 4 && changed(x, y)) hit = true;
|
||||
}
|
||||
if (v.clearance == null || Math.abs(v.clearance) < 1) unclear++;
|
||||
else if (v.clearance < 0) buried++;
|
||||
else if (hit) visible.drawn++;
|
||||
else visible.missing.push(v);
|
||||
}
|
||||
let stray = 0,
|
||||
area = 0;
|
||||
for (let y = Math.round(region.top * scale); y < region.bottom * scale; y++)
|
||||
for (
|
||||
let x = Math.round(region.left * scale);
|
||||
x < region.right * scale;
|
||||
x++
|
||||
) {
|
||||
area++;
|
||||
if (!near.has(y * width + x) && changed(x, y)) stray++;
|
||||
}
|
||||
const aboveGround = visible.drawn + visible.missing.length;
|
||||
console.log(
|
||||
'vehicles in view',
|
||||
JSON.stringify({
|
||||
aboveGround,
|
||||
drawn: visible.drawn,
|
||||
belowTerrain: buried,
|
||||
withinOneMetre: unclear,
|
||||
strayChangedPixels: stray,
|
||||
area,
|
||||
}),
|
||||
);
|
||||
if (visible.missing.length)
|
||||
console.log('not drawn', JSON.stringify(visible.missing.slice(0, 10)));
|
||||
if (buried)
|
||||
console.log(
|
||||
'vehicles below the drawn terrain, hidden by the depth test:',
|
||||
buried,
|
||||
);
|
||||
// The checks after each switch run before the incoming globe republishes
|
||||
// the roads; these compare what the population settles to.
|
||||
console.log(
|
||||
'population before the switches',
|
||||
population,
|
||||
'settled after the round trip',
|
||||
settledCount,
|
||||
'after the comparison',
|
||||
finalCount,
|
||||
'below the drawn terrain before the switches',
|
||||
buriedBefore,
|
||||
);
|
||||
for (const count of [settledCount, finalCount])
|
||||
assert.ok(
|
||||
count >= 0.8 * population && count <= 1.25 * population,
|
||||
`the population after the round trip (${count}) stays within 80-125% of the ${population} before it`,
|
||||
);
|
||||
assert.ok(aboveGround >= 12, 'at least 12 vehicles above ground in view');
|
||||
await strict.check(
|
||||
p,
|
||||
'traffic',
|
||||
'after the round trip the population stays at 80-125% and at least 90% of the vehicles above ground in view are drawn where the layer places them',
|
||||
visible.drawn >= 0.9 * aboveGround &&
|
||||
[settledCount, finalCount].every(
|
||||
(count) => count >= 0.8 * population && count <= 1.25 * population,
|
||||
),
|
||||
{ population, settledCount, finalCount, aboveGround, drawn: visible.drawn },
|
||||
);
|
||||
assert.ok(
|
||||
visible.drawn >= 0.9 * aboveGround,
|
||||
'at least 90% of vehicles above the ground are drawn where the layer places them',
|
||||
);
|
||||
// Live mode adds congestion lines along roads; only the simulation must
|
||||
// change nothing but the vehicles.
|
||||
if (keyless)
|
||||
assert.ok(stray < 0.002 * area, 'hiding the layer changes only vehicles');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS kept the traffic module, parameters, flow and vehicle identities across both globes; vehicles in view are drawn where the layer places them',
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,13 @@
|
||||
export function transitQaBase(value) {
|
||||
if (!value) throw new Error('Transit QA requires an explicit QA_BASE_URL');
|
||||
const url = new URL(value);
|
||||
if (
|
||||
['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(url.port)
|
||||
)
|
||||
throw new Error('Transit QA may not use a reserved port');
|
||||
return url.href.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/** Bound page promises as well as CDP itself; labels identify a stalled section. */
|
||||
export function boundPageEvaluations(page, defaultTimeoutMs = 30000) {
|
||||
const evaluate = page.evaluate.bind(page);
|
||||
|
||||
Loaded 100 of 1081 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user