diff --git a/scripts/qa-all-layer-switches.mjs b/scripts/qa-all-layer-switches.mjs index 5713000ad..e0bbbd204 100644 --- a/scripts/qa-all-layer-switches.mjs +++ b/scripts/qa-all-layer-switches.mjs @@ -286,6 +286,125 @@ const soakRuns = quick const longRun = quick ? '--swaps=100' : '--swaps=500'; // label, script, layers, extra arguments, timeout in seconds. const runs = [ + [ + 'switch-equals-cold-start--aircraft', + 'switch-equals-cold-start', + ['flights', 'military'], + ['--group=aircraft'], + ], + [ + 'switch-equals-cold-start--local', + 'switch-equals-cold-start', + ['local-adsb', 'local-dams', 'local-datacenters'], + ['--group=local'], + ], + [ + 'switch-equals-cold-start--ground', + 'switch-equals-cold-start', + ['ais-live-vessels', 'air-quality', 'gnss-integrity', 'fire-perimeters'], + ['--group=ground'], + ], + [ + 'switch-equals-cold-start--contacts', + 'switch-equals-cold-start', + ['military-awareness', 'military-installations'], + ['--group=contacts'], + ], + [ + 'switch-equals-cold-start--open', + 'switch-equals-cold-start', + ['drone-airspace', 'osm-places', 'sea-marks', 'ski-pistes'], + ['--group=open'], + ], + [ + 'switch-equals-cold-start--weather', + 'switch-equals-cold-start', + [ + 'weather-radar', + 'weather-satellite', + 'weather-lightning', + 'weather-cyclones', + ], + ['--group=weather'], + ], + [ + 'switch-equals-cold-start--wind', + 'switch-equals-cold-start', + ['wind'], + ['--group=wind'], + ], + [ + 'switch-equals-cold-start--ghostmaps', + 'switch-equals-cold-start', + ['s2-ghostmaps'], + ['--group=ghostmaps'], + ], + [ + 'switch-equals-cold-start--wildlife', + 'switch-equals-cold-start', + ['wildlife-sightings'], + ['--group=wildlife'], + ], + [ + 'switch-equals-cold-start--urban', + 'switch-equals-cold-start', + ['bikeshare', 'transit', 'earthquakes'], + ['--group=urban'], + ], + [ + 'switch-equals-cold-start--cameras', + 'switch-equals-cold-start', + ['cctv', 'alpr-cameras'], + ['--group=cameras'], + ], + [ + 'switch-equals-cold-start--radio', + 'switch-equals-cold-start', + ['radio'], + ['--group=radio'], + ], + [ + 'switch-equals-cold-start--space', + 'switch-equals-cold-start', + ['satellites', 'rocket-launches'], + ['--group=space'], + ], + [ + 'switch-equals-cold-start--traffic', + 'switch-equals-cold-start', + ['traffic'], + ['--group=traffic'], + ], + [ + 'switch-equals-cold-start--street', + 'switch-equals-cold-start', + ['street-level'], + ['--group=street'], + ], + [ + 'switch-equals-cold-start--imagery', + 'switch-equals-cold-start', + ['recent-imagery'], + ['--group=imagery'], + ], + [ + 'switch-equals-cold-start--nepal', + 'switch-equals-cold-start', + ['bhote-koshi-locator', 'bhote-koshi-2026'], + ['--group=nepal'], + ], + [ + 'switch-equals-cold-start--cables', + 'switch-equals-cold-start', + ['telegeography-submarine-cables', 'directions'], + ['--group=cables'], + ], + [ + 'switch-equals-cold-start--fires', + 'switch-equals-cold-start', + ['local-firms'], + ['--group=fires'], + ], ['engine-switch', 'engine-switch', ['earthquakes']], ['director-engine-switch', 'director-engine-switch', []], ['layer-handoff', 'layer-handoff', ['flights']], diff --git a/scripts/qa-switch-equals-cold-start.mjs b/scripts/qa-switch-equals-cold-start.mjs new file mode 100644 index 000000000..24414a4f7 --- /dev/null +++ b/scripts/qa-switch-equals-cold-start.mjs @@ -0,0 +1,363 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { + createStrict, + localPreview, + openPage, + waitForEngine, +} from './qa-strict-harness.mjs'; +import { whenSettled } from './qa/ready.mjs'; +import { drawLedger } from './qa/ledger.mjs'; +import { + COLD_START_GROUPS, + coldStartUrl, + compareColdStart, + probeSnapshot, + sessionExclusion, +} from './qa/coldStart.mjs'; +import { coldStartFixtures } from './qa/coldStartFixtures.mjs'; +import { stations } from './fixtures/layers/radio.mjs'; + +// Each registered group compares both directions, with a fresh storage context +// for every cold page. The URL writer and restore path supply the reference. +// node scripts/qa-switch-equals-cold-start.mjs http://127.0.0.1:4309 +// --group=ground --evidence=tmp_switch_cold_ground.json +const base = localPreview(process.argv[2]); +const group = process.argv.find((arg) => arg.startsWith('--group='))?.slice(8); +assert.ok( + COLD_START_GROUPS[group], + `Supply --group=${Object.keys(COLD_START_GROUPS).join('|')}`, +); +const { layers, view } = COLD_START_GROUPS[group]; +const fixture = await coldStartFixtures(base.origin, group); +const allow = JSON.parse( + readFileSync(new URL('./qa-ledger-allow.json', import.meta.url), 'utf8'), +); +const strict = createStrict({ + origin: base.origin, + layers, + routes: fixture.routes, + fixtures: fixture.fixtures, + name: `switch-equals-cold-start--${group}`, + time: new Date(fixture.epoch).toISOString(), +}); +const evidence = (strict.extra.coldStart = { + group, + layers, + samples: [], + differences: [], + skipped: [], +}); + +async function settle(page, cold = false) { + await whenSettled(page, { timeoutMs: 30000 }); + await page.waitForFunction( + (layers, cold) => { + const { dataManager, engine } = window.__godsEyeView; + engine.render.request('qa-cold-start-readiness'); + return layers.every((id) => { + const entry = dataManager.layers.get(id), + module = entry?.module; + if (!entry) return false; + if (cold && module.sessionState?.includes('enabled')) + return !entry.enabled; + if ( + !entry.enabled || + !entry.initialized || + entry.sceneDetached || + entry.refreshing + ) + return false; + const stats = module.getStats?.() ?? {}; + if (stats.loading || stats.refreshing) return false; + // These layers can be enabled with no search, route or selected subject. + if ( + [ + 'directions', + 'military-awareness', + 'osm-places', + 'sea-marks', + 'ski-pistes', + ].includes(id) + ) + return true; + return stats.count > 0; + }); + }, + { timeout: 30000, polling: 100 }, + layers, + cold, + ); + await whenSettled(page, { frames: 5, timeoutMs: 30000 }); +} + +async function prepare(page, owners) { + for (const id of layers) { + const before = await drawLedger(page); + assert.equal( + await page.evaluate(async (id) => { + const manager = window.__godsEyeView.dataManager; + return manager.setEnabled(id, true, { origin: 'user' }); + }, id), + true, + `${id} enabled`, + ); + for (const row of await drawLedger(page)) { + if (!before.some((old) => old.family === row.family && old.id === row.id)) + owners[`${row.family}:${row.id}`] = id; + } + } + await page.evaluate( + async (group, stationId) => { + const manager = window.__godsEyeView.dataManager; + const module = (id) => manager.layers.get(id).module; + if (group === 'open') + module('osm-places').setResults( + [ + { + id: 'node/1', + name: 'Fixture Clinic', + lat: 38.9, + lon: -77.03, + tags: {}, + }, + { + id: 'node/2', + name: 'Fixture Hospital', + lat: 38.905, + lon: -77.02, + tags: {}, + }, + ], + { kind: 'clinic', label: 'Clinics', area: 'Washington' }, + ); + if (group === 'street') + manager.setLayerParams( + 'street-level', + { kartaview: true, mapillary: false }, + { origin: 'user' }, + ); + if (group === 'wind') + await manager.setLayerParams( + 'wind', + { paused: true, overlay: 'temperature', units: 'm/s' }, + { origin: 'user' }, + ); + if (group === 'radio') { + module('radio').selectStation(stationId, { + autoplay: true, + focus: false, + origin: 'user', + }); + await manager.setLayerParams( + 'radio', + { volume: 0.36 }, + { origin: 'user' }, + ); + } + if (group === 'imagery') + module('recent-imagery').setBox({ + west: -97.77, + east: -97.71, + south: 30.25, + north: 30.29, + }); + if (group === 'cables') { + module('directions').placeEndpoint('a', { lat: 55.32, lon: 10.78 }); + module('directions').placeEndpoint('b', { lat: 55.33, lon: 10.82 }); + } + }, + group, + stations[10].id, + ); + await settle(page); + if (group === 'cables') + await page.waitForFunction( + () => + window.__godsEyeView.dataManager.layers + .get('directions') + .module.getStats().count > 0, + { timeout: 30000 }, + ); + if (group === 'wind') + await page.evaluate(() => + window.__godsEyeView.dataManager.layers + .get('wind') + .module.setParams({ inspect: true }), + ); + if (group === 'imagery') { + await page.waitForFunction( + () => + window.__godsEyeView.dataManager.layers + .get('recent-imagery') + .module.getSnapshot() + .candidates.some( + (row) => + row.product === 'S30' && row.thumbnail?.status === 'present', + ), + { timeout: 30000 }, + ); + await page.evaluate(() => { + const module = + window.__godsEyeView.dataManager.layers.get('recent-imagery').module; + const row = module + .getSnapshot() + .candidates.find( + (row) => row.product === 'S30' && row.thumbnail?.status === 'present', + ); + module.setAssignment('a', row.key); + module.setMode('image'); + }); + } + await settle(page); + // Asynchronous feed completion can create registries after enable resolves. + for (const row of await drawLedger(page)) { + const owner = layers.find((id) => row.id.startsWith(id)); + if (owner) owners[`${row.family}:${row.id}`] ??= owner; + } +} + +await strict.run({}, async (browser) => { + for (const [from, target] of [ + ['cesium', 'navara'], + ['navara', 'cesium'], + ]) { + const direction = `${from} -> ${target}`; + const sample = { direction, owners: {} }; + evidence.samples.push(sample); + let context, page; + const close = async () => { + const unexpected = await page + ?.evaluate(() => window.__coldStartUnexpectedApi ?? []) + .catch(() => []); + for (const path of unexpected ?? []) + evidence.differences.push({ + layer: '[page]', + direction, + family: 'fixture', + id: path, + field: 'unstubbed API', + switchValue: 'blocked', + coldStartValue: 'fixture required', + }); + await page?.evaluate(() => window.__qaFinishMonitors?.()).catch(() => {}); + await context?.close(); + context = page = null; + }; + const open = async (url) => { + context = await browser.createBrowserContext(); + const opened = await openPage(context, { shaderErrors: true }); + page = opened.page; + sample.errors ??= []; + sample.errors.push(opened.errors); + await page.evaluateOnNewDocument(() => { + if (localStorage.length || sessionStorage.length) + throw new Error('Cold-start storage is not empty'); + }); + await fixture.install(page); + await page.goto(url, { waitUntil: 'domcontentloaded' }); + return opened; + }; + try { + const url = new URL(base.origin); + url.search = `?engine=${from}&welcome=0&look=cinematic`; + const [lat, lon, alt] = view; + url.hash = `v=2&lat=${lat}&lon=${group === 'street' ? fixture.streetLongitude : lon}&alt=${alt}&heading=0&pitch=-90&roll=0&map=osm&l=`; + await open(url.href); + await waitForEngine(page, from, { timeout: 60000 }); + await prepare(page, sample.owners); + sample.before = await probeSnapshot(page, layers); + await strict.switchTo(page, target); + await settle(page); + sample.switched = await probeSnapshot(page, layers); + sample.url = await page.evaluate(() => { + window.__godsEyeView.styleManager.shareLinkManager.flush(); + return location.href; + }); + const retention = compareColdStart(sample.before, sample.switched, { + direction, + owners: sample.owners, + sessionState: false, + }); + sample.retention = retention.differences.filter((row) => + sessionExclusion(row, sample.before), + ); + evidence.differences.push( + ...sample.retention.map((row) => ({ + ...row, + family: `handoff:${row.family}`, + })), + ); + await close(); + sample.coldUrl = coldStartUrl(sample.url, target); + await open(sample.coldUrl); + await waitForEngine(page, target, { timeout: 60000 }); + await settle(page, true); + sample.cold = await probeSnapshot(page, layers); + sample.comparison = compareColdStart(sample.switched, sample.cold, { + direction, + owners: sample.owners, + allow, + }); + evidence.differences.push(...sample.comparison.differences); + evidence.skipped.push(...sample.comparison.skipped); + for (const id of layers) { + if ( + ![...sample.retention, ...sample.comparison.differences].some( + (row) => row.layer === id || row.layer === '[page]', + ) + ) + strict.record( + id, + target, + 'engine switch equals a fresh share-link restore; declared session fields survive the switch', + ); + } + for (const error of sample.errors.flat()) + evidence.differences.push({ + layer: '[page]', + direction, + family: 'error', + id: '', + field: 'pageerror', + switchValue: error, + coldStartValue: null, + }); + } catch (error) { + sample.error = String(error.stack ?? error); + sample.failureSnapshot = await probeSnapshot(page, layers).catch( + () => null, + ); + evidence.differences.push({ + layer: layers.join(', '), + direction, + family: 'readiness', + id: '', + field: 'error', + switchValue: error.message, + coldStartValue: '', + }); + } finally { + await close(); + } + } + console.log( + `switch-equals-cold-start ${group}: ${evidence.differences.length} differences, ${evidence.skipped.length} declared session differences`, + ); + if (evidence.differences.length) + console.table( + evidence.differences.map((row) => ({ + ...row, + switchValue: JSON.stringify(row.switchValue), + coldStartValue: JSON.stringify(row.coldStartValue), + })), + ); + assert.equal( + evidence.differences.length, + 0, + 'See coldStart.differences in the evidence file', + ); + console.log( + `PASS switch-equals-cold-start ${group}: 2 directions, ${layers.length} layers, 0 differences`, + ); +}); diff --git a/scripts/qa/coldStart.mjs b/scripts/qa/coldStart.mjs new file mode 100644 index 000000000..df02f1ed0 --- /dev/null +++ b/scripts/qa/coldStart.mjs @@ -0,0 +1,293 @@ +import { createQaProbe, createSettledState } from '../../src/app/qaProbe.js'; +import { compareLedgers, drawLedger } from './ledger.mjs'; + +// Each group needs four page starts and two picker switches within 300 seconds. +export const COLD_START_GROUPS = { + aircraft: { + layers: ['flights', 'military'], + view: [40.2539, -125.5779, 21130], + }, + local: { + layers: ['local-adsb', 'local-dams', 'local-datacenters'], + view: [41.534, -90.507, 20000], + }, + ground: { + layers: [ + 'ais-live-vessels', + 'air-quality', + 'gnss-integrity', + 'fire-perimeters', + ], + view: [43.65, -79.38, 46000], + }, + contacts: { + layers: ['military-awareness', 'military-installations'], + view: [43.65, -79.38, 46000], + }, + open: { + layers: ['drone-airspace', 'osm-places', 'sea-marks', 'ski-pistes'], + view: [38.9, -77.03, 30000], + }, + weather: { + layers: [ + 'weather-radar', + 'weather-satellite', + 'weather-lightning', + 'weather-cyclones', + ], + view: [43.65, -79.38, 46000], + }, + wind: { layers: ['wind'], view: [43.65, -79.38, 2000000] }, + ghostmaps: { layers: ['s2-ghostmaps'], view: [43.65, -79.38, 46000] }, + wildlife: { layers: ['wildlife-sightings'], view: [48.4, -123.1, 200000] }, + urban: { + layers: ['bikeshare', 'transit', 'earthquakes'], + view: [42.3554, -71.0605, 6000], + }, + cameras: { layers: ['cctv', 'alpr-cameras'], view: [30.262, -97.7431, 5500] }, + radio: { layers: ['radio'], view: [30.27, -97.74, 12000] }, + space: { layers: ['satellites', 'rocket-launches'], view: [5, 5, 18000000] }, + traffic: { layers: ['traffic'], view: [30.267, -97.744, 1800] }, + street: { layers: ['street-level'], view: [38.5831, -121.504, 850] }, + imagery: { layers: ['recent-imagery'], view: [30.27, -97.74, 12000] }, + nepal: { + layers: ['bhote-koshi-locator', 'bhote-koshi-2026'], + view: [28.3326, 85.4848, 18000], + }, + cables: { + layers: ['telegeography-submarine-cables', 'directions'], + view: [55.3241, 10.7995, 25000], + }, + fires: { layers: ['local-firms'], view: [37.77, -122.42, 100000] }, +}; + +/** Change only the engine query of the address written by the application. */ +export function coldStartUrl(href, engine) { + const url = new URL(href); + url.searchParams.set('engine', engine); + return url.href; +} + +// Share addresses round the camera to 0.0001 degrees and whole metres/degrees. +// World positions must still match within a metre; projected positions allow +// two CSS pixels for that camera quantization. Counts and identities are exact. +export function numericTolerance(field) { + if (/(?:^|\.)screen\.[xy]$/.test(field)) return 2; + if (/(?:^|\.)position\.[xyz]$/.test(field)) return 1; + return 1e-6; +} + +/** Compare JSON without losing absent fields, nulls or identities in arrays. */ +export function compareProbe(before, after, { skip = [], field = '' } = {}) { + const differences = []; + const visit = (left, right, path) => { + if (skip.includes(path)) return; + if (Object.is(left, right)) return; + if (typeof left === 'number' && typeof right === 'number') { + const tolerance = /(?:count|capacity|length|id)$/.test(path) + ? 0 + : numericTolerance(path); + if ( + Number.isFinite(left) && + Number.isFinite(right) && + Math.abs(left - right) <= tolerance + ) + return; + } else if ( + left && + right && + typeof left === 'object' && + typeof right === 'object' && + Array.isArray(left) === Array.isArray(right) + ) { + if (Array.isArray(left)) { + if (left.length !== right.length) + visit(left.length, right.length, `${path}.length`); + const identified = [...left, ...right].every( + (row) => row && typeof row === 'object' && row.id !== undefined, + ); + if (identified) { + const a = new Map(left.map((row) => [String(row.id), row])); + const b = new Map(right.map((row) => [String(row.id), row])); + if (a.size !== left.length || b.size !== right.length) + differences.push({ + field: path, + switchValue: left, + coldStartValue: right, + error: 'duplicate ids', + }); + for (const id of new Set([...a.keys(), ...b.keys()])) + visit(a.get(id), b.get(id), `${path}[${JSON.stringify(id)}]`); + } else + for (let i = 0; i < Math.max(left.length, right.length); i++) + visit(left[i], right[i], `${path}[${i}]`); + } else + for (const key of new Set([ + ...Object.keys(left), + ...Object.keys(right), + ])) + visit(left[key], right[key], path ? `${path}.${key}` : key); + return; + } + differences.push({ + field: path, + switchValue: left === undefined ? '' : left, + coldStartValue: right === undefined ? '' : right, + }); + }; + visit(before, after, field); + return differences; +} + +/** The production build drops its dev probe; inject the same implementation. */ +export async function probeSnapshot(page, layers) { + const probe = await page.evaluate(`(() => { + const plain = (p) => p ? { x: p.x, y: p.y, z: p.z } : null; + const createSettledState = ${createSettledState}; + const probe = (${createQaProbe})(window.__godsEyeView); + const layers = ${JSON.stringify(layers)}; + try { + return { + layers: Object.fromEntries(layers.map((id) => { + const module = window.__godsEyeView.dataManager.layers.get(id).module; + const session = id === 'radio' + ? { selected: module.getUIState().selected, audioState: module.getUIState().audioState } + : id === 'wind' ? { reading: module.getRowControls().summary.reading } + : id === 'osm-places' ? { results: module.getAnalystRecords(1000000) } + : id === 'directions' ? { route: module.getRowControls().list } : {}; + return [id, { + enabled: window.__godsEyeView.dataManager.layers.get(id).enabled, + objects: probe.objects(id, 1000000), selected: probe.selected(id), + session, sessionState: module.sessionState ?? [], + }]; + })), + following: probe.following(), movers: probe.movers(), + }; + } finally { probe.destroy(); } + })()`); + return { ...probe, ledger: await drawLedger(page) }; +} + +/** Keep raw samples and declared exclusions beside the complete difference list. */ +export function compareColdStart( + switched, + cold, + { direction, owners = {}, allow = [], sessionState = true }, +) { + const differences = [], + skipped = []; + const add = (layer, family, id, rows) => + differences.push( + ...rows.map((row) => ({ layer, direction, family, id, ...row })), + ); + const layerOf = (family, id) => owners[`${family}:${id}`] ?? '[page]'; + const ledger = compareLedgers(switched.ledger, cold.ledger, allow); + for (const [side, rows] of [ + ['switch', ledger.onlyBefore], + ['cold', ledger.onlyAfter], + ]) + for (const { family, id } of rows) + add(layerOf(family, id), family, id, [ + { + field: 'present', + switchValue: side === 'switch', + coldStartValue: side === 'cold', + }, + ]); + for (const { family, id, before, after } of ledger.shown) + add(layerOf(family, id), family, id, [ + { field: 'shown', switchValue: before, coldStartValue: after }, + ]); + for (const row of switched.ledger) { + const next = cold.ledger.find( + (other) => other.family === row.family && other.id === row.id, + ); + if (next) + add( + layerOf(row.family, row.id), + row.family, + row.id, + compareProbe( + { kind: row.kind, count: row.count }, + { kind: next.kind, count: next.count }, + ), + ); + } + for (const layer of new Set([ + ...Object.keys(switched.layers), + ...Object.keys(cold.layers), + ])) { + const before = switched.layers[layer], + after = cold.layers[layer]; + for (const row of compareProbe(before, after)) { + const id = /^objects\[("(?:[^"\\]|\\.)*")\]/.exec(row.field)?.[1]; + add(layer, 'probe', id ? JSON.parse(id) : layer, [row]); + } + } + add( + '[page]', + 'probe', + 'following', + compareProbe(switched.following, cold.following, { field: 'following' }), + ); + // Keep all mover registry fields, including capacity, beyond the draw ledger. + const a = new Map(switched.movers.map((row) => [String(row.id), row])); + const b = new Map(cold.movers.map((row) => [String(row.id), row])); + for (const id of new Set([...a.keys(), ...b.keys()])) + add( + layerOf('movers', id), + 'probe-movers', + id, + compareProbe(a.get(id), b.get(id)), + ); + return { + differences: differences.filter((row) => { + const field = sessionState && sessionExclusion(row, switched); + if (!field) return true; + skipped.push({ ...row, sessionState: field }); + return false; + }), + skipped, + ledger, + }; +} + +/** Only declared fields and their drawn derivatives can differ from a link. */ +export function sessionExclusion(row, snapshot) { + const declared = snapshot.layers[row.layer]?.sessionState ?? []; + for (const field of declared) { + if ( + row.family === 'probe' && + (row.field === `session.${field}` || + row.field.startsWith(`session.${field}.`) || + row.field.startsWith(`session.${field}[`)) + ) + return field; + // Session-only layers are absent from the link. Their drawing must still + // survive the switch; the scenario checks these same rows against S first. + if ( + field === 'enabled' && + (row.family !== 'probe' || + /^(enabled|objects|selected)(\.|\[|$)/.test(row.field)) + ) + return field; + if ( + row.layer === 'radio' && + field === 'selected' && + ['radio-selected', 'radio-bracket'].includes(row.id) + ) + return field; + if ( + row.layer === 'directions' && + field === 'route' && + [ + 'directions-route', + 'directions-steps', + 'directions-markers', + 'directions-marker-letters', + ].includes(row.id) + ) + return field; + } + return null; +} diff --git a/scripts/qa/coldStart.test.mjs b/scripts/qa/coldStart.test.mjs new file mode 100644 index 000000000..5ea274f69 --- /dev/null +++ b/scripts/qa/coldStart.test.mjs @@ -0,0 +1,258 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import { createFakeEngine } from '../../src/globe/testing/fakeEngine.js'; +import { APPLICATION_LAYER_DIRECTORY } from '../../src/app/layers/directory.js'; +import { + COLD_START_GROUPS, + coldStartUrl, + compareProbe, + compareColdStart, + sessionExclusion, + probeSnapshot, +} from './coldStart.mjs'; + +const snapshot = () => ({ + layers: { + radio: { + enabled: true, + objects: [], + selected: null, + session: { selected: null, audioState: 'idle' }, + sessionState: ['selected', 'audioState'], + }, + }, + ledger: [ + { + family: 'movers', + id: 'radio-selected', + shown: true, + count: 1, + kind: 'point', + }, + ], + movers: [{ id: 'radio-selected', count: 1, kind: 'point', capacity: 1 }], + following: null, +}); +const options = { + direction: 'cesium -> navara', + owners: { 'movers:radio-selected': 'radio' }, +}; + +test('groups cover every strict catalog layer once and are registered with flags', () => { + const ids = Object.values(COLD_START_GROUPS).flatMap((group) => group.layers); + assert.deepEqual( + ids.sort(), + APPLICATION_LAYER_DIRECTORY.map(({ id }) => id).sort(), + ); + assert.ok( + Object.values(COLD_START_GROUPS).every(({ layers }) => layers.length <= 4), + ); + const runner = readFileSync( + new URL('../qa-all-layer-switches.mjs', import.meta.url), + 'utf8', + ); + const registered = JSON.parse( + JSON.stringify( + vm.runInNewContext(runner.match(/const runs = (\[[\s\S]*?\n\]);/)[1], { + soakRuns: [], + longRun: '', + }), + ), + ); + for (const [group, { layers }] of Object.entries(COLD_START_GROUPS)) { + assert.deepEqual( + registered.find( + ([label]) => label === `switch-equals-cold-start--${group}`, + ), + [ + `switch-equals-cold-start--${group}`, + 'switch-equals-cold-start', + layers, + [`--group=${group}`], + ], + ); + } +}); + +test('cold URL preserves the app-written hash, path and other query settings', () => { + const from = + 'http://127.0.0.1:4309/share/ABC?engine=cesium&lang=de#v=2&l=f&lo=f.t.71be26'; + const result = new URL(coldStartUrl(from, 'navara')); + assert.equal(result.pathname, '/share/ABC'); + assert.equal(result.hash, new URL(from).hash); + assert.equal(result.searchParams.get('engine'), 'navara'); + assert.equal(result.searchParams.get('lang'), 'de'); +}); + +test('probe comparison tolerates camera quantization but rejects lost objects and count changes', () => { + const a = { + objects: [ + { + id: 'a', + position: { x: 6378000, y: 0, z: 0 }, + screen: { x: 500, y: 400 }, + }, + ], + }; + const b = structuredClone(a); + b.objects[0].position.x += 0.5; + b.objects[0].screen.x += 1.5; + assert.deepEqual(compareProbe(a, b), []); + b.objects[0].position.x += 1; + assert.equal(compareProbe(a, b)[0].field, 'objects["a"].position.x'); + assert.ok( + compareProbe(a, { objects: [] }).some( + ({ field }) => field === 'objects["a"]', + ), + ); + assert.equal(compareProbe({ count: 1 }, { count: 1.0000001 }).length, 1); + assert.equal(compareProbe({ selected: null }, {}).length, 1); + assert.equal( + compareProbe({ position: { x: NaN } }, { position: { x: 0 } }).length, + 1, + ); +}); + +test('probe arrays compare identities without hiding duplicate ids or reordered paths', () => { + assert.deepEqual( + compareProbe([{ id: 'b' }, { id: 'a' }], [{ id: 'a' }, { id: 'b' }]), + [], + ); + assert.ok( + compareProbe([{ id: 'a' }, { id: 'a' }], [{ id: 'a' }, { id: 'a' }]).some( + (row) => row.error === 'duplicate ids', + ), + ); + assert.equal(compareProbe([1, 2], [2, 1]).length, 2); + assert.equal( + compareProbe( + { selected: { id: 'a' }, objects: [] }, + { selected: null, objects: [1] }, + { skip: ['selected'] }, + ).length, + 2, + ); +}); + +test('comparison returns every ledger family and field difference, with attribution', () => { + const a = snapshot(), + b = snapshot(); + b.ledger[0].count = 2; + b.ledger[0].shown = false; + b.ledger.push({ family: 'lines', id: 'extra', shown: true }); + b.layers.radio.objects = [{ id: 'new' }]; + b.following = { label: 'lost', native: null }; + const result = compareColdStart(a, b, { ...options, sessionState: false }); + assert.ok( + result.differences.some( + (row) => row.field === 'count' && row.layer === 'radio', + ), + ); + assert.ok(result.differences.some((row) => row.field === 'shown')); + assert.ok( + result.differences.some( + (row) => row.family === 'lines' && row.id === 'extra', + ), + ); + assert.ok(result.differences.some((row) => row.id === 'following')); + assert.ok( + result.differences.some( + (row) => row.field === 'objects["new"]' && row.id === 'new', + ), + ); + assert.equal(result.differences.length, 6); +}); + +test('declared session fields do not hide other probe fields or other layers', () => { + const a = snapshot(), + b = snapshot(); + a.layers.radio.session.selected = { id: 'station' }; + a.layers.radio.session.audioState = 'playing'; + b.movers[0].count = b.ledger[0].count = 0; + b.layers.radio.enabled = false; + const result = compareColdStart(a, b, options); + assert.equal(result.skipped.length, 4); + assert.deepEqual( + result.differences.map((row) => row.field), + ['enabled'], + ); + b.layers.radio.sessionState.push('objects'); + assert.ok( + compareColdStart(a, b, options).differences.some((row) => + row.field.startsWith('sessionState'), + ), + ); + assert.equal( + sessionExclusion( + { layer: 'wind', family: 'movers', id: 'radio-selected', field: 'count' }, + a, + ), + null, + ); +}); + +test('a lost session field still fails the source-to-switch retention check', () => { + const a = snapshot(), + b = snapshot(); + a.layers.radio.session.selected = { id: 'station' }; + b.movers[0].count = b.ledger[0].count = 0; + const retention = compareColdStart(a, b, { + ...options, + sessionState: false, + }).differences.filter((row) => sessionExclusion(row, a)); + assert.equal(retention.length, 3); +}); + +test('allowlisted absences keep their reason and never hide changed visibility or counts', () => { + const a = snapshot(), + b = snapshot(); + a.ledger.push({ family: 'lines', id: 'overlay', shown: true }); + b.ledger[0].shown = false; + const result = compareColdStart(a, b, { + ...options, + sessionState: false, + allow: [{ family: 'lines', id: 'overlay', reason: 'Engine overlay' }], + }); + assert.equal(result.ledger.allowed[0].reason, 'Engine overlay'); + assert.deepEqual( + result.differences.map((row) => row.field), + ['shown'], + ); +}); + +test('the production probe captures all requested fields and releases its frame listeners', async () => { + const engine = createFakeEngine(); + const position = { x: 6378137, y: 0, z: 0 }; + const dataManager = { + layers: new Map([ + [ + 'fixture', + { + enabled: true, + module: { + getAllPositions: () => [{ id: 'one', position }], + getSelectedInfo: () => ({ id: 'one' }), + }, + }, + ], + ]), + }; + const context = vm.createContext({ + window: { __godsEyeView: { engine, dataManager } }, + }); + const page = { + evaluate: async (code) => + vm.runInContext(typeof code === 'string' ? code : `(${code})()`, context), + }; + const listeners = engine.frames.postRender.numberOfListeners; + const snapshot = await probeSnapshot(page, ['fixture']); + assert.equal(snapshot.layers.fixture.objects[0].id, 'one'); + assert.equal(snapshot.layers.fixture.selected.id, 'one'); + assert.equal(snapshot.following, null); + assert.equal(snapshot.movers.length, 0); + assert.equal(snapshot.ledger.length, 0); + assert.equal(engine.frames.postRender.numberOfListeners, listeners); + engine.destroy(); +}); diff --git a/scripts/qa/coldStartFixtures.mjs b/scripts/qa/coldStartFixtures.mjs new file mode 100644 index 000000000..e7e6094bb --- /dev/null +++ b/scripts/qa/coldStartFixtures.mjs @@ -0,0 +1,622 @@ +import { readFileSync } from 'node:fs'; +import sharp from 'sharp'; +import { installAlprFixture } from '../fixtures/layers/alpr.mjs'; +import { installCctvFixture } from '../fixtures/layers/cctv.mjs'; +import { installCombinedFixture } from '../fixtures/layers/combined.mjs'; +import { installRadioFixture } from '../fixtures/layers/radio.mjs'; +import { + installTransitFixture, + TRANSIT_EPOCH, +} from '../fixtures/layers/transit.mjs'; +import { firmsFixture } from '../fixtures/layers/firms.mjs'; +import { wildlifeRoutes } from '../fixtures/wildlife.mjs'; +import { serveFixtures, newFixture } from '../qa-street-level.mjs'; +import { KV_LINE } from '../fixtures/street-level/kartaView.mjs'; +import { trafficFixtureResponse } from '../traffic-fixtures.mjs'; +import { + satelliteFixture, + SATELLITE_FIXTURE_EPOCH, +} from '../../src/bench/satelliteFixture.js'; +import { launchFixture } from '../../src/bench/launchFixture.js'; + +// These are the feeds used by the strict switching scenarios, with motion and +// timestamps held fixed so page startup duration cannot change the sample. +export async function coldStartFixtures(origin, group) { + const epoch = + group === 'space' + ? SATELLITE_FIXTURE_EPOCH + : group === 'urban' + ? TRANSIT_EPOCH + : Date.now(); + const png = await sharp({ + create: { width: 256, height: 256, channels: 4, background: '#40506a' }, + }) + .png() + .toBuffer(); + const json = (body) => ({ + contentType: 'application/json', + body: JSON.stringify(body), + }); + const image = () => ({ contentType: 'image/png', body: png }); + const routes = []; + const fixtures = []; + const route = (pattern, respond) => routes.push({ pattern, respond }); + if (group === 'wildlife') routes.push(...wildlifeRoutes(origin).routes); + if (group === 'open') { + route('https://tiles.openseamap.org/*', image); + route('https://www.opensnowmap.org/*', image); + } + if (group === 'cameras') fixtures.push('https://tiles.dontgetflocked.com/*'); + if (group === 'street') + fixtures.push( + '*://*.mapillary.com/*', + 'https://api.kartaview.org/*', + 'https://cdn.kartaview.org/*', + '*://*.openstreetcam.org/*', + 'https://api.panoramax.xyz/*', + 'https://panoramax.openstreetmap.fr/*', + ); + if (group === 'nepal') { + route('https://i.ytimg.com/*', image); + for (const pattern of [ + 'https://www.youtube.com/iframe_api*', + 'https://connect.facebook.net/*', + ]) + route(pattern, () => ({ contentType: 'text/javascript', body: '' })); + for (const pattern of [ + 'https://www.youtube-nocookie.com/*', + 'https://www.facebook.com/*', + ]) + route(pattern, () => ({ + contentType: 'text/html', + body: 'Fixture media', + })); + } + if (group === 'traffic') { + for (const pattern of [ + 'https://tiles.openfreemap.org/*', + origin + '/api/tomtom/*', + ]) + route( + pattern, + (req) => + trafficFixtureResponse({ url: () => req.url }) ?? + json({ hasKey: false }), + ); + } + if (group === 'imagery') { + route('https://cmr.earthdata.nasa.gov/*', () => + json({ + 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: -98, + EastBoundingCoordinate: -97, + SouthBoundingCoordinate: 30, + NorthBoundingCoordinate: 31, + }, + ], + }, + }, + }, + }, + })), + }), + ); + route('https://*.earthdata.nasa.gov/*', image); + } + if (group === 'weather') { + const times = ['2026-11-02T12:00:00.000Z', '2026-11-02T12:05:00.000Z']; + route(origin + '/api/weather/*', (req) => { + const url = new URL(req.url), + product = url.searchParams.get('product'); + return url.pathname.endsWith('manifest') + ? json({ + schemaVersion: 1, + product, + times, + latest: times.at(-1), + maxLevel: 6, + tileSize: 256, + tilingScheme: 'geographic', + bounds: { west: -130, south: 20, east: -50, north: 60 }, + display: { credit: 'Weather fixture' }, + }) + : image(); + }); + route(origin + '/api/cyclones*', () => + json({ + schemaVersion: 1, + source: 'NOAA fixture', + attribution: 'NOAA fixture', + coverage: 'Atlantic', + fetchedAt: Date.parse(times[1]), + stale: false, + unavailable: false, + reason: null, + storms: [ + { + id: 'al012026', + name: 'Fixture storm', + basin: 'AL', + classification: 'TS', + position: { longitude: -79.38, latitude: 43.65 }, + advisoryNumber: '10', + issuedAt: times[1], + positionAt: times[1], + windKt: 40, + pressureHpa: 1000, + movement: { directionDegrees: 45, speedKt: 10 }, + advisoryUrl: null, + outlookUrl: null, + geometryStatus: 'current', + geometryAdvisoryNumber: '10', + track: { + type: 'LineString', + coordinates: [ + [-79.38, 43.65], + [-79.31, 43.68], + ], + }, + cone: { + type: 'Polygon', + coordinates: [ + [ + [-79.35, 43.63], + [-79.28, 43.63], + [-79.28, 43.7], + [-79.35, 43.63], + ], + ], + }, + forecastPoints: [ + { + tauHours: 24, + position: { longitude: -79.31, latitude: 43.68 }, + windKt: 50, + gustKt: 60, + }, + ], + }, + ], + }), + ); + } + if (group === 'wind') + route(origin + '/api/wind/*', (req) => { + const url = new URL(req.url), + model = url.searchParams.get('model'), + kind = url.searchParams.get('overlay'); + if (url.pathname.endsWith('/manifest')) + return json({ + model, + overlay: kind ?? 'none', + grid: { nx: 36, ny: 19, lo1: 0, la1: 90, dx: 10, dy: 10 }, + gridUrl: '/api/wind/grid/' + model + '-fixture.bin' + url.search, + cycle: { + validIso: '2026-11-02T12:00:00Z', + runIso: '2026-11-02T06:00:00Z', + }, + ...(kind + ? { + scalar: { + kind, + units: kind === 'temperature' ? '°C' : 'hPa', + level: '2 m above ground', + }, + } + : {}), + }); + const count = 36 * 19, + values = new Float32Array(count * (kind ? 3 : 2)); + values.fill(8, 0, count); + values.fill(6, count, 2 * count); + if (kind) values.fill(kind === 'temperature' ? 40 : 1020, 2 * count); + return { + contentType: 'application/octet-stream', + body: Buffer.from(values.buffer), + }; + }); + const groups = + group === 'space' + ? Object.fromEntries( + [ + 'stations', + 'visual', + 'gps-ops', + 'glo-ops', + 'galileo', + 'geo', + 'starlink', + 'resource', + 'weather', + 'planet', + 'sar', + 'science', + ].map((id) => [id, satelliteFixture(id)]), + ) + : {}; + const ghost = + group === 'ghostmaps' + ? [ + ...readFileSync( + new URL('../fixtures/ghostmaps-scenes.kmz', import.meta.url), + ), + ] + : []; + const firms = firmsFixture(); + return { + routes, + fixtures, + epoch, + streetLongitude: KV_LINE.lon, + async install(page) { + if (group === 'urban') { + await installCombinedFixture(page); + const transit = await installTransitFixture(page); + transit.setStep(9); + } + if (group === 'radio') await installRadioFixture(page); + if (group === 'cameras') { + await installCctvFixture(page); + await installAlprFixture(page); + } + if (group === 'street') + await serveFixtures(page, newFixture({ configured: true })); + await page.evaluateOnNewDocument(installFeeds, { + group, + epoch, + groups, + ghost, + launches: launchFixture(), + firms, + }); + }, + }; +} + +function installFeeds({ group, epoch, groups, ghost, launches, firms }) { + const RealDate = Date; + globalThis.Date = class extends RealDate { + constructor(...args) { + super(...(args.length ? args : [epoch])); + } + static now() { + return epoch; + } + }; + localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); + window.__coldStartUnexpectedApi = []; + const original = window.fetch.bind(window); + const json = (body) => Promise.resolve(Response.json(body)); + const EventSource = window.EventSource; + window.EventSource = class extends EventTarget { + constructor(url, options) { + super(); + if (!url.includes('/api/ghostmaps/events')) + return new EventSource(url, options); + this.readyState = 1; + } + close() {} + }; + window.fetch = (input, options) => { + const url = new URL(input.url ?? input, location.href), + path = url.pathname; + if (path === '/api/flights') + return json({ + time: epoch / 1000, + states: + group === 'aircraft' + ? [ + [ + '71be26', + 'GEV123', + 'Fixture', + epoch / 1000, + epoch / 1000, + -125.5779, + 40.2539, + 9700, + false, + 0, + 45, + 0, + null, + 9700, + null, + false, + 0, + ], + ] + : [], + }); + if (path === '/api/military') + return json({ + now: epoch, + ac: + group === 'aircraft' + ? [ + { + hex: 'aaa052', + flight: 'GEVMIL', + lon: -125.58, + lat: 40.26, + alt_geom: 31825, + alt_baro: 31825, + gs: 0, + track: 45, + seen: 0, + seen_pos: 0, + dbFlags: 1, + type: 'adsb_icao', + }, + ] + : [], + }); + if (path.startsWith('/api/flights/')) return json({ path: [] }); + if (path.startsWith('/api/adsbdb/')) return json({ response: {} }); + if (path === '/api/vessels') + return json({ + status: 'live', + rows: ['ground', 'contacts'].includes(group) + ? [ + { + mmsi: '123456789', + name: 'Fixture Toronto', + lat: 43.65, + lon: -79.38, + last_position_epoch: epoch / 1000, + }, + ] + : [], + }); + if (path === '/api/vessels/track') return json({ samples: [] }); + if (path === '/api/local-receivers/aircraft') + return json({ + configured: true, + generatedAt: epoch, + records: [ + { + icao: 'abc123', + callsign: 'LOCAL1', + lat: 41.534, + lon: -90.507, + altitudeFt: 11000, + groundSpeedKt: 0, + trackDeg: 5, + lastPositionAt: epoch, + lastMessageAt: epoch, + messageCount: 12, + band: '1090', + source: 'decoder', + }, + ], + }); + if (url.hostname === 'earthquake.usgs.gov') + return json({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + id: 'fixture-boston', + geometry: { type: 'Point', coordinates: [-71.0605, 42.3554, 8] }, + properties: { mag: 4, time: epoch, place: 'Fixture Boston' }, + }, + ], + }); + if (path === '/api/military-installations') + return json({ + status: 'ok', + elements: + group === 'contacts' + ? [ + { + type: 'way', + id: 424242, + tags: { military: 'naval_base', name: 'Fixture Harbour' }, + center: { lat: 43.6725, lon: -79.33 }, + geometry: [ + [43.665, -79.345], + [43.665, -79.315], + [43.68, -79.315], + [43.68, -79.345], + [43.665, -79.345], + ].map(([lat, lon]) => ({ lat, lon })), + }, + ] + : [], + }); + if (path.startsWith('/api/fire-perimeters/inciweb')) + return json({ rows: [] }); + if (path === '/api/fire-perimeters') + return json({ + rows: [ + { + stableId: 'fire:fixture', + name: 'Fixture Fire', + acres: 500, + containedPct: 40, + polygons: [ + [ + [ + [-79.47, 43.56], + [-79.29, 43.56], + [-79.29, 43.74], + [-79.47, 43.74], + [-79.47, 43.56], + ], + ], + ], + }, + ], + }); + if (path === '/api/gnss-integrity') + return 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') + return json({ + type: 'FeatureCollection', + features: + group !== 'ground' + ? [] + : [ + { + type: 'Feature', + ...(path.includes('aqhi-stations') + ? { + geometry: { + type: 'Point', + coordinates: [-79.38, 43.65], + }, + properties: { + location_id: 'QA1', + location_name_en: 'Fixture Toronto', + }, + } + : { + properties: { + location_id: 'QA1', + aqhi: 9, + observation_datetime: new Date().toISOString(), + }, + }), + }, + ], + }); + if (path === '/api/drone-airspace') + return json({ + rows: [ + { + stableId: '310', + name: 'Fixture Zone', + typeCode: 'P', + type: 'Prohibited', + lowerAltitude: 'SFC', + upperAltitude: '18,000 ft MSL', + timesOfUse: 'CONTINUOUS', + agency: null, + city: 'WASHINGTON', + state: 'DC', + polygons: [ + [ + [ + [-77.06, 38.88], + [-77, 38.88], + [-77, 38.92], + [-77.06, 38.92], + [-77.06, 38.88], + ], + ], + ], + }, + ], + stale: false, + fetchedAt: epoch, + }); + if (path === '/api/overpass/status') return json({ configured: true }); + if (path.startsWith('/api/overpass/')) + return json({ + elements: [ + { + type: 'node', + id: 1, + lat: 38.9, + lon: -77.03, + tags: { name: 'Fixture Clinic', amenity: 'clinic' }, + }, + ], + }); + if (path === '/api/ghostmaps/catalog') + return json({ + commit: 'fixture', + products: [ + { id: 'cip', version: 'fixture', title: 'Fixture', defaultOn: true }, + ], + }); + if (path.startsWith('/api/ghostmaps/file/')) + return Promise.resolve(new Response(new Uint8Array(ghost))); + if (path.startsWith('/api/celestrak/')) + return Promise.resolve( + new Response(groups[path.split('/').pop()] ?? groups.stations), + ); + if (path === '/api/launches') return json(launches); + if (path === '/api/firms') return json(firms); + if (path === '/api/route') { + const [a, b] = url.searchParams + .get('coords') + .split(';') + .map((point) => point.split(',').map(Number)); + const geometry = [a, [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], b]; + return json({ + ok: true, + geometry, + distanceM: 9000, + durationS: 800, + steps: geometry.map(([lon, lat], i) => ({ + lon, + lat, + instruction: 'Route step ' + i, + distanceM: 3000, + durationS: 120, + })), + }); + } + if (path === '/api/terrain/heights') + return json({ + results: (url.searchParams.get('points') ?? '') + .split(';') + .map(() => ({ ellipsoid: 0 })), + }); + // Same-origin proxies must not silently turn this into a live-feed test. + // Delegate only routes answered by the fixtures above or the strict harness. + if (path.startsWith('/api/')) { + if (path === '/api/google/nearby-places') return json({ places: [] }); + if (path === '/api/setup/status') return json({ ready: true }); + if (/^\/api\/(firms|mapillary)\/status$/.test(path) && group !== 'street') + return json({ hasKey: false, configured: false }); + if (path === '/api/tomtom/status' && group !== 'traffic') + return json({ hasKey: false }); + const delegated = { + urban: /^\/api\/(gbfs|transit)\//, + cameras: /^\/api\/cctv\//, + radio: /^\/api\/radio\//, + street: /^\/api\/mapillary\//, + weather: /^\/api\/(weather\/|cyclones)/, + wind: /^\/api\/wind\//, + traffic: /^\/api\/tomtom\//, + wildlife: /^\/api\/(inaturalist|acartia)\//, + }[group]; + if ( + !delegated?.test(path) && + ![ + '/api/openai/hud-summary/status', + '/api/google/tiles-token', + '/api/realtime/debug-log', + ].includes(path) + ) { + window.__coldStartUnexpectedApi.push(path); + return Promise.reject(new Error(`No cold-start fixture for ${path}`)); + } + } + return original(input, options); + }; +} diff --git a/scripts/qa/coldStartFixtures.test.mjs b/scripts/qa/coldStartFixtures.test.mjs new file mode 100644 index 000000000..35830807e --- /dev/null +++ b/scripts/qa/coldStartFixtures.test.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import vm from 'node:vm'; +import { coldStartFixtures } from './coldStartFixtures.mjs'; +import { COLD_START_GROUPS } from './coldStart.mjs'; + +const origin = 'http://127.0.0.1:4309'; +test('every group has valid strict fixture routes', async () => { + for (const group of Object.keys(COLD_START_GROUPS)) { + const fixture = await coldStartFixtures(origin, group); + assert.ok(Number.isFinite(fixture.epoch)); + for (const { pattern } of fixture.routes) + assert.ok(new URLPattern(pattern)); + for (const pattern of fixture.fixtures) assert.ok(new URLPattern(pattern)); + } +}); + +test('imagery fixtures answer catalog, thumbnails and every GIBS tile subdomain', async () => { + const { routes } = await coldStartFixtures(origin, 'imagery'); + for (const host of ['cmr', 'wvs', 'gibs-a', 'gibs-b', 'gibs-c']) { + const url = `https://${host}.earthdata.nasa.gov/fixture`; + const route = routes.find(({ pattern }) => + new URLPattern(pattern).test(url), + ); + assert.ok(route, host); + const response = await route.respond({ url }); + assert.equal( + response.contentType, + host === 'cmr' ? 'application/json' : 'image/png', + ); + assert.ok(response.body.length); + } +}); + +test('wind fixture supplies the declared grid dimensions and uniform vectors', async () => { + const { routes } = await coldStartFixtures(origin, 'wind'); + const manifest = JSON.parse( + routes[0].respond({ + url: origin + '/api/wind/manifest?model=gfs&overlay=temperature', + }).body, + ); + const response = routes[0].respond({ url: origin + manifest.gridUrl }); + const values = new Float32Array( + response.body.buffer, + response.body.byteOffset, + response.body.length / 4, + ); + const count = manifest.grid.nx * manifest.grid.ny; + assert.equal(values.length, count * 3); + assert.ok(values.slice(0, count).every((value) => value === 8)); + assert.ok(values.slice(count, count * 2).every((value) => value === 6)); +}); + +test('independent pages receive identical stationary aircraft and timestamps through fetch', async () => { + const fixture = await coldStartFixtures(origin, 'aircraft'); + const scripts = []; + await fixture.install({ + evaluateOnNewDocument: async (fn, args) => scripts.push({ fn, args }), + }); + const page = () => { + const context = vm.createContext({ + URL, + Response, + EventTarget, + location: { href: origin }, + localStorage: { setItem() {} }, + window: { + EventSource: class {}, + fetch() { + throw new Error('Unstubbed network request'); + }, + }, + }); + for (const { fn, args } of scripts) + vm.runInContext(`(${fn})(${JSON.stringify(args)})`, context); + return context; + }; + const a = page(), + b = page(); + for (const path of ['/api/flights', '/api/military', '/api/flights/track']) { + const read = async (context) => + ( + await vm.runInContext(`window.fetch(${JSON.stringify(path)})`, context) + ).json(); + assert.deepEqual(await read(a), await read(b)); + } + assert.equal(vm.runInContext('Date.now()', a), fixture.epoch); + assert.equal(vm.runInContext('+new Date()', b), fixture.epoch); + await assert.rejects( + vm.runInContext("window.fetch('/api/unexpected-feed')", a), + /No cold-start fixture/, + ); + assert.equal( + vm.runInContext('window.__coldStartUnexpectedApi[0]', a), + '/api/unexpected-feed', + ); +}); diff --git a/scripts/qa/sessionState.test.mjs b/scripts/qa/sessionState.test.mjs new file mode 100644 index 000000000..9b36e3f9a --- /dev/null +++ b/scripts/qa/sessionState.test.mjs @@ -0,0 +1,263 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { optionSpecs, REGISTRY_BY_ID } from '../../src/data/layerState.js'; +import { createFakeEngine } from '../../src/globe/testing/fakeEngine.js'; +import { createOsmPlacesLayer } from '../../src/layers/osmPlaces/index.js'; +import { createWindLayer } from '../../src/layers/wind/index.js'; +import { createApplicationLocalAdsb } from '../../src/app/layers/localAdsb.js'; +import { radioLayer } from '../../src/data/radio.js'; +import { + withRadioLayer, + radioStationRow, +} from '../../src/testSupport/radioLayerHarness.mjs'; +import { radioTestEngine } from '../../src/testSupport/radioTestEngine.mjs'; +import { loadLayerRenderers } from '../../src/app/layers/renderers.js'; +import { createApplicationDirectionsLayer } from '../../src/data/directions.js'; +import { createDirectionsDisplay } from '../../src/layers/directions/render/display.js'; + +// Each entry has a non-default retention exercise below. A new declaration +// needs an exercise; spelling a plausible field name alone cannot suppress QA. +const declarations = { + 'src/layers/radio/index.js': ['selected', 'audioState'], + 'src/layers/wind/index.js': ['reading'], + 'src/layers/osmPlaces/index.js': ['enabled', 'results'], + 'src/layers/localAdsb/index.js': ['enabled'], + 'src/layers/directions/index.js': ['route'], +}; +test('every declaration is covered by a real handoff exercise, without extra fields', () => { + const files = execFileSync( + 'git', + ['ls-files', '--cached', '--others', '--exclude-standard', 'src'], + { encoding: 'utf8' }, + ) + .trim() + .split('\n'); + const found = {}; + for (const file of files.filter((file) => file.endsWith('.js'))) { + const text = readFileSync(file, 'utf8'); + for (const match of text.matchAll(/sessionState\s*:\s*(\[[^\]]*\])/g)) { + assert.ok(!found[file], `Duplicate declaration in ${file}`); + found[file] = JSON.parse(match[1].replaceAll("'", '"')); + } + } + assert.deepEqual(found, declarations); +}); + +function verifyFields(layer, before, after) { + assert.deepEqual([...layer.sessionState].sort(), Object.keys(before).sort()); + for (const field of layer.sessionState) { + assert.notEqual(before[field], undefined, field); + assert.deepEqual( + after[field], + before[field], + `${layer.id}.${field} survived detach/attach`, + ); + assert.ok( + !optionSpecs(layer.id).some((spec) => spec.key === field), + `${field} is not a share option`, + ); + } +} + +test('radio selection and playback survive reattachment; volume is shareable', async () => { + await loadLayerRenderers('cesium'); + const station = radioStationRow(); + await withRadioLayer({ stations: [station] }, async () => { + radioLayer.selectStation(station.id, { + autoplay: true, + focus: false, + origin: 'user', + }); + await new Promise((resolve) => setImmediate(resolve)); + const state = () => { + const { selected, audioState } = radioLayer.getUIState(); + return { selected, audioState }; + }; + const before = state(); + assert.equal(before.selected.id, station.id); + assert.notEqual(before.audioState, 'idle'); + radioLayer.detachRenderer(); + radioLayer.attachRenderer({ engine: radioTestEngine() }); + verifyFields(radioLayer, before, state()); + assert.ok(optionSpecs('radio').some((spec) => spec.key === 'volume')); + }); +}); + +test('wind inspection survives reattachment; pause is a share option', async () => { + const field = { + model: 'gfs', + grid: { nx: 2, ny: 2, lo1: 0, la1: 90, dx: 180, dy: 180 }, + u: new Float32Array(4).fill(4), + v: new Float32Array(4).fill(3), + cycle: { validIso: '2026-11-02T12:00:00Z' }, + }; + const layer = createWindLayer({ + feed: { getSnapshot: async () => field }, + createNativeRendering: () => ({ + attach() {}, + start() {}, + stop() {}, + clear() {}, + destroy() {}, + setField() {}, + getDiagnostics: () => ({}), + }), + }); + try { + layer.init(null, { engine: createFakeEngine() }); + layer.enable(); + await layer.update(); + layer.setParams({ inspect: true }); + const before = { reading: layer.getRowControls().summary.reading }; + assert.ok(before.reading); + layer.detachRenderer(); + layer.attachRenderer({ engine: createFakeEngine() }); + verifyFields(layer, before, { + reading: layer.getRowControls().summary.reading, + }); + assert.ok(optionSpecs('wind').some((spec) => spec.key === 'paused')); + } finally { + layer.destroy(); + } +}); + +test('OSM results and visibility survive reattachment but have no share registry entry', () => { + const host = { clearSource() {}, setEntries() {}, setVisible() {} }; + const layer = createOsmPlacesLayer({ + overlayHost: host, + overpassConfigured: async () => true, + }); + const first = createFakeEngine(), + second = createFakeEngine(); + const records = [ + { id: 'node/1', lat: 38.9, lon: -77.03, name: 'Fixture Clinic' }, + ]; + try { + layer.init(null, { engine: first }); + layer.setResults(records, { label: 'Clinics', area: 'Washington' }); + layer.enable(); + const before = { + enabled: first.movers.list()[0].shown, + results: layer.getAnalystRecords(), + }; + assert.equal(before.enabled, true); + assert.equal(before.results.length, 1); + layer.detachRenderer(); + layer.attachRenderer({ engine: second }); + verifyFields(layer, before, { + enabled: second.movers.list()[0].shown, + results: layer.getAnalystRecords(), + }); + assert.equal(second.movers.list()[0].count, 1); + assert.equal(REGISTRY_BY_ID.has(layer.id), false); + } finally { + layer.destroy(); + first.destroy(); + second.destroy(); + } +}); + +test('local receiver visibility survives reattachment without restarting its session', async () => { + let starts = 0, + visible = false; + const layer = createApplicationLocalAdsb({ + receiver: { getState: () => ({ mode: 'adsb', aircraft: [] }) }, + feeds: { getState: () => ({ records: [] }), start: () => starts++ }, + renderer: { + create: ({ engine }) => ({ + engine, + attach() {}, + detach() {}, + destroy() {}, + setVisible: (value) => { + visible = value; + }, + view: { on: () => () => {} }, + }), + }, + }); + const first = createFakeEngine(), + second = createFakeEngine(); + try { + layer.init(null, { engine: first }); + await layer.enable(); + const before = { enabled: visible }; + assert.equal(visible, true); + layer.detachRenderer(); + visible = false; + layer.attachRenderer({ engine: second }); + verifyFields(layer, before, { enabled: visible }); + assert.equal(starts, 1); + assert.equal(REGISTRY_BY_ID.has(layer.id), false); + } finally { + layer.destroy(); + first.destroy(); + second.destroy(); + } +}); + +test('directions retain the route and its markers; a cold layer has no route', async () => { + const geometry = [ + [10.78, 55.32], + [10.8, 55.325], + [10.82, 55.33], + ]; + const layer = createApplicationDirectionsLayer({ + source: { + getRoute: async () => ({ + ok: true, + geometry, + distanceM: 9000, + durationS: 800, + steps: geometry.map(([lon, lat], i) => ({ + lon, + lat, + instruction: `Step ${i}`, + distanceM: 3000, + durationS: 120, + })), + }), + }, + groundFloor: { + GROUND_FLOOR_LIFT_M: 2, + cachedGroundFloor: () => 0, + warmGroundFloor() {}, + }, + rendererFactory: createDirectionsDisplay, + }); + layer._setOverlayHostForTest({ + setEntries() {}, + setVisible() {}, + clearSource() {}, + }); + const first = createFakeEngine(), + second = createFakeEngine(); + try { + layer.init(null, { engine: first }); + layer.enable(); + const cold = layer.getRowControls().list; + layer.placeEndpoint('a', { lat: 55.32, lon: 10.78 }); + layer.placeEndpoint('b', { lat: 55.33, lon: 10.82 }); + await new Promise((resolve) => setImmediate(resolve)); + const before = { route: layer.getRowControls().list }; + assert.notDeepEqual(before.route, cold); + assert.equal(layer.getStats().count, 3); + layer.detachRenderer(); + layer.attachRenderer({ engine: second }); + verifyFields(layer, before, { route: layer.getRowControls().list }); + assert.equal( + second.ground.list().find(({ id }) => id === 'directions-route').count, + 1, + ); + assert.equal( + second.movers.list().find(({ id }) => id === 'directions-markers').count, + 2, + ); + } finally { + layer.destroy(); + first.destroy(); + second.destroy(); + } +}); diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index 8ffd5adf9..fee09e2aa 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -1511,6 +1511,7 @@ export function createDirectionsLayer({ }; const directionsLayer = { id: 'directions', + sessionState: ['route'], name: 'Directions', icon: '🧭', source: 'OSM routing', diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index 38494cd4d..74c148fec 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -606,6 +606,7 @@ export function createLocalAdsbLayer({ const picks = { owner: LAYER_ID, predicate: (id) => markers.has(id) }; return { id: LAYER_ID, + sessionState: ['enabled'], name: LAYER_NAME, icon: '📡', source: LAYER_SOURCE, diff --git a/src/layers/osmPlaces/index.js b/src/layers/osmPlaces/index.js index ec87b5fe5..5ced01e33 100644 --- a/src/layers/osmPlaces/index.js +++ b/src/layers/osmPlaces/index.js @@ -72,6 +72,7 @@ export function createOsmPlacesLayer({ return { id: OSM_PLACES_LAYER_ID, + sessionState: ['enabled', 'results'], name: 'OSM Places', icon: '⌖', source: 'OpenStreetMap', diff --git a/src/layers/radio/index.js b/src/layers/radio/index.js index 632203e9b..48c9afae8 100644 --- a/src/layers/radio/index.js +++ b/src/layers/radio/index.js @@ -52,6 +52,7 @@ export function createRadioLayer({ services, source, rendererFactory }) { parts.lifecycle.methods, parts.ingestion?.methods, { + sessionState: ['selected', 'audioState'], radioGlobeLabel: parts.labels.radioGlobeLabel, createRadioSelectedOverlayEntry: parts.labels.createRadioSelectedOverlayEntry, diff --git a/src/layers/wind/index.js b/src/layers/wind/index.js index 675d2e90f..d654c22e9 100644 --- a/src/layers/wind/index.js +++ b/src/layers/wind/index.js @@ -106,6 +106,7 @@ export function createWindLayer({ const unsubscribeClock = clock?.subscribe(notify); const layer = { id: 'wind', + sessionState: ['reading'], name: 'Wind', icon: '🌬', source: 'GFS / ECMWF IFS · FORECAST',