Files
gods-eye-view/scripts/qa-gnss.mjs
T

989 lines
36 KiB
JavaScript

#!/usr/bin/env node
/**
* qa-gnss.mjs — headless proof for the GNSS Integrity layer
* (id `gnss-integrity`): low navigation accuracy reported by ADS-B.
*
* Drives the REAL app in headless Chromium against a dev server (keyless).
* The live section reads adsb.lol through `/api/gnss-integrity` (60 s server
* cache, one upstream read per whole-degree anchor); the other sections
* stub that route in the page so they are deterministic.
*
* (i) LIVE — over Poland and the southern Baltic the layer settles with no error,
* one geometry per cell and legend band counts that sum to the cell
* count; cells are required only when the returned rows, binned
* with the layer's own rules, reach the per-cell minimum (live
* traffic varies by hour). Requires a non-stale response no older than 60 s.
* (ii) BANDS (synthetic) — a 10-aircraft cell with 5 degraded is high,
* a 30-aircraft cell with 2 degraded is medium, a 3-aircraft cell
* with 1 degraded is low (first bad discounted), a clean cell at sea
* is low, a 2-aircraft cell is withheld; `stale: true` reaches
* getStats. Every band is drawn and low is shown over land and sea.
* The row reads as low navigation accuracy, and stats carry the
* machine-readable provenance (GEV classifier, gpsjam bands, window).
* PICK verifies actual perimeter and empty-ground clicks under GNSS.
* CAMERA/GLOBE verify automatic sampling and the screen-centre anchor.
* (iii) FAILURE — the route stubbed to 502: stats.error is set and the
* cells already on the globe are kept.
* (iv) DISABLE — owned ground primitives are hidden.
* (v) KEYBOARD — the row's toggle button, focused, turns the layer on
* with Enter and off with Space, keeps focus and names its state.
* (vi) VIEWPORT — at a 390x844 phone viewport the open row and its legend
* fit the panel width with no horizontal overflow.
* (vii) AGE (synthetic) — a fresh page whose stubbed proxy replays the same
* stale snapshot observed 31 minutes ago draws nothing and reports no
* error; observed 29 minutes ago it draws its cells. The replay keeps
* its observation time instead of restarting the 30-minute window.
*
* Screenshots wait until every cell's ground geometry is built and show the
* open Data Layers row with its legend. Visual proof saved to
* qa-shots/gnss-*.png (gitignored).
*
* Run: node scripts/qa-gnss.mjs --url http://localhost:4173
* (also against a built preview: npm run build && npm run preview,
* then pass the preview URL)
* Optional: --timeout-ms 180000 for a slow boot.
* Exits non-zero on any FAIL. Does not commit anything.
*/
import puppeteer from 'puppeteer';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
accumulateGnssObservations,
binGnssCells,
} from '../src/layers/gnss/records.js';
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:4173');
const HEADFUL = argv.includes('--headful');
const TIMEOUT_MS = Number(getOpt('--timeout-ms', '60000'));
const LAYER_ID = 'gnss-integrity';
const results = [];
function record(name, ok, detail) {
results.push({ name, ok, detail });
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Boot the app, dismiss the intro and place the camera straight down. */
async function boot(page, { lon, lat, height }) {
await page.goto(`${APP_URL}/?welcome=0`, {
waitUntil: 'domcontentloaded',
timeout: TIMEOUT_MS,
});
await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: TIMEOUT_MS },
);
await sleep(5000);
await page.keyboard.press('Escape');
await page.evaluate(
(lo, la, h) => {
const gev = window.__godsEyeView;
const d2r = Math.PI / 180;
// The app's intro flyTo animation clobbers a setView issued mid-flight.
try {
gev.viewer.camera.cancelFlight();
} catch {
/* no flight active */
}
gev.viewer.camera.setView({
destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: lo * d2r,
latitude: la * d2r,
height: h,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
gev.viewer.scene.requestRender?.();
},
lon,
lat,
height,
);
}
/** Enable (or refresh) the layer and poll until it settles. */
async function enableAndSettle(page, { refresh = false } = {}) {
return page.evaluate(
async (id, again) => {
const dm = window.__godsEyeView.dataManager;
const layer = dm.layers.get(id).module;
if (again) await dm.refreshLayer(id);
else await dm.setEnabled(id, true);
const stats = layer.getStats();
const rowControls = layer.getRowControls().legend;
const legend = rowControls.map(({ label, count }) => ({ label, count }));
const blurb = rowControls[0]?.blurb ?? '';
const rowName =
document.querySelector(`[data-layer-id="${id}"] .data-name`)
?.textContent ?? '';
const all = window.__godsEyeView.viewer.scene.groundPrimitives;
const ds = Array.from({ length: all.length }, (_, i) => all.get(i)).find(
(item) => item.id === id,
);
return {
stats,
legend,
blurb,
rowName,
name: layer.name,
entities: ds?.get(0)?.geometryInstances.length ?? 0,
shown: ds?.show ?? null,
};
},
LAYER_ID,
refresh,
);
}
/** Open the Data Layers panel and bring the GNSS row into view. */
async function showLayerRow(page) {
await page.evaluate((id) => {
const panel = document.getElementById('data-panel');
if (panel.classList.contains('collapsed'))
panel.querySelector('[data-collapse-target="data-panel"]').click();
document
.querySelector(`[data-layer-id="${id}"]`)
?.scrollIntoView({ block: 'center' });
}, LAYER_ID);
await sleep(800);
}
/** Actual card pixels on the shared canvas, available in dev and production. */
function paintedOverlayPixels() {
const canvas = document.getElementById('world-overlay-canvas');
if (!canvas) return -1;
const pixels = canvas
.getContext('2d')
.getImageData(0, 0, canvas.width, canvas.height).data;
let count = 0;
for (let i = 3; i < pixels.length; i += 4) if (pixels[i] > 0) count++;
return count;
}
async function shoot(page, name) {
await page
.waitForFunction(
() => window.__godsEyeView.viewer.scene.globe.tilesLoaded,
{ timeout: TIMEOUT_MS },
)
.catch(() => {});
await showLayerRow(page);
await page.screenshot({ path: path.join(SHOTS_DIR, `gnss-${name}.png`) });
}
/** Bounding-sphere state of every GNSS cell: DONE = 0, PENDING = 1, FAILED = 2. */
function cellStates(id) {
const { viewer } = window.__godsEyeView;
const all = viewer.scene.groundPrimitives;
const ds = Array.from({ length: all.length }, (_, i) => all.get(i)).find(
(item) => item.id === id,
);
if (!ds?.length) return [];
return [ds.get(0).ready ? 0 : 1];
}
/** Wait until every GNSS cell's ground rectangle has finished building. */
async function waitForBuiltCells(page, timeoutMs = 60000) {
const built = await page
.waitForFunction(
`(${cellStates})(${JSON.stringify(LAYER_ID)}).every((state) => state === 0)`,
{ timeout: timeoutMs, polling: 250 },
)
.then(
() => true,
() => false,
);
const states = await page.evaluate(
`(${cellStates})(${JSON.stringify(LAYER_ID)})`,
);
return { built, detail: `states=${JSON.stringify(states)}` };
}
const fixtureRow = (hex, lat, lon, degraded) => ({
hex,
lat,
lon,
nic: degraded ? 0 : 8,
nacp: degraded ? 0 : 10,
gpsLost: false,
degraded,
});
/**
* Synthetic 0.5° cells: high at sea (5/10 bad), medium on land (2/30 bad),
* low on land (1/3 bad), low at sea (0/4 bad), withheld (2 aircraft).
*/
function fixturePayload() {
const rows = [];
const hex = (prefix, i) => `${prefix}${i.toString(16).padStart(2, '0')}`;
for (let i = 0; i < 10; i++)
rows.push(fixtureRow(hex('a000', i), 55.1 + i * 0.01, 19.1, i < 5));
for (let i = 0; i < 30; i++)
rows.push(fixtureRow(hex('d000', i), 54.1 + i * 0.01, 19.6, i < 2));
for (let i = 0; i < 3; i++)
rows.push(fixtureRow(hex('b000', i), 54.1 + i * 0.01, 18.1, i === 0));
for (let i = 0; i < 4; i++)
rows.push(fixtureRow(hex('e000', i), 55.1 + i * 0.01, 18.1, false));
for (let i = 0; i < 2; i++)
rows.push(fixtureRow(hex('c000', i), 53.1 + i * 0.01, 17.1, true));
return {
fetchedAt: Date.now(),
anchor: { lat: 55, lon: 19 },
radiusNm: 250,
rows,
stale: true,
};
}
/** Answer /api/gnss-integrity in-page from `fixturePayload()`. */
async function stubRoute(page, { ageMs = 0, initialFailures = 0, stale } = {}) {
await page.evaluateOnNewDocument(
(payload, age, failures, staleOverride) => {
window.__gnssQaAgeMs = age;
window.__gnssQaRequests = [];
window.__gnssQaFailures = failures;
const realFetch = window.fetch.bind(window);
window.__gnssQaMode = 'fixture';
window.fetch = (input, init) => {
const url = typeof input === 'string' ? input : input?.url || '';
if (!url.includes('/api/gnss-integrity')) return realFetch(input, init);
window.__gnssQaRequests.push(url);
if (window.__gnssQaFailures > 0) {
window.__gnssQaFailures--;
return Promise.resolve(
Response.json(
{ error: 'rate_limited' },
{
status: 429,
headers: { 'Retry-After': '5' },
},
),
);
}
const ok = window.__gnssQaMode === 'fixture';
const body = ok
? {
...payload,
fetchedAt: Date.now() - (window.__gnssQaAgeMs || 0),
ageMs: window.__gnssQaAgeMs || 0,
stale: staleOverride ?? payload.stale,
}
: { error: 'gnss_integrity_unavailable' };
return Promise.resolve(
new Response(JSON.stringify(body), {
status: ok ? 200 : 502,
headers: { 'Content-Type': 'application/json' },
}),
);
};
},
fixturePayload(),
ageMs,
initialFailures,
stale,
);
}
/** Toggle button of the GNSS row, as the panel draws it. */
function toggleState(id) {
const button = document.querySelector(
`[data-layer-id="${id}"] .data-toggle-btn`,
);
const layer = window.__godsEyeView.dataManager.layers.get(id);
return {
focused: document.activeElement === button,
label: button?.getAttribute('aria-label') ?? '',
enabled: Boolean(layer?.enabled),
count: layer?.module.getStats().count ?? -1,
};
}
/** Press a key on the focused GNSS toggle and wait for the lifecycle to settle. */
async function pressToggle(page, key, wantEnabled) {
await page.evaluate((id) => {
document.querySelector(`[data-layer-id="${id}"] .data-toggle-btn`)?.focus();
}, LAYER_ID);
await page.keyboard.press(key);
await page
.waitForFunction(
(id, want) => {
const layer = window.__godsEyeView.dataManager.layers.get(id);
return (
Boolean(layer?.enabled) === want &&
(!want || layer.module.getStats().count > 0)
);
},
{ timeout: 30000, polling: 250 },
LAYER_ID,
wantEnabled,
)
.catch(() => {});
return page.evaluate(`(${toggleState})(${JSON.stringify(LAYER_ID)})`);
}
async function main() {
console.log('\nGNSS Integrity proof (qa-gnss)');
console.log(` App URL : ${APP_URL}\n`);
try {
const res = await fetch(APP_URL);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
console.error(
`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`,
);
process.exit(2);
}
fs.mkdirSync(SHOTS_DIR, { recursive: true });
const executablePath =
process.env.PUPPETEER_EXECUTABLE_PATH ||
(await puppeteer.executablePath().catch(() => null)) ||
undefined;
const browser = await puppeteer.launch({
headless: HEADFUL ? false : 'new',
...(executablePath && fs.existsSync(executablePath)
? { executablePath }
: {}),
args: [
'--use-gl=angle',
'--use-angle=swiftshader',
'--disable-dev-shm-usage',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1440,900',
],
});
let exitCode = 0;
const errors = [];
try {
// ── (i) LIVE ──────────────────────────────────────────────────────────
console.log(
'(i) LIVE — adsb.lol integrity snapshot over Poland and the southern Baltic...',
);
const live = await browser.newPage();
await live.setViewport({ width: 1440, height: 900 });
live.on('pageerror', (e) => errors.push(e.message));
const liveBodies = [];
live.on('response', (res) => {
if (res.url().includes('/api/gnss-integrity') && res.ok())
liveBodies.push(res.json());
});
await boot(live, { lon: 19.6, lat: 52.6, height: 1_100_000 });
const l = await enableAndSettle(live);
const legendTotal = l.legend.reduce((sum, { count }) => sum + count, 0);
// Bin what adsb.lol actually returned with the layer's own rules: live
// traffic varies by hour, so cells are required only when some cell
// really reached the minimum aircraft count.
const liveSnapshots = (await Promise.allSettled(liveBodies))
.filter((r) => r.status === 'fulfilled')
.map((r) => r.value);
record(
'LIVE: a successful, dated integrity response was received',
liveSnapshots.length > 0 &&
liveSnapshots.every(
(body) =>
Array.isArray(body.rows) &&
Number.isFinite(body.ageMs) &&
body.ageMs >= 0 &&
body.ageMs <= 60000 &&
body.stale !== true &&
body.classifier === 'gev-nic-nacp-v2',
),
`successful responses=${liveSnapshots.length}`,
);
const liveRows = liveSnapshots.reduce(
(sum, body) => sum + (body.rows?.length || 0),
0,
);
const expectedStore = new Map();
for (const body of liveSnapshots) {
const at = Date.now();
accumulateGnssObservations(expectedStore, body.rows, at, {
observedAt: at - body.ageMs,
});
}
const expectedCells = binGnssCells(expectedStore).length;
record(
'LIVE: no error, one geometry per cell',
!l.stats.error && l.entities === l.stats.count,
`cells=${l.stats.count} entities=${l.entities} error=${JSON.stringify(l.stats.error)}`,
);
if (expectedCells > 0) {
record(
'LIVE: a snapshot with a qualifying cell yields cells',
l.stats.count > 0,
`rows=${liveRows} expected=${expectedCells} cells=${l.stats.count}`,
);
} else {
console.log(
` [info] ${liveRows} live rows, no cell reaches the minimum — cell count not asserted`,
);
}
record(
'LIVE: legend band counts sum to the cell count',
legendTotal === l.stats.count,
JSON.stringify(l.legend),
);
const liveBuilt = await waitForBuiltCells(live);
record(
'LIVE: every cell rectangle is built on a parked camera',
liveBuilt.built,
liveBuilt.detail,
);
await shoot(live, 'live-poland-baltic');
await live.close();
// ── (ii) BANDS ────────────────────────────────────────────────────────
console.log(
'\n(ii) BANDS — synthetic integrity rows through the real layer...',
);
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
page.on('pageerror', (e) => errors.push(e.message));
// Stub the route in-page rather than with request interception: an
// intercepted page stalls Cesium's geometry workers, so ground rectangles
// never finish building and the screenshot would not show real behaviour.
await stubRoute(page);
await boot(page, { lon: 18.6, lat: 54.6, height: 450_000 });
const f = await enableAndSettle(page);
const band = (prefix) =>
f.legend.find(({ label }) => label.startsWith(prefix))?.count;
record(
'BANDS: 4 cells shown, the 2-aircraft cell withheld',
f.stats.count === 4 && f.entities === 4,
`cells=${f.stats.count} entities=${f.entities}`,
);
record(
'BANDS: 5/10 is high, 2/30 is medium, 1/3 and 0/4 are low',
band('Low-accuracy score >') === 1 &&
band('Low-accuracy score 2') === 1 &&
band('Low-accuracy score <') === 2,
JSON.stringify(f.legend),
);
const prov = f.stats.provenance;
record(
'PROVENANCE: GEV classifier, gpsjam bands and visited-view window are machine-readable',
prov?.classifier?.id === 'gev-nic-nacp-v2' &&
prov.classifier.definedBy === 'GEV' &&
prov.classifier.validated === false &&
prov.aggregation?.source === 'https://gpsjam.org/faq' &&
JSON.stringify(prov.aggregation.bands) === '[2,10]' &&
prov.window?.minutes === 30 &&
prov.window.scope === 'visited-view' &&
prov.interpretation?.validated === false,
JSON.stringify(prov?.classifier),
);
record(
'WORDING: the row leads with low navigation accuracy, interference only as the hedged reading',
f.name === 'GNSS Integrity' &&
f.rowName.includes('GNSS Integrity') &&
!/interference/i.test(f.rowName) &&
f.legend.every(({ label }) => label.startsWith('Low-accuracy score')) &&
/one aircraft is discounted/.test(f.blurb) &&
/not a detection/.test(f.blurb),
`name=${JSON.stringify(f.name)}`,
);
record(
'BANDS: stale snapshot is reported',
f.stats.stale === true,
`stale=${f.stats.stale}`,
);
const bandsBuilt = await waitForBuiltCells(page);
record(
'BANDS: every cell rectangle is built on a parked camera',
bandsBuilt.built,
bandsBuilt.detail,
);
await shoot(page, 'synthetic-bands');
// GNSS fills must not own map input, including over a real perimeter.
await page.evaluate(async () => {
const original = window.fetch.bind(window);
window.fetch = (input, init) => {
const url = typeof input === 'string' ? input : input.url;
if (url.includes('/api/fire-perimeters/inciweb'))
return Promise.resolve(Response.json({ rows: [] }));
if (url.includes('/api/fire-perimeters'))
return Promise.resolve(
Response.json({
rows: [
{
stableId: 'gnss-pick-qa',
name: 'GNSS PICK QA FIRE',
containedPct: 0,
acres: 100,
polygons: [
[
[
[18.247, 54.247],
[18.253, 54.247],
[18.253, 54.253],
[18.247, 54.253],
[18.247, 54.247],
],
],
],
},
],
}),
);
return original(input, init);
};
const { viewer, dataManager } = window.__godsEyeView;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (18.25 * Math.PI) / 180,
latitude: (54.25 * Math.PI) / 180,
height: 2500,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
await dataManager.setEnabled('fire-perimeters', true);
viewer.scene.requestRender();
});
await page.waitForFunction(
() => {
const v = window.__godsEyeView.viewer,
p = v.scene.pick({
x: v.scene.canvas.clientWidth / 2,
y: v.scene.canvas.clientHeight / 2,
});
return String(p?.id?.id ?? p?.id ?? '').startsWith(
'fire-perimeter:gnss-pick-qa:',
);
},
{ timeout: TIMEOUT_MS, polling: 250 },
);
const pickProbe = await page.evaluate(() => {
const v = window.__godsEyeView.viewer,
c = v.scene.canvas;
const centre = { x: c.clientWidth / 2, y: c.clientHeight / 2 };
const empty = { x: centre.x + 300, y: centre.y };
const picked = v.scene.pick(empty),
box = c.getBoundingClientRect();
return {
centre: { x: centre.x + box.left, y: centre.y + box.top },
empty: { x: empty.x + box.left, y: empty.y + box.top },
surface: !picked || picked.id === undefined,
height: v.camera.positionCartographic.height,
};
});
record('PICK: a perimeter remains selectable under GNSS', true);
record(
'PICK: street ground remains a surface pick under GNSS',
pickProbe.surface,
);
const beforeCardPixels = await page.evaluate(paintedOverlayPixels);
await page.mouse.click(pickProbe.centre.x, pickProbe.centre.y);
await page.waitForFunction(
`(${paintedOverlayPixels})() > ${beforeCardPixels + 100}`,
{ timeout: 10000, polling: 250 },
);
const selectedCardPixels = await page.evaluate(paintedOverlayPixels);
record(
'PICK: actual perimeter click paints its incident card',
selectedCardPixels > beforeCardPixels + 100,
`${beforeCardPixels} → ${selectedCardPixels} pixels`,
);
await page.screenshot({
path: path.join(SHOTS_DIR, 'gnss-perimeter-selected.png'),
});
await page.mouse.click(pickProbe.empty.x, pickProbe.empty.y);
await page.waitForFunction(
`(${paintedOverlayPixels})() <= ${beforeCardPixels}`,
{ timeout: 10000, polling: 250 },
);
const clearedCardPixels = await page.evaluate(paintedOverlayPixels);
record(
'PICK: empty ground clears the perimeter card',
clearedCardPixels <= beforeCardPixels,
`${selectedCardPixels} → ${clearedCardPixels} pixels`,
);
await page.mouse.click(pickProbe.empty.x, pickProbe.empty.y, {
clickCount: 2,
});
await sleep(1000);
record(
'PICK: empty double-click does not zoom to a GNSS cell',
await page.evaluate(
(height) =>
Math.abs(
window.__godsEyeView.viewer.camera.positionCartographic.height -
height,
) < 200,
pickProbe.height,
),
);
await page.evaluate(async () => {
await window.__godsEyeView.dataManager.setEnabled(
'fire-perimeters',
false,
);
});
console.log('\nCAMERA — automatic regional refresh without toggling...');
const beforeMove = await page.evaluate(
() => window.__gnssQaRequests.length,
);
await page.evaluate(() => {
const { viewer } = window.__godsEyeView;
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (139.7 * Math.PI) / 180,
latitude: (35.7 * Math.PI) / 180,
height: 1_100_000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
viewer.scene.requestRender();
});
await page.waitForFunction(
() => {
const url = window.__gnssQaRequests.at(-1);
return (
url &&
Math.abs(
Number(new URL(url, location.origin).searchParams.get('lon')) -
139.7,
) < 1
);
},
{ timeout: 15000 },
);
await sleep(3500);
const moved = await page.evaluate(
(id) => ({
requests: window.__gnssQaRequests.length,
enabled: window.__godsEyeView.dataManager.isEnabled(id),
coverage: window.__godsEyeView.dataManager.layers
.get(id)
.module.getStats().coverage,
info: document.querySelector(
`[data-layer-id="${id}"] .data-toggle-controls-info`,
)?.textContent,
}),
LAYER_ID,
);
record(
'CAMERA: moving to Tokyo automatically refreshes the new region and updates the row',
moved.enabled &&
moved.requests > beforeMove &&
moved.requests <= beforeMove + 2 &&
moved.coverage?.longitude === 140 &&
/Regional sample.*250 nm radius/.test(moved.info),
JSON.stringify(moved),
);
await shoot(page, 'camera-refresh');
await page.evaluate(() => {
const { viewer } = window.__godsEyeView;
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (12.4 * Math.PI) / 180,
latitude: (41.9 * Math.PI) / 180,
height: 16_000_000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
viewer.scene.requestRender();
});
await page.waitForFunction(
() => {
const url = window.__gnssQaRequests.at(-1);
const params = url && new URL(url, location.origin).searchParams;
return (
params &&
Math.abs(Number(params.get('lon')) - 12.4) < 1 &&
Math.abs(Number(params.get('lat')) - 41.9) < 1
);
},
{ timeout: 15000 },
);
const world = await page.evaluate((id) => {
const { viewer, dataManager } = window.__godsEyeView;
const bounds = viewer.camera.computeViewRectangle(
viewer.scene.globe.ellipsoid,
);
return {
enabled: dataManager.isEnabled(id),
height: viewer.camera.positionCartographic.height,
boundsMidLongitude:
bounds && ((bounds.west + bounds.east) * 90) / Math.PI,
coverage: dataManager.layers.get(id).module.getStats().coverage,
};
}, LAYER_ID);
record(
'GLOBE: zoomed-out sampling stays centred on Rome and exposes its regional radius',
world.enabled &&
world.height > 15_000_000 &&
world.coverage?.longitude === 12 &&
world.coverage?.latitude === 42 &&
world.coverage?.radiusNm === 250,
JSON.stringify(world),
);
await shoot(page, 'globe-regional');
await page.evaluate(() => {
const { viewer } = window.__godsEyeView;
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (18.6 * Math.PI) / 180,
latitude: (54.6 * Math.PI) / 180,
height: 450_000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
viewer.scene.requestRender();
});
await page.waitForFunction(
() => {
const url = window.__gnssQaRequests.at(-1);
return (
url &&
Math.abs(
Number(new URL(url, location.origin).searchParams.get('lon')) -
18.6,
) < 1
);
},
{ timeout: 15000 },
);
await sleep(3500);
// ── (iii) FAILURE ─────────────────────────────────────────────────────
console.log('\n(iii) FAILURE — /api/gnss-integrity stubbed to 502...');
await page.evaluate(() => {
window.__gnssQaMode = 'error';
});
const e = await enableAndSettle(page, { refresh: true });
record(
'FAILURE: error surfaced, cells kept',
Boolean(e.stats.error) && e.stats.count === 4 && e.entities === 4,
`error=${JSON.stringify(e.stats.error)} cells=${e.stats.count}`,
);
await shoot(page, 'failure');
// ── (iv) DISABLE ──────────────────────────────────────────────────────
console.log('\n(iv) DISABLE...');
const shown = await page.evaluate(async (id) => {
await window.__godsEyeView.dataManager.setEnabled(id, false);
const all = window.__godsEyeView.viewer.scene.groundPrimitives;
return Array.from({ length: all.length }, (_, i) => all.get(i)).find(
(item) => item.id === id,
)?.show;
}, LAYER_ID);
record('DISABLE: data source hidden', shown === false, `show=${shown}`);
await shoot(page, 'disabled');
// ── (v) KEYBOARD ──────────────────────────────────────────────────────
console.log('\n(v) KEYBOARD — the row toggle, from the keyboard...');
await page.evaluate(() => {
window.__gnssQaMode = 'fixture';
});
await showLayerRow(page);
const on = await pressToggle(page, 'Enter', true);
record(
'KEYBOARD: Enter on the focused toggle enables the layer and keeps focus',
on.enabled &&
on.focused &&
on.count === 4 &&
/^GNSS Integrity: /.test(on.label) &&
!/OFF$/.test(on.label),
JSON.stringify(on),
);
const off = await pressToggle(page, ' ', false);
record(
'KEYBOARD: Space on the focused toggle disables it and the label says OFF',
!off.enabled && off.focused && /^GNSS Integrity: OFF$/.test(off.label),
JSON.stringify(off),
);
// ── (vi) VIEWPORT ─────────────────────────────────────────────────────
console.log(
'\n(vi) VIEWPORT — the open row on a 390x844 phone viewport...',
);
await page.close();
// A fresh page: switching an open page to isMobile reloads it, which
// would measure a half-booted app instead of the open row.
const phone = await browser.newPage();
await phone.setViewport({ width: 390, height: 844, isMobile: true });
phone.on('pageerror', (e) => errors.push(e.message));
await stubRoute(phone);
await boot(phone, { lon: 18.6, lat: 54.6, height: 450_000 });
await showLayerRow(phone);
await pressToggle(phone, 'Enter', true);
await showLayerRow(phone);
const fit = await phone.evaluate((id) => {
const row = document.querySelector(`[data-layer-id="${id}"]`);
const legend = [...row.querySelectorAll('.data-toggle-legend-item')];
const rect = row.getBoundingClientRect();
return {
width: window.innerWidth,
rowLeft: Math.round(rect.left),
rowRight: Math.round(rect.right),
overflow: row.scrollWidth - row.clientWidth,
legendItems: legend.length,
coverageInfo: row.querySelector('.data-toggle-controls-info')
?.textContent,
legendInside: legend.every((item) => {
const r = item.getBoundingClientRect();
return (
r.width > 0 && r.left >= rect.left - 1 && r.right <= rect.right + 1
);
}),
};
}, LAYER_ID);
record(
'VIEWPORT: row and legend fit a 390 px viewport without horizontal overflow',
fit.rowLeft >= 0 &&
fit.rowRight <= fit.width &&
fit.overflow <= 0 &&
fit.legendItems === 3 &&
/Regional sample.*250 nm radius/.test(fit.coverageInfo) &&
fit.legendInside,
JSON.stringify(fit),
);
await phone.screenshot({ path: path.join(SHOTS_DIR, 'gnss-phone.png') });
await phone.close();
console.log(
'\nSTARTUP — initial rate limit stays enabled and recovers automatically...',
);
const retry = await browser.newPage();
await retry.setViewport({ width: 1440, height: 900 });
retry.on('pageerror', (e) => errors.push(e.message));
// Pages share the disposable test context's storage. Start this one cold,
// so a previous fixture cannot auto-enable GNSS before the START test.
await retry.evaluateOnNewDocument(() => localStorage.clear());
await stubRoute(retry, { initialFailures: 1, stale: false });
await boot(retry, { lon: 18.6, lat: 54.6, height: 450_000 });
await showLayerRow(retry);
await retry.evaluate((id) => {
window.__gnssQaFailedStarts = 0;
window.__godsEyeView.dataManager.subscribe((change) => {
if (change.layerId === id && change.type === 'visibility-failed')
window.__gnssQaFailedStarts++;
});
}, LAYER_ID);
const initial = await enableAndSettle(retry);
const limited = await retry.evaluate((id) => {
const dm = window.__godsEyeView.dataManager;
const row = document.querySelector(`[data-layer-id="${id}"]`);
return {
enabled: dm.isEnabled(id),
failedStarts: window.__gnssQaFailedStarts,
label: row.querySelector('.data-toggle-btn').textContent,
info: row.querySelector('.data-toggle-controls-info').textContent,
};
}, LAYER_ID);
record(
'STARTUP: first 429 keeps the layer enabled and shows automatic retry timing',
limited.enabled &&
limited.failedStarts === 0 &&
limited.label === 'UNAVAILABLE' &&
initial.stats.count === 0 &&
initial.stats.retryInSec > 0 &&
/rate limited.*Retrying automatically at/.test(limited.info),
JSON.stringify(limited),
);
await retry.screenshot({
path: path.join(SHOTS_DIR, 'gnss-startup-retrying.png'),
});
await retry.waitForFunction(
(id) => {
const layer = window.__godsEyeView.dataManager.layers.get(id);
return (
layer.enabled &&
layer.module.getStats().error === null &&
layer.module.getStats().count === 4
);
},
{ timeout: 20000 },
LAYER_ID,
);
const recovered = await retry.evaluate(
(id) => ({
requests: window.__gnssQaRequests.length,
enabled: window.__godsEyeView.dataManager.isEnabled(id),
label: document.querySelector(
`[data-layer-id="${id}"] .data-toggle-btn`,
).textContent,
info: document.querySelector(
`[data-layer-id="${id}"] .data-toggle-controls-info`,
).textContent,
}),
LAYER_ID,
);
record(
'STARTUP: one automatic retry recovers to ON without toggling and clears the warning',
recovered.enabled &&
recovered.requests === 2 &&
recovered.label === 'ON' &&
!/Retrying/.test(recovered.info),
JSON.stringify(recovered),
);
await shoot(retry, 'startup-recovered');
await retry.close();
// ── (vii) AGE ─────────────────────────────────────────────────────────
console.log(
'\n(vii) AGE — a replayed stale snapshot keeps its observation time...',
);
const aged = await browser.newPage();
await aged.setViewport({ width: 1440, height: 900 });
aged.on('pageerror', (e) => errors.push(e.message));
await stubRoute(aged, { ageMs: 31 * 60_000 });
await boot(aged, { lon: 18.6, lat: 54.6, height: 450_000 });
const old = await enableAndSettle(aged, {});
record(
'AGE: a snapshot observed 31 min ago adds no cells and is not an error',
old.stats.count === 0 && old.entities === 0 && !old.stats.error,
`cells=${old.stats.count} entities=${old.entities} error=${JSON.stringify(old.stats.error)}`,
);
await aged.evaluate(() => {
window.__gnssQaAgeMs = 29 * 60_000;
});
const recent = await enableAndSettle(aged, { refresh: true });
record(
'AGE: the same snapshot observed 29 min ago is still inside the window',
recent.stats.count === 4 && recent.entities === 4,
`cells=${recent.stats.count} entities=${recent.entities}`,
);
await aged.close();
record(
'no uncaught browser errors',
errors.length === 0,
errors.slice(0, 3).join(' | '),
);
} catch (err) {
console.error('\x1b[31mHarness error:\x1b[0m', err);
exitCode = 3;
} finally {
await browser.close();
}
const pass = results.filter((r) => r.ok).length;
const fail = results.length - pass;
console.log('\n' + '─'.repeat(60));
console.log(` RESULT: ${pass} passed, ${fail} failed`);
console.log(` Shots : ${SHOTS_DIR}/gnss-*.png`);
console.log('─'.repeat(60) + '\n');
process.exit(exitCode || (fail > 0 ? 1 : 0));
}
main().catch((e) => {
console.error(e);
process.exit(3);
});