diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f9637b600..c41e24ade 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -178,9 +178,20 @@ one application, and only the globe-bound parts move to the next engine. - **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 + in for tests, and `src/globe/testing/contractSuite.mjs` holds the checks + every engine must pass. `npm test` runs them on the fake engine; + `node scripts/qa-contract-suite.mjs ` runs them in headless Chrome + on the real Cesium and Navara of a keyless dev server. On November 7, 2026 + Cesium passed 57 of 57 and Navara 64 of 67: a resting move raised no + `moveEnd`, a click payload never came, and contact sonar lost an angle-only + change inside the app. `movers` and `lines` are optional capabilities: only Navara has them, and the suite skips their checks elsewhere. +- **Engine coupling.** `node scripts/report-engine-coupling.mjs` counts the + engine-specific code outside the engines: renderer lines per engine, Cesium + imports, `viewer` mentions, engine-name checks, raw Navara reads, Navara + ground passes, Cesium SDK loads and `ScreenSpaceEventHandler` users. + `scripts/engineCoupling.test.mjs` (in `npm test`) fails when a count grows + past `scripts/engine-coupling-baseline.json`. - **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, diff --git a/scripts/engine-coupling-baseline.json b/scripts/engine-coupling-baseline.json new file mode 100644 index 000000000..71e03a271 --- /dev/null +++ b/scripts/engine-coupling-baseline.json @@ -0,0 +1,16 @@ +{ + "metrics": { + "cesiumLayerRendererLines": 5239, + "navaraLayerRendererLines": 12331, + "cesiumImportFiles": 69, + "viewerLines": 2424, + "navaraKindChecks": 17, + "engineSniffCalls": 51, + "navaraViewsReads": 27, + "groundEffectPasses": 9, + "cesiumSdkLoads": 6, + "screenSpaceHandlerFiles": 20, + "engineNameLines": 175 + }, + "measuredAt": "german/navara merged with origin/main 8da8c05e, 2026-11-07" +} diff --git a/scripts/engineCoupling.test.mjs b/scripts/engineCoupling.test.mjs new file mode 100644 index 000000000..7830b26fb --- /dev/null +++ b/scripts/engineCoupling.test.mjs @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + ENGINE_COUPLING_METRICS, + couplingGrowth, + measureEngineCoupling, + readBaseline, +} from './report-engine-coupling.mjs'; + +test('engine coupling does not grow past the baseline', () => { + const growth = couplingGrowth( + measureEngineCoupling(), + readBaseline().metrics, + ); + assert.deepEqual( + growth, + [], + 'Code outside the globe engines grew more engine-specific. Draw through ' + + 'the globe contract instead; only a deliberate exception may raise ' + + 'scripts/engine-coupling-baseline.json (--write-baseline).', + ); +}); + +test('the baseline names every metric', () => { + const { metrics } = readBaseline(); + assert.deepEqual( + Object.keys(metrics).sort(), + ENGINE_COUPLING_METRICS.map(({ id }) => id).sort(), + ); +}); + +test('a metric that grows is reported, one that falls is not', () => { + const baseline = Object.fromEntries( + ENGINE_COUPLING_METRICS.map(({ id }) => [id, 10]), + ); + const measured = { ...baseline, viewerLines: 11, navaraKindChecks: 3 }; + assert.deepEqual( + couplingGrowth(measured, baseline).map(({ id }) => id), + ['viewerLines'], + ); +}); diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 6f7704712..d30a2cf57 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1616,5 +1616,9 @@ "src/layers/wildlifeSightings/density.test.mjs", "src/layers/wildlifeSightings/selection.test.mjs", "server/providers/inaturalist/proxy.fixture.mjs", - "src/globe/cesium/doubleClick.js" + "scripts/report-engine-coupling.mjs", + "scripts/engineCoupling.test.mjs", + "scripts/qa-contract-suite.mjs", + "src/globe/cesium/doubleClick.js", + "scripts/engine-coupling-baseline.json" ] diff --git a/scripts/qa-contract-suite.mjs b/scripts/qa-contract-suite.mjs new file mode 100644 index 000000000..643a49e43 --- /dev/null +++ b/scripts/qa-contract-suite.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +/** + * Run the globe contract suite (src/globe/testing/contractSuite.mjs) in + * headless Chrome against the real engines of a running app, the way + * contract.test.mjs runs it against the fake engine. + * + * GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4241 + * node scripts/qa-contract-suite.mjs http://localhost:4241 [--engine=cesium,navara] [--json=FILE] + * + * Serve the app keyless from the dev server: Google and Cesium ion requests + * are refused here anyway, so neither engine draws photoreal tiles. The page + * imports the suite from the dev server, so it shares the app's modules (an + * engine event is recognised by the module that made it). It gets a real + * pointer (the pick checks) and no map sources (the maps checks would ask for + * photoreal tiles). Exits 1 when any check fails on any engine. + */ +import fs from 'node:fs'; +import puppeteer from 'puppeteer'; + +const BLOCKED_HOSTS = + /(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/; + +const args = process.argv.slice(2); +const origin = args.find((arg) => !arg.startsWith('--')); +const option = (name) => + args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3); +if (!origin) { + console.error( + 'Usage: node scripts/qa-contract-suite.mjs [--engine=cesium,navara] [--json=FILE]', + ); + process.exit(2); +} +const engines = (option('engine') ?? 'cesium,navara').split(','); + +async function runOn(browser, kind) { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800 }); + const pageErrors = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + await page.setRequestInterception(true); + page.on('request', (request) => { + const { hostname } = new URL(request.url()); + if (BLOCKED_HOSTS.test(hostname)) request.abort(); + else request.continue(); + }); + // A view in the link: the app starts there instead of flying to a city. + await page.goto( + `${origin}/?welcome=0&engine=${kind}#v=2&lat=10&lon=20&alt=1000000&heading=0&pitch=-90&map=osm&l=`, + ); + await page.waitForFunction( + (expected) => + window.__godsEyeView?.engine?.kind === expected && + document.getElementById('loading-screen')?.classList.contains('hidden'), + // A cold dev server compiles Navara's modules on the first visit. + { timeout: 300_000, polling: 250 }, + kind, + ); + // The suite owns the globe: no camera flight running, and the app's panels + // let the pointer through to the canvas. + await page.waitForFunction( + () => !window.__godsEyeView.engine.camera.flightActive(), + { timeout: 60_000, polling: 250 }, + ); + await page.addStyleTag({ + content: + '* { pointer-events: none !important; } canvas { pointer-events: auto !important; }', + }); + // A real left click and pointer move at a canvas position. + await page.exposeFunction('__contractPointer', async (type, x, y) => { + const box = await page.evaluate(() => { + const rect = + window.__godsEyeView.engine.host.canvas.getBoundingClientRect(); + return { left: rect.left, top: rect.top }; + }); + const at = [box.left + x, box.top + y]; + if (type === 'click') await page.mouse.click(...at); + else { + await page.mouse.move(at[0] - 1, at[1]); + await page.mouse.move(...at); + } + }); + const results = await page.evaluate(async () => { + const { engine } = window.__godsEyeView; + const waitFrames = (count) => + new Promise((resolve) => { + let left = count; + const remove = engine.frames.postRender.addEventListener(() => { + if (--left > 0) return; + remove(); + resolve(); + }); + engine.render.request('contract-suite'); + }); + const pointer = { + click: (x, y) => window.__contractPointer('click', x, y), + move: (x, y) => window.__contractPointer('move', x, y), + }; + const { runContractSuite } = + await import('/src/globe/testing/contractSuite.mjs'); + const outcome = await runContractSuite(engine, { + waitFrames, + pointer, + }); + return outcome.map(({ name, ok, error }) => ({ + name, + ok, + error: error ? String(error.message ?? error) : undefined, + })); + }); + await page.close(); + return { engine: kind, results, pageErrors }; +} + +const browser = await puppeteer.launch({ + headless: true, + // A Navara page can hold its main thread for minutes while it starts. + protocolTimeout: 600_000, + args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'], +}); +const report = []; +try { + for (const kind of engines) { + const run = await runOn(browser, kind); + report.push(run); + const failed = run.results.filter(({ ok }) => !ok); + console.log( + `${kind}: ${run.results.length - failed.length}/${run.results.length} checks pass`, + ); + for (const { name, error } of failed) + console.log(` FAIL ${name}: ${error}`); + for (const message of run.pageErrors) + console.log(` page error: ${message}`); + } +} finally { + await browser.close(); +} +const json = option('json'); +if (json) fs.writeFileSync(json, `${JSON.stringify(report, null, 2)}\n`); +process.exit( + report.every(({ results }) => results.every(({ ok }) => ok)) ? 0 : 1, +); diff --git a/scripts/report-engine-coupling.mjs b/scripts/report-engine-coupling.mjs new file mode 100644 index 000000000..35e600164 --- /dev/null +++ b/scripts/report-engine-coupling.mjs @@ -0,0 +1,232 @@ +#!/usr/bin/env node +/** + * How tightly the application is still tied to one globe engine: counts of + * engine-specific code outside the engines (src/globe/cesium, src/globe/navara). + * The renderer-abstraction work drives every number down; none may grow. + * engineCoupling.test.mjs (npm test) compares a fresh count with + * scripts/engine-coupling-baseline.json. + * + * Only production sources count: `.js` and `.vue` under src. Tests, fixtures + * and test doubles are `.mjs`. + * + * node scripts/report-engine-coupling.mjs # table + * node scripts/report-engine-coupling.mjs --json + * node scripts/report-engine-coupling.mjs --write-baseline + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const ROOT = fileURLToPath(new URL('..', import.meta.url)); +export const BASELINE_PATH = path.join( + ROOT, + 'scripts', + 'engine-coupling-baseline.json', +); + +const inEngine = (file) => + file.startsWith('src/globe/cesium/') || file.startsWith('src/globe/navara/'); +const inGlobe = (file) => file.startsWith('src/globe/'); +const isLayerRenderer = (engine) => (file) => + file.startsWith('src/layers/') && + new RegExp(`/render/${engine}([^/]*\\.js|/.+\\.js)$`).test(file); +const isRenderer = (file) => /\/render\//.test(file); + +const lines = (text) => text.split('\n'); +const countLines = (text, pattern) => + lines(text).filter((line) => pattern.test(line)).length; +const countMatches = (text, pattern) => + (text.match(new RegExp(pattern.source, 'g')) ?? []).length; + +/** + * The metrics, in report order. `measure(files)` gets every production + * source as `{ file, text }` (file relative to the repository, with `/`). + */ +export const ENGINE_COUPLING_METRICS = Object.freeze([ + { + id: 'cesiumLayerRendererLines', + label: 'Lines in src/layers/**/render/cesium*', + measure: (files) => + files + .filter(({ file }) => isLayerRenderer('cesium')(file)) + .reduce((sum, { text }) => sum + lines(text).length, 0), + }, + { + id: 'navaraLayerRendererLines', + label: 'Lines in src/layers/**/render/navara*', + measure: (files) => + files + .filter(({ file }) => isLayerRenderer('navara')(file)) + .reduce((sum, { text }) => sum + lines(text).length, 0), + }, + { + id: 'cesiumImportFiles', + label: "Files importing 'cesium' outside src/globe/cesium and render/", + measure: (files) => + files.filter( + ({ file, text }) => + !file.startsWith('src/globe/cesium/') && + !isRenderer(file) && + /from\s+['"]cesium['"]|import\(\s*['"]cesium['"]\s*\)/.test(text), + ).length, + }, + { + id: 'viewerLines', + label: 'Lines naming `viewer` outside src/globe/cesium', + measure: (files) => + files + .filter(({ file }) => !file.startsWith('src/globe/cesium/')) + .reduce((sum, { text }) => sum + countLines(text, /\bviewer\b/), 0), + }, + { + id: 'navaraKindChecks', + label: "`kind === 'navara'` checks outside src/globe", + measure: (files) => + files + .filter(({ file }) => !inGlobe(file)) + .reduce( + (sum, { text }) => + sum + countMatches(text, /kind\s*[!=]==?\s*['"]navara['"]/), + 0, + ), + }, + { + id: 'engineSniffCalls', + label: 'isGlobeEngine / engineFromViewer calls outside src/globe', + measure: (files) => + files + .filter(({ file }) => !inGlobe(file)) + .reduce( + (sum, { text }) => + sum + countMatches(text, /\b(?:isGlobeEngine|engineFromViewer)\(/), + 0, + ), + }, + { + id: 'navaraViewsReads', + label: 'Raw Navara reads through navaraViews', + measure: (files) => + files + .filter(({ file }) => !inEngine(file)) + .reduce( + (sum, { text }) => sum + countMatches(text, /\bnavaraViews\.get\(/), + 0, + ), + }, + { + id: 'groundEffectPasses', + label: 'Navara ground passes (defineGroundEffect calls)', + measure: (files) => + files.reduce( + (sum, { text }) => + sum + countMatches(text, /\bdefineGroundEffect\(\s*\{/), + 0, + ), + }, + { + id: 'cesiumSdkLoads', + label: 'Cesium SDK loads outside the engines and loadCesium.js', + measure: (files) => + files + .filter( + ({ file }) => !inEngine(file) && file !== 'src/app/loadCesium.js', + ) + .reduce( + (sum, { text }) => + sum + countMatches(text, /\b(?:loadCesiumSdk|whenCesiumSdk)\(/), + 0, + ), + }, + { + id: 'screenSpaceHandlerFiles', + label: 'Files in src/layers and src/data using ScreenSpaceEventHandler', + measure: (files) => + files.filter( + ({ file, text }) => + (file.startsWith('src/layers/') || file.startsWith('src/data/')) && + /ScreenSpaceEventHandler/.test(text), + ).length, + }, + { + id: 'engineNameLines', + label: + "Lines with an engine name literal ('navara', 'cesium') outside src/globe", + measure: (files) => + files + .filter(({ file }) => !inGlobe(file)) + .reduce( + (sum, { text }) => + sum + countLines(text, /['"](?:navara|cesium)['"]/), + 0, + ), + }, +]); + +/** Production sources under `root`/src: `.js` and `.vue`. */ +export function readProductionSources(root = ROOT) { + const files = []; + const visit = (directory) => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) visit(absolute); + else if (entry.isFile() && /\.(?:js|vue)$/.test(entry.name)) + files.push({ + file: path.relative(root, absolute).split(path.sep).join('/'), + text: fs.readFileSync(absolute, 'utf8'), + }); + } + }; + visit(path.join(root, 'src')); + return files.sort((a, b) => a.file.localeCompare(b.file)); +} + +/** Every metric's value for the sources under `root`. */ +export function measureEngineCoupling(root = ROOT) { + const files = readProductionSources(root); + return Object.fromEntries( + ENGINE_COUPLING_METRICS.map(({ id, measure }) => [id, measure(files)]), + ); +} + +/** The metrics that grew past `baseline`, with both values. */ +export function couplingGrowth(measured, baseline) { + return ENGINE_COUPLING_METRICS.filter( + ({ id }) => !(measured[id] <= (baseline[id] ?? -Infinity)), + ).map(({ id, label }) => ({ + id, + label, + measured: measured[id], + baseline: baseline[id], + })); +} + +export function readBaseline(file = BASELINE_PATH) { + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +function main(args) { + const measured = measureEngineCoupling(); + if (args.includes('--write-baseline')) { + const note = args.find((arg) => arg.startsWith('--note='))?.slice(7); + fs.writeFileSync( + BASELINE_PATH, + `${JSON.stringify({ metrics: measured, ...(note ? { measuredAt: note } : {}) }, null, 2)}\n`, + ); + } + if (args.includes('--json')) { + console.log(JSON.stringify(measured, null, 2)); + return; + } + const baseline = fs.existsSync(BASELINE_PATH) ? readBaseline().metrics : {}; + for (const { id, label } of ENGINE_COUPLING_METRICS) { + const was = baseline[id]; + const change = + was === undefined + ? '' + : ` (baseline ${was}, ${measured[id] - was >= 0 ? '+' : ''}${measured[id] - was})`; + console.log(`${String(measured[id]).padStart(7)} ${label}${change}`); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) + main(process.argv.slice(2));