From 731eadc34a6d83e6848a8aa43b07f08116191e0b Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 16:43:58 +0400 Subject: [PATCH 1/6] Give QA scripts one launch, readiness and geometry helper scripts/qa/browser.mjs launches headless Chrome with QA_ANGLE and the shared Chrome lookup; scripts/qa/ready.mjs adds frame, tile and transition readiness and bounded polling; scripts/qa/geometry.mjs hands page evaluations the engine-neutral geometry without dev-only source URLs. CODE-BOUNDARIES gains the rule for new QA scripts. --- docs/CODE-BOUNDARIES.md | 11 +++++ scripts/qa/browser.mjs | 15 ++++++ scripts/qa/browser.test.mjs | 39 ++++++++++++++++ scripts/qa/geometry.mjs | 38 +++++++++++++++ scripts/qa/geometry.test.mjs | 24 ++++++++++ scripts/qa/ready.mjs | 91 ++++++++++++++++++++++++++++++++++++ scripts/qa/ready.test.mjs | 87 ++++++++++++++++++++++++++++++++++ 7 files changed, 305 insertions(+) create mode 100644 scripts/qa/browser.test.mjs create mode 100644 scripts/qa/geometry.mjs create mode 100644 scripts/qa/geometry.test.mjs create mode 100644 scripts/qa/ready.test.mjs diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index e5a9999ca..e275f04e0 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -19,6 +19,17 @@ excluded. The formatter validates every entry before writing any file. New world text labels must use the world-overlay host; Cesium text labels are forbidden. +## QA scripts + +New `scripts/qa-*.mjs` scenarios use `scripts/qa/browser.mjs` for headless launch +and bounded shutdown, and `scripts/qa/ready.mjs` for application, camera and +frame readiness. Select the GPU backend with `QA_ANGLE`. Keep timed pauses only +when elapsed time is part of the assertion, and explain that measurement. +Read scene state through the globe contract or the QA probe; native reads belong +only in explicit engine-adapter checks. The counters in +`node scripts/report-qa-harness.mjs` must not grow; lower the baseline with +`--write-baseline` after removing duplication. + ## Current component ownership Package imports use `gods-eye-view`; `package.json` is the authoritative diff --git a/scripts/qa/browser.mjs b/scripts/qa/browser.mjs index f91cf9695..cf4ceb528 100644 --- a/scripts/qa/browser.mjs +++ b/scripts/qa/browser.mjs @@ -1,4 +1,19 @@ import { processTree, readProcesses } from './process.mjs'; +import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from '../qa-chrome.mjs'; +/** Keep every scenario on the same headless GPU path. */ +export function launchBrowser({ args = [], ...options } = {}) { + return puppeteer.launch({ + executablePath: chromeExecutablePath(), + ...options, + headless: true, + args: [ + `--use-angle=${process.env.QA_ANGLE || 'metal'}`, + '--enable-gpu', + ...args, + ], + }); +} /** Bound shutdown even when Chrome leaves descendants holding its pipes. */ export async function closeBrowser(browser, { deadlineMs = 5000 } = {}) { diff --git a/scripts/qa/browser.test.mjs b/scripts/qa/browser.test.mjs new file mode 100644 index 000000000..9af359739 --- /dev/null +++ b/scripts/qa/browser.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import puppeteer from 'puppeteer'; +import { launchBrowser } from './browser.mjs'; + +test('scenario launch uses the selected ANGLE backend and stays headless', async (t) => { + const prior = process.env.QA_ANGLE; + t.after(() => { + if (prior === undefined) delete process.env.QA_ANGLE; + else process.env.QA_ANGLE = prior; + }); + process.env.QA_ANGLE = 'swiftshader'; + const browser = {}; + let options; + t.mock.method(puppeteer, 'launch', async (value) => { + options = value; + return browser; + }); + assert.equal( + await launchBrowser({ + headless: false, + args: ['--no-sandbox'], + defaultViewport: { width: 800, height: 600 }, + protocolTimeout: 120000, + }), + browser, + ); + assert.equal(options.headless, true); + assert.deepEqual(options.args, [ + '--use-angle=swiftshader', + '--enable-gpu', + '--no-sandbox', + ]); + assert.deepEqual(options.defaultViewport, { width: 800, height: 600 }); + assert.equal(options.protocolTimeout, 120000); + delete process.env.QA_ANGLE; + await launchBrowser(); + assert.equal(options.args[0], '--use-angle=metal'); +}); diff --git a/scripts/qa/geometry.mjs b/scripts/qa/geometry.mjs new file mode 100644 index 000000000..c89910f50 --- /dev/null +++ b/scripts/qa/geometry.mjs @@ -0,0 +1,38 @@ +import { build } from 'vite'; +import { fileURLToPath } from 'node:url'; + +let bundle; + +/** Supply geometry to page callbacks without requiring a dev server's /src routes. */ +export async function evaluateWithGeo(page, callback, ...args) { + const evaluate = new Function( + '...args', + `${await geometryBundle()}\nreturn (${callback})(qaGeo, ...args);`, + ); + return page.evaluate(evaluate, ...args); +} + +/** A self-contained predicate for page-side polling as well as evaluation. */ +export async function withGeo(callback, ...args) { + return `(() => { ${await geometryBundle()}\nreturn (${callback})(qaGeo, ...${JSON.stringify(args)}); })()`; +} + +function geometryBundle() { + bundle ??= build({ + configFile: false, + envFile: false, + logLevel: 'silent', + build: { + write: false, + minify: false, + lib: { + entry: fileURLToPath( + new URL('../../src/geo/index.js', import.meta.url), + ), + name: 'qaGeo', + formats: ['iife'], + }, + }, + }).then((result) => result[0].output[0].code); + return bundle; +} diff --git a/scripts/qa/geometry.test.mjs b/scripts/qa/geometry.test.mjs new file mode 100644 index 000000000..bfa043150 --- /dev/null +++ b/scripts/qa/geometry.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import { evaluateWithGeo } from './geometry.mjs'; + +test('page geometry works without source routes or native engine constructors', async () => { + const page = { + evaluate: (callback, ...args) => + vm.runInNewContext(`(${callback})(...${JSON.stringify(args)})`), + }; + const result = await evaluateWithGeo( + page, + (geo, lon, lat, height) => { + const point = geo.cartesian3.fromDegrees(lon, lat, height); + return geo.cartographic.fromCartesian(point); + }, + 23, + -12, + 650, + ); + assert.ok(Math.abs(result.longitude - (23 * Math.PI) / 180) < 1e-12); + assert.ok(Math.abs(result.latitude + (12 * Math.PI) / 180) < 1e-12); + assert.ok(Math.abs(result.height - 650) < 1e-6); +}); diff --git a/scripts/qa/ready.mjs b/scripts/qa/ready.mjs index 25814c04b..bcac3dc97 100644 --- a/scripts/qa/ready.mjs +++ b/scripts/qa/ready.mjs @@ -120,3 +120,94 @@ export function waitForSettled(createState, { frames, timeoutMs }) { engine.render.request('qa-readiness'); }); } +/** Wait for drawn frames when the scenario deliberately keeps a follow or orbit moving. */ +export async function whenFramesDrawn( + page, + { frames = 3, timeoutMs = 30000 } = {}, +) { + return page.evaluate( + ({ frames, timeoutMs }) => + new Promise((resolve, reject) => { + const { engine } = window.__godsEyeView; + let drawn = 0; + const timer = setTimeout(() => { + remove(); + reject(new Error('Readiness timeout: drawn frames')); + }, timeoutMs); + const remove = engine.frames.postRender.addEventListener(() => { + if (++drawn >= frames) { + clearTimeout(timer); + remove(); + resolve(); + } else { + queueMicrotask(() => engine.render.request('qa-drawn-frames')); + } + }); + engine.render.request('qa-drawn-frames'); + }), + { frames, timeoutMs }, + ); +} +/** Tile readiness is independent of camera rest, including a keyless globe. */ +export async function whenTilesLoaded(page, { timeoutMs = 30000 } = {}) { + const handle = await page.waitForFunction( + () => window.__godsEyeView?.engine?.surface.tilesLoaded(), + { timeout: timeoutMs, polling: 100 }, + ); + await handle.dispose(); +} +/** Wait for finite panel transitions without waiting on decorative looping animations. */ +export async function whenUiSettled(page) { + const handle = await page.waitForFunction( + () => { + const app = window.__godsEyeView; + app?.engine?.render.request('qa-ui-readiness'); + return ( + !app?.styleManager?.transitions?.size && + document + .getAnimations() + .every( + (animation) => + animation.playState !== 'running' || + animation.effect.getTiming().iterations === Infinity, + ) + ); + }, + { polling: 'raf', timeout: 15000 }, + ); + await handle.dispose(); +} +/** Predicate polling keeps timeout-as-evidence scenarios explicit. */ +export async function pollPage( + page, + predicate, + { timeoutMs, intervalMs = 250, args = [] }, +) { + let handle; + try { + handle = await page.waitForFunction( + predicate, + { timeout: timeoutMs, polling: intervalMs }, + ...args, + ); + return true; + } catch (error) { + if (error.name === 'TimeoutError') return false; + throw error; + } finally { + await handle?.dispose(); + } +} +/** Poll scenario state outside the page without duplicating timeout loops. */ +export async function until( + predicate, + { timeoutMs = 30000, intervalMs = 100, label = 'scenario state' } = {}, +) { + const deadline = Date.now() + timeoutMs; + do { + const value = await predicate(); + if (value) return value; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } while (Date.now() < deadline); + throw new Error(`Readiness timeout: ${label}`); +} diff --git a/scripts/qa/ready.test.mjs b/scripts/qa/ready.test.mjs new file mode 100644 index 000000000..f82153e38 --- /dev/null +++ b/scripts/qa/ready.test.mjs @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createFakeEngine } from '../../src/globe/testing/fakeEngine.js'; +import { + whenFramesDrawn, + whenTilesLoaded, + whenUiSettled, + pollPage, + until, +} from './ready.mjs'; + +test('frame readiness advances a request-mode engine and removes its listener', async (t) => { + const engine = createFakeEngine(); + const previous = globalThis.window; + globalThis.window = { __godsEyeView: { engine } }; + t.after(() => { + if (previous === undefined) delete globalThis.window; + else globalThis.window = previous; + engine.destroy(); + }); + let frames = 0; + t.mock.method(engine.render, 'request', () => + queueMicrotask(() => { + frames++; + engine.fake.frame(); + }), + ); + const page = { evaluate: (fn, value) => fn(value) }; + await whenFramesDrawn(page, { frames: 3, timeoutMs: 1000 }); + assert.equal(frames, 3); + engine.fake.frame(); + await Promise.resolve(); + assert.equal(frames, 3, 'completion does not leave a render pump'); + t.mock.method(engine.render, 'request', () => {}); + await assert.rejects( + whenFramesDrawn(page, { timeoutMs: 5 }), + /Readiness timeout: drawn frames/, + ); +}); + +test('page readiness disposes polling handles and only treats timeouts as absence', async () => { + let disposed = 0; + const page = { + waitForFunction: async () => ({ + dispose: async () => { + disposed++; + }, + }), + }; + await whenTilesLoaded(page); + await whenUiSettled(page); + assert.equal(await pollPage(page, () => true, { timeoutMs: 10 }), true); + assert.equal(disposed, 3); + page.waitForFunction = async () => { + throw Object.assign(new Error('late'), { name: 'TimeoutError' }); + }; + assert.equal(await pollPage(page, () => false, { timeoutMs: 10 }), false); + page.waitForFunction = async () => { + throw new Error('page closed'); + }; + await assert.rejects( + pollPage(page, () => true, { timeoutMs: 10 }), + /page closed/, + ); +}); + +test('scenario polling waits for evidence and identifies an unmet condition', async () => { + let calls = 0; + assert.equal( + await until(() => ++calls === 3 && 'ready', { intervalMs: 1 }), + 'ready', + ); + await assert.rejects( + until(() => false, { + timeoutMs: 5, + intervalMs: 1, + label: 'fixture response', + }), + /Readiness timeout: fixture response/, + ); + await assert.rejects( + until(() => { + throw new Error('broken predicate'); + }), + /broken predicate/, + ); +}); From ee697422c9e1859aa5e2ae7b4ea26c2e63f1cc8c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 16:43:58 +0400 Subject: [PATCH 2/6] Move QA scripts to the shared harness, batch 1 of 4 Each script launches and closes Chrome through scripts/qa/browser.mjs, waits on the application through scripts/qa/ready.mjs instead of the loading screen and fixed pauses, and reads the contract instead of Cesium where the check is not about Cesium itself. --- scripts/qa-action-buttons.mjs | 19 +- scripts/qa-admin-outlines.mjs | 53 ++--- scripts/qa-air-quality.mjs | 36 ++-- scripts/qa-aircraft-heading.mjs | 65 ++---- scripts/qa-alpr-journey.mjs | 41 ++-- scripts/qa-anime-sky.mjs | 26 +-- scripts/qa-application.mjs | 16 +- scripts/qa-attribution-b12.mjs | 41 ++-- scripts/qa-browserEvidence.mjs | 2 + scripts/qa-cables-overlay.mjs | 49 ++--- scripts/qa-cables-render-probe.mjs | 43 ++-- scripts/qa-cables-shot.mjs | 35 ++-- scripts/qa-camera-controls.mjs | 28 +-- scripts/qa-cctv-v2.mjs | 197 ++++++------------ scripts/qa-cockpit-plates.mjs | 65 ++---- scripts/qa-cockpit-utility.mjs | 53 ++--- scripts/qa-context-arrivals.mjs | 120 ++++------- scripts/qa-directions.mjs | 87 ++++---- scripts/qa-director-camera.mjs | 21 +- scripts/qa-director-interactions.mjs | 24 +-- scripts/qa-director-packs.mjs | 41 ++-- scripts/qa-director-sharing.mjs | 25 +-- scripts/qa-director-timing.mjs | 21 +- scripts/qa-draw-tool.mjs | 300 +++++++++++++-------------- scripts/qa-journey-recorder.mjs | 60 ++---- 25 files changed, 587 insertions(+), 881 deletions(-) diff --git a/scripts/qa-action-buttons.mjs b/scripts/qa-action-buttons.mjs index ef11e8ff2..dd22946e2 100644 --- a/scripts/qa-action-buttons.mjs +++ b/scripts/qa-action-buttons.mjs @@ -6,12 +6,13 @@ * native dispatch/disabled/teardown without providers or world calibration writes. * Real Scene authoring/playback remains covered by qa-scene-controls.mjs. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; -import puppeteer from 'puppeteer'; -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, - args: ['--use-gl=angle', '--use-angle=swiftshader'], + args: [], }); const page = await browser.newPage(); const errors = []; @@ -34,11 +35,10 @@ try { { waitUntil: 'domcontentloaded' }, ); await page.waitForFunction( - () => - window.__godsEyeView?.styleManager?._cctvControls && - document.getElementById('loading-screen')?.classList.contains('hidden'), + () => window.__godsEyeView?.styleManager?._cctvControls, { timeout: 60000 }, ); + await whenSettled(page); const inventory = await page.evaluate(() => { const ids = [ 'scene-new-btn', @@ -304,11 +304,10 @@ try { localized.searchParams.set('lang', 'ru'); await page.goto(localized.href, { waitUntil: 'domcontentloaded' }); await page.waitForFunction( - () => - window.__godsEyeView?.styleManager?._cctvControls && - document.getElementById('loading-screen')?.classList.contains('hidden'), + () => window.__godsEyeView?.styleManager?._cctvControls, { timeout: 60000 }, ); + await whenSettled(page); for (const panel of ['scene-panel', 'cctv-panel']) { await page.evaluate((panel) => { window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, { @@ -329,7 +328,7 @@ try { } check('no uncaught browser errors', errors.length === 0, errors); } finally { - await browser.close(); + await closeBrowser(browser); } console.log(`RESULT: ${failures} failures`); process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-admin-outlines.mjs b/scripts/qa-admin-outlines.mjs index 114daacad..5f030764b 100644 --- a/scripts/qa-admin-outlines.mjs +++ b/scripts/qa-admin-outlines.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// Cesium-only probes: DataSource and ground-primitive readiness validate the native outline adapter. /** * qa-admin-outlines.mjs — bundled state/province/county outlines, driven like a * user would drive them. @@ -23,11 +24,12 @@ * timings, frame-time p50/p95 and still-camera frame-difference statistics. * Exits nonzero on any failed assertion. */ +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import fs from 'node:fs/promises'; import path from 'node:path'; -import puppeteer from 'puppeteer'; import sharp from 'sharp'; const args = process.argv.slice(2); @@ -63,12 +65,11 @@ const result = { video: null, }; -const browser = await puppeteer.launch({ - headless: !args.includes('--headful'), +const browser = await launchBrowser({ + headless: true, protocolTimeout: 300_000, args: [ '--no-sandbox', - '--use-gl=angle', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', @@ -145,14 +146,10 @@ try { timeout: 60_000, }); await page.waitForFunction( - () => window.__godsEyeView?.viewer && window.__godsEyeView?.annotations, + () => window.__godsEyeView?.engine && window.__godsEyeView?.annotations, { timeout: 90_000, polling: 500 }, ); - await page.waitForFunction( - () => - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 90_000, polling: 250 }, - ); + await whenSettled(page); await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, ); @@ -162,7 +159,7 @@ try { { timeout: 10_000 }, ); result.renderer = await page.evaluate(() => { - const gl = window.__godsEyeView.viewer.scene.context._gl; + const gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2'); const debug = gl.getExtension('WEBGL_debug_renderer_info'); return debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) @@ -177,7 +174,7 @@ try { window.__qaOutlines = { fly(view, duration) { return new Promise((resolve) => { - viewer.camera.cancelFlight(); + window.__godsEyeView.engine.camera.cancelFlight(); viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees( view.lon, @@ -264,20 +261,7 @@ try { }); const settle = () => - page - .waitForFunction( - () => { - const { scene } = window.__godsEyeView.viewer; - for (let i = 0; i < scene.primitives.length; i++) { - const p = scene.primitives.get(i); - if (p?.show && typeof p.tilesLoaded === 'boolean' && !p.tilesLoaded) - return false; - } - return true; - }, - { timeout: 60_000, polling: 500 }, - ) - .catch(() => {}); + whenTilesLoaded(page, { timeoutMs: 60_000 }).catch(() => {}); async function shot(name) { await page.evaluate( @@ -317,11 +301,14 @@ try { // built the mark's primitives (each tick updates them before render). const { viewer } = window.__godsEyeView; await new Promise((resolve) => { - const off = viewer.scene.postRender.addEventListener(() => { - if (!viewer.dataSourceDisplay.ready) return; - off(); - resolve(); - }); + const off = + window.__godsEyeView.engine.frames.postRender.addEventListener( + () => { + if (!viewer.dataSourceDisplay.ready) return; + off(); + resolve(); + }, + ); }); const drawnMs = performance.now() - started; const mark = window.__godsEyeView.annotations @@ -343,7 +330,7 @@ try { target, flyTo, ); - if (flyTo) await new Promise((r) => setTimeout(r, 2200)); + if (flyTo) await whenSettled(page); await settle(); // Hold still: the drawn mark must not rebuild or grow. marker = `still:${name}`; @@ -551,7 +538,7 @@ try { .screenshot({ path: path.join(shots, 'admin-outlines-failure.png') }) .catch(() => {}); } finally { - await browser.close(); + await closeBrowser(browser); } if (recordVideo && frames.length > 1) { diff --git a/scripts/qa-air-quality.mjs b/scripts/qa-air-quality.mjs index 68e6bd505..817d91eb4 100644 --- a/scripts/qa-air-quality.mjs +++ b/scripts/qa-air-quality.mjs @@ -1,3 +1,6 @@ +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; /** * qa-air-quality.mjs — headless proof for the Air quality layer. * @@ -12,7 +15,6 @@ * Run: node scripts/qa-air-quality.mjs --url http://localhost:4173 */ -import puppeteer from 'puppeteer'; const argv = process.argv.slice(2); const getOpt = (name, dflt) => { @@ -27,19 +29,13 @@ function record(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)); async function main() { let exitCode = 0; const consoleErrors = []; - const browser = await puppeteer.launch({ - headless: 'new', - args: [ - '--use-gl=swiftshader', - '--enable-unsafe-swiftshader', - '--no-sandbox', - '--disable-dev-shm-usage', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--enable-unsafe-swiftshader', '--no-sandbox', '--disable-dev-shm-usage'], defaultViewport: { width: 1400, height: 900 }, }); @@ -52,10 +48,10 @@ async function main() { await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90000 }); await page.waitForFunction( - () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + () => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager, { timeout: 90000 }, ); - await sleep(1500); + await whenSettled(page); const stats = await page.evaluate(async () => { const dm = window.__godsEyeView.dataManager; @@ -106,26 +102,24 @@ async function main() { // The card's whole point is "highest readings in view", so put the camera // over Canada before asserting on it — the default view is over Austin, // where an empty in-view list is correct and proves nothing. - await page.evaluate(() => { - const gev = window.__godsEyeView; - const ellipsoid = gev.viewer.scene.globe.ellipsoid; + await evaluateWithGeo(page, async (qaGeo) => { const d2r = Math.PI / 180; try { - gev.viewer.camera.cancelFlight(); + window.__godsEyeView.engine.camera.cancelFlight(); } catch { /* no flight in progress */ } - gev.viewer.camera.setView({ - destination: ellipsoid.cartographicToCartesian({ + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: -100 * d2r, latitude: 55 * d2r, height: 4_000_000, }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); - gev.viewer.scene.requestRender?.(); + window.__godsEyeView.engine.render.request?.(); }); - await sleep(2500); + await whenSettled(page); const controls = await page.evaluate( () => @@ -154,7 +148,7 @@ async function main() { console.error('\x1b[31mHarness error:\x1b[0m', e); exitCode = 3; } finally { - await browser.close(); + await closeBrowser(browser); } const pass = results.filter((r) => r.ok === true).length; diff --git a/scripts/qa-aircraft-heading.mjs b/scripts/qa-aircraft-heading.mjs index 3bab0e4b1..26d77eecd 100644 --- a/scripts/qa-aircraft-heading.mjs +++ b/scripts/qa-aircraft-heading.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// Cesium-only probes: tracked entities and native model axes validate Cesium aircraft heading. /** * qa-aircraft-heading.mjs — aircraft display-heading checks * (path-derived, rate-limited display course; Batch 3 overnight run). @@ -97,9 +98,10 @@ * Exits non-zero if any assertion fails. Never commits anything. */ +import { whenSettled, whenFramesDrawn } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; import { installNavaraHeadingProbe } from './qa-heading-navara.mjs'; import { stubPlanespotters } from './qa-photo-stub.mjs'; @@ -124,26 +126,7 @@ const HEADFUL = getFlag('--headful'); const SHOT_DIR = path.resolve('qa-shots/aircraft-heading'); const SHOT_DIR_V2 = path.resolve('qa-shots/heading-v2'); -const CHROME_EXECUTABLE_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - // Prefer puppeteer's version-pinned Chrome-for-Testing over the system - // Chrome: /Applications auto-updates underneath the harnesses, and its - // software-GL behavior shifts across majors (system Chrome 150 blew the - // tile-gated drain budget under SwiftShader on 2026-07-30 — six - // false-negative qa-cctv-v2 runs against a healthy build). A deterministic - // pinned browser beats the newest one for regression harnesses. - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -function findChromeExecutable() { - for (const candidate of CHROME_EXECUTABLE_CANDIDATES) { - try { if (fs.existsSync(candidate)) return candidate; } catch { /* skip */ } - } - return null; -} // --------------------------------------------------------------------------- // PASS/FAIL reporting @@ -157,7 +140,7 @@ function record(name, ok, detail) { async function observeTrackedHostPaint(page, timeoutMs = 5000) { return page.evaluate((boundedTimeoutMs) => new Promise((resolve) => { - const viewer = (window.__headingView ?? window.__godsEyeView?.viewer); + const viewer = (window.__headingView ?? window.__godsEyeView?.engine); const scene = viewer?.scene; const history = []; const startedAt = performance.now(); @@ -455,20 +438,9 @@ async function main() { fs.mkdirSync(SHOT_DIR, { recursive: true }); fs.mkdirSync(SHOT_DIR_V2, { recursive: true }); - const chromeExecutable = findChromeExecutable(); - const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', - ...(chromeExecutable ? { executablePath: chromeExecutable } : {}), - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - ...(HEADFUL ? [] : ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`]), - '--disable-dev-shm-usage', - '--disable-web-security', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--window-size=1280,800', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', ...(HEADFUL ? [] : []), '--disable-dev-shm-usage', '--disable-web-security', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1280,800'], }); const consoleErrors = []; @@ -588,8 +560,9 @@ async function main() { () => window.__godsEyeView?.layers && window.__godsEyeView.dataManager, { timeout: 60000, polling: 200 } ); + await whenSettled(page); await page.evaluate(() => window.__godsEyeView.styleManager.initialRestorePromise); - await page.waitForFunction(() => document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 180000 }); + await whenSettled(page); await page.evaluate(() => window.__godsEyeView.styleManager.orbitController.stop()); console.log(' App globals ready.'); @@ -730,7 +703,7 @@ async function main() { }, { timeout: 40000, polling: 250 }, TURN.flights[0].icao).then(() => true).catch(() => false); record('flights: tracked 3D model rendered (ready+shown)', flModelUp, flModelUp ? 'model up' : 'model never became ready'); if (!flModelUp) { finish(); return; } - await sleep(1500); // let the follow camera + reconciliation settle + await whenFramesDrawn(page); // let the follow camera + reconciliation settle const readoutObservation = await observeTrackedHostPaint(page); const readoutHost = await page.evaluate(() => ({ @@ -780,7 +753,7 @@ async function main() { await page.evaluate(() => { window.__godsEyeView.styleManager._setDetectionMode('DENSE'); }); - await sleep(800); + await whenFramesDrawn(page); const shots = [ ['flights-far-orbitA.png', async () => { /* default follow framing */ }], ['flights-far-orbitB.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.rotateRight(1.9); })], @@ -794,7 +767,7 @@ async function main() { ]; for (const [name, move] of shots) { try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); } - await sleep(1200); // let a few frames render at the new pose + await whenFramesDrawn(page); // let a few frames render at the new pose await page.screenshot({ path: path.join(SHOT_DIR, name) }); console.log(` saved ${name}`); } @@ -819,7 +792,7 @@ async function main() { }, { timeout: 40000, polling: 250 }, TURN.military[0].hex).then(() => true).catch(() => false); record('military: tracked 3D model rendered (ready+shown)', milModelUp, milModelUp ? 'model up' : 'model never became ready'); if (milModelUp) { - await sleep(1500); + await whenFramesDrawn(page); const milSamples = await page.evaluate( (icao) => window.__sampleModelCourse(icao, 180, 40000), TURN.military[0].hex, @@ -841,7 +814,7 @@ async function main() { await page.evaluate(() => { window.__godsEyeView.styleManager._setDetectionMode('DENSE'); }); - await sleep(800); + await whenFramesDrawn(page); for (const [name, move] of [ ['military-orbitA.png', async () => { }], ['military-orbitB.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.rotateRight(1.6); })], @@ -855,7 +828,7 @@ async function main() { })], ]) { try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); } - await sleep(1200); + await whenFramesDrawn(page); await page.screenshot({ path: path.join(SHOT_DIR, name) }); console.log(` saved ${name}`); } @@ -892,7 +865,7 @@ async function main() { // ---- Phase 3 — hovering helicopter -------------------------------------- console.log('\nPhase 3 — hovering helicopter (klass=helicopter, drift ~4 m, track flips ±45°)'); if (await trackFlightsAndWaitModel(TURN.hover.icao, 'hover-heli')) { - await sleep(1500); + await whenFramesDrawn(page); const heliSamples = await sampleCourse(180, 35000, TURN.hover.icao); analyzeHover('hover-heli', heliSamples, {}); for (const [name, waitMs] of [['hover-heli-T0.png', 0], ['hover-heli-T8s.png', 8000]]) { @@ -905,7 +878,7 @@ async function main() { // ---- Phase 4 — 25 kt plane in a continuous 4°/s right turn -------------- console.log('\nPhase 4 — slow plane in a tight turn (25 kt, 4°/s, R=184 m)'); if (await trackFlightsAndWaitModel(TURN.slow.icao, 'slow-turn')) { - await sleep(1500); + await whenFramesDrawn(page); const slowSamples = await sampleCourse(180, 40000, TURN.slow.icao); analyzeSlowTurn('slow-turn', slowSamples, { scoreProgress: false }); // Exercise the production dt clamp with render gaps above @@ -923,7 +896,7 @@ async function main() { ['slowturn-near.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.zoomIn(2500); })], ]) { try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); } - await sleep(1200); + await whenFramesDrawn(page); await page.screenshot({ path: path.join(SHOT_DIR_V2, name) }); console.log(` saved ${name}`); } @@ -1097,7 +1070,7 @@ async function main() { finish(); } finally { - await browser.close(); + await closeBrowser(browser); } function finish() { diff --git a/scripts/qa-alpr-journey.mjs b/scripts/qa-alpr-journey.mjs index bc1c58855..b005e7254 100644 --- a/scripts/qa-alpr-journey.mjs +++ b/scripts/qa-alpr-journey.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// Cesium-only probes: requestRender instrumentation measures Cesium request-mode cost. /** * qa-alpr-journey.mjs — ALPR cameras on a realistic Austin camera journey. * @@ -36,9 +37,10 @@ * Writes /result.json, /clip.mp4 (CDP screencast with real frame * timing) and per-stop screenshots. Needs ffmpeg on PATH. */ +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; import { blockPaidQaMaps } from './qa-map-policy.mjs'; import sharp from 'sharp'; import { installAlprFixture } from './fixtures/layers/alpr.mjs'; @@ -163,14 +165,12 @@ const STOPS = [ }, ]; -const browser = await puppeteer.launch({ - headless: !argv.includes('--headful'), +const browser = await launchBrowser({ + headless: true, protocolTimeout: 300_000, args: [ '--no-sandbox', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', @@ -233,12 +233,10 @@ try { const url = new URL(appUrl); url.searchParams.set('welcome', '0'); await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 90_000 }); - await page.waitForFunction( - () => - window.__godsEyeView?.dataManager && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 120_000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.dataManager, { + timeout: 120_000, + }); + await whenSettled(page); await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, ); @@ -286,22 +284,7 @@ try { await recorder.start(); async function waitTiles(maxMs = 20_000) { - await page - .waitForFunction( - () => { - if (window.__godsEyeView.engine.kind === 'navara') - return window.__godsEyeView.engine.surface.tilesLoaded(); - const { scene } = window.__godsEyeView.viewer; - if (scene.globe.show) return scene.globe.tilesLoaded; - for (let i = 0; i < scene.primitives.length; i++) { - const p = scene.primitives.get(i); - if (p.show && p.tilesLoaded === false) return false; - } - return true; - }, - { timeout: maxMs, polling: 250 }, - ) - .catch(() => {}); + await whenTilesLoaded(page, { timeoutMs: maxMs }).catch(() => {}); } for (const [passIndex, mode] of passes.entries()) { @@ -653,7 +636,7 @@ try { path.join(outDir, 'result.json'), JSON.stringify(result, null, 1), ); - await browser.close(); + await closeBrowser(browser); } console.log( `${result.failures.length ? 'FAIL' : 'PASS'} — ${path.join(outDir, 'result.json')}`, diff --git a/scripts/qa-anime-sky.mjs b/scripts/qa-anime-sky.mjs index a0a0c9935..922cd1371 100644 --- a/scripts/qa-anime-sky.mjs +++ b/scripts/qa-anime-sky.mjs @@ -1,4 +1,7 @@ #!/usr/bin/env node +// Cesium-only probes: sky commands, the Julian clock and post-process stages validate the Cesium sky. +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; /** * qa-anime-sky — the anime style must leave the sky without bands. * @@ -13,14 +16,13 @@ * Usage: node scripts/qa-anime-sky.mjs [--url http://localhost:4333/] * Exit: 0 pass, 1 fail, 2 the anime style was not reached. */ -import puppeteer from 'puppeteer'; const i = process.argv.indexOf('--url'); const URL_ = i > 0 && process.argv[i + 1] ? process.argv[i + 1] : 'http://localhost:4333/'; -const browser = await puppeteer.launch({ - headless: 'new', +const browser = await launchBrowser({ + headless: true, args: ['--no-sandbox', '--ignore-gpu-blocklist'], }); try { @@ -41,17 +43,15 @@ try { await page.waitForFunction(() => window.__godsEyeView?.viewer?.scene, { timeout: 120000, }); + await whenSettled(page); await page.waitForFunction( () => { const sky = window.__godsEyeView.viewer.scene.skyBox; - return ( - sky?._milkyWayCubeMap && - sky?._starCommand && - document.getElementById('loading-screen')?.classList.contains('hidden') - ); + return sky?._milkyWayCubeMap && sky?._starCommand; }, { timeout: 60000 }, ); + await whenSettled(page); const r = await page.evaluate(async () => { const entry = performance .getEntriesByType('resource') @@ -94,22 +94,22 @@ try { C.Cartesian3.cross(look, north, new C.Cartesian3()), new C.Cartesian3(), ); - s.camera.cancelFlight(); + window.__godsEyeView.engine.camera.cancelFlight(); s.tweens.removeAll(); - s.camera.setView({ + window.__godsEyeView.engine.camera.setView({ destination: C.Cartesian3.multiplyByScalar(look, 3e7, new C.Cartesian3()), orientation: { direction: look, up: C.Cartesian3.cross(right, look, new C.Cartesian3()), }, }); - const gl = s.context._gl, + const gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2'), w = gl.drawingBufferWidth, h = gl.drawingBufferHeight, px = new Uint8Array(w * h * 4); const measure = async () => { await new Promise((res) => setTimeout(res, 2500)); - s.requestRender(); + window.__godsEyeView.engine.render.request(); s.render(); gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); let banded = 0; @@ -156,5 +156,5 @@ try { console.error(`UNREACHED: ${error.message}`); process.exitCode = 2; } finally { - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-application.mjs b/scripts/qa-application.mjs index 41b77c8dc..23ae2ea77 100644 --- a/scripts/qa-application.mjs +++ b/scripts/qa-application.mjs @@ -1,16 +1,15 @@ #!/usr/bin/env node +import { whenSettled } from './qa/ready.mjs'; /** Prove standalone startup and terminal resource ownership in a real browser. */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; -import puppeteer from 'puppeteer'; const args = process.argv.slice(2); const urlIndex = args.indexOf('--url'); const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173'; -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, - executablePath: - process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()), - args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'], + args: ['--no-sandbox'], }); try { const page = await browser.newPage(); @@ -28,6 +27,7 @@ try { await page.waitForFunction(() => window.__godsEyeView?.voiceCommands, { timeout: 60_000, }); + await whenSettled(page); const before = await page.evaluate(async () => { const entry = [...document.scripts].find((script) => /\/src\/main\.js(?:\?|$)/.test(script.src), @@ -52,7 +52,7 @@ try { enabled, layers: app.dataManager.layers.size, annotationCount: annotation.drawn, - viewerAlive: !app.viewer.isDestroyed(), + viewerAlive: !window.__godsEyeView.engine.isDestroyed(), credits: Boolean(document.querySelector('#cesium-credits')), }; }); @@ -83,7 +83,7 @@ try { return { status: application.getState().status, samePromise, - viewerDestroyed: app.viewer.isDestroyed(), + viewerDestroyed: app.engine.isDestroyed(), layers: app.dataManager.layers.size, annotations: app.annotations.count(), governor: app.getRenderGovernorDiagnostics(), @@ -112,5 +112,5 @@ try { 'PASS: terminal teardown releases runtime owners without late browser errors', ); } finally { - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-attribution-b12.mjs b/scripts/qa-attribution-b12.mjs index 8875f1e19..97ae042cc 100644 --- a/scripts/qa-attribution-b12.mjs +++ b/scripts/qa-attribution-b12.mjs @@ -1,3 +1,5 @@ +// Cesium-only probes: the native credit registry and lightbox are the attribution oracle. +import { whenSettled, whenUiSettled } from './qa/ready.mjs'; /** * qa-attribution-b12.mjs — visual + state proof for Batch 12 (data attribution). * @@ -16,7 +18,7 @@ * * Run: node scripts/qa-attribution-b12.mjs --url http://localhost:4300 */ -import puppeteer from 'puppeteer'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import { existsSync, mkdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; @@ -32,19 +34,7 @@ const APP_ORIGIN = new URL(APP_URL).origin; const SHOT_DIR = resolve(__dirname, '..', 'qa-shots', 'b12'); mkdirSync(SHOT_DIR, { recursive: true }); -const CHROME_EXECUTABLE_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -function findChromeExecutable() { - return CHROME_EXECUTABLE_CANDIDATES.find((candidate) => { - try { return existsSync(candidate); } catch { return false; } - }) || null; -} let passed = 0; let failed = 0; @@ -70,10 +60,8 @@ const REQUIRED_CREDIT_SUBSTRINGS = [ ]; async function main() { - const chromeExecutable = findChromeExecutable(); - const browser = await puppeteer.launch({ - headless: 'new', - ...(chromeExecutable ? { executablePath: chromeExecutable } : {}), + const browser = await launchBrowser({ + headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'], }); const page = await browser.newPage(); @@ -122,8 +110,9 @@ async function main() { () => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.viewer.creditDisplay, { timeout: 60000 }, ); + await whenSettled(page); // Give Cesium a few frames to render the on-screen credit line (logo + link). - await new Promise((r) => setTimeout(r, 2500)); + await whenUiSettled(page); // ── (i) H11: static credits registered ──────────────────────────── console.log('H11 — per-layer credits registered in viewer.creditDisplay'); @@ -147,11 +136,11 @@ async function main() { // Force a render frame so the credit display flushes, then open the lightbox. const lightboxState = await page.evaluate(async () => { const viewer = window.__godsEyeView.viewer; - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); const cd = viewer.creditDisplay; cd.showLightbox(); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); const box = document.querySelector('.cesium-credit-lightbox'); const list = box?.querySelector(':scope > ul'); @@ -263,7 +252,7 @@ async function main() { await page.evaluate(async () => { const viewer = window.__godsEyeView.viewer; viewer.resize(); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); }); const mobileLightbox = await page.evaluate(() => { @@ -311,7 +300,7 @@ async function main() { check('found datacenter + cable layer ids', !!dcId && !!cableId, `dc=${dcId} cable=${cableId}`); if (dcId) await page.evaluate((id) => window.__godsEyeView.dataManager.setEnabled(id, true), dcId); if (cableId) await page.evaluate((id) => window.__godsEyeView.dataManager.setEnabled(id, true), cableId); - await new Promise((r) => setTimeout(r, 800)); + await whenUiSettled(page); const afterEnableHtml = await page.evaluate(() => (window.__godsEyeView.viewer.creditDisplay._staticCredits || []).map((c) => c.html), ); @@ -348,7 +337,7 @@ async function main() { // ── (iii) clean-view: credit line STILL visible ─────────────────── console.log('\nH10 — clean-view keeps the credit line visible'); await page.evaluate(() => document.body.classList.add('ui-clean-view')); - await new Promise((r) => setTimeout(r, 400)); + await whenUiSettled(page); const cleanVis = await creditVisibility(); await page.screenshot({ path: resolve(SHOT_DIR, 'clean-view.png') }); check( @@ -366,7 +355,7 @@ async function main() { // ── (iv) recording-mode: credit line STILL visible ──────────────── console.log('\nH10 — recording-mode keeps the credit line visible'); await page.evaluate(() => document.body.classList.add('recording-mode')); - await new Promise((r) => setTimeout(r, 400)); + await whenUiSettled(page); const recVis = await creditVisibility(); await page.screenshot({ path: resolve(SHOT_DIR, 'recording-mode.png') }); check( @@ -377,7 +366,7 @@ async function main() { await page.evaluate(() => document.body.classList.remove('recording-mode')); // baseline (normal) screenshot for comparison - await new Promise((r) => setTimeout(r, 300)); + await whenUiSettled(page); await page.screenshot({ path: resolve(SHOT_DIR, 'normal.png') }); check( @@ -391,7 +380,7 @@ async function main() { failedResponses.slice(0, 3).join(' | ') || 'clean', ); - await browser.close(); + await closeBrowser(browser); console.log('\n────────────────────────────────────────────────────────────'); console.log(` RESULT: ${passed} passed, ${failed} failed`); diff --git a/scripts/qa-browserEvidence.mjs b/scripts/qa-browserEvidence.mjs index e2d9677b9..af1ca0217 100644 --- a/scripts/qa-browserEvidence.mjs +++ b/scripts/qa-browserEvidence.mjs @@ -105,6 +105,8 @@ export function hookRenderErrors(page) { return page.evaluateOnNewDocument(() => { window.__qaRenderErrors = []; const hook = () => { + // Cesium-only: its render loop catches errors before pageerror sees them. + if (window.__godsEyeView?.engine?.kind === 'navara') return true; const scene = window.__godsEyeView?.viewer?.scene; if (!scene?.renderError) return false; scene.renderError.addEventListener((_, error) => diff --git a/scripts/qa-cables-overlay.mjs b/scripts/qa-cables-overlay.mjs index e5f0cf834..59a65c6b1 100644 --- a/scripts/qa-cables-overlay.mjs +++ b/scripts/qa-cables-overlay.mjs @@ -1,4 +1,8 @@ #!/usr/bin/env node +// Cesium-only probes: native DataSource counts and scene-render timing measure the Cesium cable adapter. +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; /** * qa-cables-overlay — submarine-cable label path measurement + contract gate. * @@ -20,7 +24,6 @@ * Requires a running dev server. Headless; rAF throttling disabled so the * frame clock is honest (hidden-pane gotcha). */ -import puppeteer from 'puppeteer'; const argv = process.argv; const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214'; @@ -40,35 +43,26 @@ function report(name, detail) { console.log(` [MEAS] ${name} — ${JSON.stringify(detail)}`); } -const browser = await puppeteer.launch({ - headless: 'new', +const browser = await launchBrowser({ + headless: true, protocolTimeout: 300_000, - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--window-size=1440,900', - '--disable-backgrounding-occluded-windows', - '--disable-renderer-backgrounding', - '--disable-background-timer-throttling', - ], + args: ['--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-background-timer-throttling'], }); try { const page = await browser.newPage(); await page.setViewport({ width: 1440, height: 860 }); await page.goto(url, { waitUntil: 'domcontentloaded' }); - await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 }); - await new Promise((r) => setTimeout(r, 12_000)); // boot flyTo + deferred init + await page.waitForFunction(() => !!window.__godsEyeView?.engine, { timeout: 90_000 }); + await whenSettled(page); // boot flyTo + deferred init // Park mid-Atlantic (many cables + both coasts' landings in range) and // disable every layer so the cables layer is measured in isolation. - await page.evaluate(async () => { + await evaluateWithGeo(page, async (qaGeo) => { const gev = window.__godsEyeView; - const v = gev.viewer; - v.camera.cancelFlight(); - const ell = v.scene.globe.ellipsoid; - v.camera.setView({ - destination: ell.cartographicToCartesian({ + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: -40 * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_000, }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, @@ -77,7 +71,7 @@ try { if (entry.enabled) { try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* measured via counts */ } } } }); - await new Promise((r) => setTimeout(r, 6_000)); // tiles + fades settle + await whenSettled(page); // tiles + fades settle // ── b. toggle-ON → labels visible ───────────────────────────────────── if (!control) { @@ -96,14 +90,14 @@ try { }; while (performance.now() < deadline) { if (labelsUp()) return { ms: Math.round(performance.now() - t0), timedOut: false }; - gev.viewer.scene.requestRender?.(); + window.__godsEyeView.engine.render.request?.(); await new Promise((r) => setTimeout(r, 100)); } return { ms: 60_000, timedOut: true }; }, LAYER_ID, legacy); report('toggle-ON -> labels visible (ms)', labelLatency); check('labels became visible after enable', labelLatency.timedOut === false, labelLatency); - await new Promise((r) => setTimeout(r, 3_000)); // let load/labels settle + await whenSettled(page); // let load/labels settle } // ── c. drawn-set / label / host counts ──────────────────────────────── @@ -190,11 +184,10 @@ try { : 'scene.render cost, cables ON, 10s orbit', frameCost); // ── d. parked idle honesty (labels must not force continuous render) ── - await new Promise((r) => setTimeout(r, 4_000)); // orbit stop + fades settle + await whenSettled(page); // orbit stop + fades settle const idle = await page.evaluate(() => new Promise((resolve) => { - const scene = window.__godsEyeView.viewer.scene; let renders = 0; - const remove = scene.postRender.addEventListener(() => { renders += 1; }); + const remove = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { renders += 1; }); setTimeout(() => { remove(); resolve({ renders }); }, 5_000); })); report(control @@ -224,7 +217,7 @@ try { const cycle = await page.evaluate(async (layerId) => { const gev = window.__godsEyeView; await gev.dataManager.setEnabled(layerId, false, { origin: 'user' }); - gev.viewer.scene.requestRender?.(); + window.__godsEyeView.engine.render.request?.(); await new Promise((r) => setTimeout(r, 1_200)); const offDiag = window.__gevWorldOverlay?.getDiagnostics?.() || {}; const offEntries = offDiag.entriesBySource?.[layerId] || 0; @@ -236,7 +229,7 @@ try { const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {}; onPainted = diag.paintedBySource?.[layerId] || 0; if (onPainted > 0) break; - gev.viewer.scene.requestRender?.(); + window.__godsEyeView.engine.render.request?.(); await new Promise((r) => setTimeout(r, 100)); } return { offEntries, offPainted, onPainted }; @@ -245,7 +238,7 @@ try { check('re-enable restores painted labels', cycle.onPainted > 0, cycle); } } finally { - await browser.close(); + await closeBrowser(browser); } const passed = results.filter((r) => r.pass).length; diff --git a/scripts/qa-cables-render-probe.mjs b/scripts/qa-cables-render-probe.mjs index babef945d..c174e5080 100644 --- a/scripts/qa-cables-render-probe.mjs +++ b/scripts/qa-cables-render-probe.mjs @@ -1,4 +1,8 @@ #!/usr/bin/env node +// Cesium-only probes: manual native renders isolate Cesium cable render cost. +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; /** * qa-cables-render-probe — replicates the owner-side perf probe methodology * for the cables layer (relative numbers; SwiftShader when run headless): @@ -11,35 +15,29 @@ * * Usage: node scripts/qa-cables-render-probe.mjs [--url http://localhost:4216] */ -import puppeteer from 'puppeteer'; const argv = process.argv; const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4216'; const LAYER_ID = 'telegeography-submarine-cables'; -const browser = await puppeteer.launch({ - headless: 'new', +const browser = await launchBrowser({ + headless: true, protocolTimeout: 300_000, - args: [ - '--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900', - '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', - '--disable-background-timer-throttling', - ], + args: ['--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-background-timer-throttling'], }); try { const page = await browser.newPage(); await page.setViewport({ width: 1440, height: 860 }); await page.goto(url, { waitUntil: 'domcontentloaded' }); - await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 }); - await new Promise((r) => setTimeout(r, 12_000)); + await page.waitForFunction(() => !!window.__godsEyeView?.engine, { timeout: 90_000 }); + await whenSettled(page); - await page.evaluate(async () => { + await evaluateWithGeo(page, async (qaGeo) => { const gev = window.__godsEyeView; - gev.viewer.camera.cancelFlight(); - const ell = gev.viewer.scene.globe.ellipsoid; - gev.viewer.camera.setView({ - destination: ell.cartographicToCartesian({ + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: -40 * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_000, }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, @@ -48,7 +46,7 @@ try { if (entry.enabled) { try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* probe */ } } } }); - await new Promise((r) => setTimeout(r, 5_000)); + await whenSettled(page); const stats = (values) => ({ mean: +(values.reduce((s, v) => s + v, 0) / Math.max(1, values.length)).toFixed(2), @@ -60,7 +58,7 @@ try { const gev = window.__godsEyeView; await gev.dataManager.setEnabled(id, on, { origin: 'user' }); }, LAYER_ID, enabled); - await new Promise((r) => setTimeout(r, enabled ? 6_000 : 3_000)); // load/teardown + settle + await whenSettled(page); // load/teardown + settle // 1. PARKED timed renders. const parked = await page.evaluate(() => { @@ -77,18 +75,17 @@ try { // 2. PAN-STOP cycles: move, raise moveEnd, wait 650 ms, 20 timed renders. const cycles = []; for (let cycle = 0; cycle < 3; cycle++) { - const frames = await page.evaluate(async (step) => { + const frames = await evaluateWithGeo(page, async (qaGeo, step) => { const v = window.__godsEyeView.viewer; - const ell = v.scene.globe.ellipsoid; - v.camera.setView({ - destination: ell.cartographicToCartesian({ + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: (-40 + step * 3) * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_000, }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); - v.camera.moveEnd.raiseEvent(); + window.__godsEyeView.engine.camera.moveEnd.raiseEvent(); await new Promise((resolve) => setTimeout(resolve, 650)); const durations = []; for (let i = 0; i < 20; i++) { @@ -112,5 +109,5 @@ try { console.log(`\nparked delta (ON-OFF mean): ${(on.parked.mean - off.parked.mean).toFixed(2)}ms`); console.log(`pan-stop worst: ON ${on.worst}ms vs OFF ${off.worst}ms`); } finally { - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-cables-shot.mjs b/scripts/qa-cables-shot.mjs index 3e3008e03..a9b0cbbd8 100644 --- a/scripts/qa-cables-shot.mjs +++ b/scripts/qa-cables-shot.mjs @@ -1,10 +1,12 @@ #!/usr/bin/env node +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled } from './qa/ready.mjs'; /** * qa-cables-shot — capture the cables layer at two fixed cameras for * before/after visual-identity comparison. Writes to gitignored qa-shots/. * Usage: node scripts/qa-cables-shot.mjs [--url http://localhost:4214] [--tag before] */ -import puppeteer from 'puppeteer'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import { mkdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; @@ -14,24 +16,20 @@ const tag = argv.includes('--tag') ? argv[argv.indexOf('--tag') + 1] : 'shot'; const LAYER_ID = 'telegeography-submarine-cables'; mkdirSync(new URL('../qa-shots', import.meta.url), { recursive: true }); -const browser = await puppeteer.launch({ - headless: 'new', +const browser = await launchBrowser({ + headless: true, protocolTimeout: 300_000, - args: [ - '--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900', - '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', - '--disable-background-timer-throttling', - ], + args: ['--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-background-timer-throttling'], }); try { const page = await browser.newPage(); await page.setViewport({ width: 1440, height: 860 }); await page.goto(url, { waitUntil: 'domcontentloaded' }); - await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 }); - await new Promise((r) => setTimeout(r, 12_000)); + await page.waitForFunction(() => !!window.__godsEyeView?.engine, { timeout: 90_000 }); + await whenSettled(page); await page.evaluate(async (layerId) => { const gev = window.__godsEyeView; - gev.viewer.camera.cancelFlight(); + window.__godsEyeView.engine.camera.cancelFlight(); for (const [id, entry] of gev.dataManager.layers) { if (entry.enabled && id !== layerId) { try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* shot only */ } @@ -45,11 +43,9 @@ try { { name: 'ny-coast', lon: -73.5, lat: 40.4, height: 500_000 }, ]; for (const view of views) { - await page.evaluate((v) => { - const viewer = window.__godsEyeView.viewer; - const ell = viewer.scene.globe.ellipsoid; - viewer.camera.setView({ - destination: ell.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo, v) => { + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: v.lon * Math.PI / 180, latitude: v.lat * Math.PI / 180, height: v.height, }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, @@ -57,15 +53,14 @@ try { }, view); // Let the sweep + labels settle with frames flowing. await page.evaluate(() => new Promise((resolve) => { - const v = window.__godsEyeView.viewer; let ticks = 0; const tick = () => { - v.scene.requestRender?.(); + window.__godsEyeView.engine.render.request?.(); if (++ticks < 240) requestAnimationFrame(tick); else resolve(); }; requestAnimationFrame(tick); })); - await new Promise((r) => setTimeout(r, 1_000)); + await whenSettled(page); const path = fileURLToPath( new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url), ); @@ -73,5 +68,5 @@ try { console.log(`saved ${path}`); } } finally { - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-camera-controls.mjs b/scripts/qa-camera-controls.mjs index c2d2b5646..7dbe8111f 100644 --- a/scripts/qa-camera-controls.mjs +++ b/scripts/qa-camera-controls.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node /** Camera-panel browser acceptance with controlled sources, images and terrain. */ +import { whenSettled, whenUiSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; -import puppeteer from 'puppeteer'; const origin = process.env.QA_BASE_URL || 'http://localhost:4173'; const selectAllModifier = process.platform === 'darwin' ? 'Meta' : 'Control'; const sources = [ @@ -31,13 +32,11 @@ const sources = [ rangeM: 300, mountHeightM: 8, })); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: [ '--no-sandbox', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), ], }); const page = await browser.newPage(); @@ -108,10 +107,10 @@ try { await page.waitForFunction( () => window.__godsEyeView?.styleManager?._cctvControls && - window.__godsEyeView?.dataManager?.layers?.has('cctv') && - document.getElementById('loading-screen')?.classList.contains('hidden'), + window.__godsEyeView?.dataManager?.layers?.has('cctv'), { timeout: 60000 }, ); + await whenSettled(page); await page.$eval('[data-collapse-target="cctv-panel"]', (button) => button.click(), ); @@ -357,22 +356,23 @@ try { fs.mkdirSync('qa-shots/camera-controls', { recursive: true }); await page.screenshot({ path: 'qa-shots/camera-controls/desktop.png' }); await page.evaluate(() => { - const camera = window.__godsEyeView.viewer.camera; - camera.setView({ + window.__godsEyeView.engine.camera.setView({ + destination: window.__godsEyeView.engine.camera.pose().position, orientation: { - heading: camera.heading + Math.PI / 3, + heading: + window.__godsEyeView.engine.camera.pose().heading + Math.PI / 3, pitch: -0.6, roll: 0, }, }); }); - await new Promise((resolve) => setTimeout(resolve, 700)); + await whenUiSettled(page); await page.screenshot({ path: 'qa-shots/camera-controls/angle.png' }); await page.setViewport({ width: 390, height: 844 }); - await new Promise((resolve) => setTimeout(resolve, 700)); + await whenUiSettled(page); // Phones present CCTV in the Cameras tab's sheet. await page.click('#mobile-tab-bar [data-mobile-sheet="cameras"]'); - await new Promise((resolve) => setTimeout(resolve, 400)); + await whenUiSettled(page); await page.screenshot({ path: 'qa-shots/camera-controls/narrow.png' }); check( 'narrow camera panel remains within the viewport', @@ -411,6 +411,6 @@ try { ); } finally { await Promise.all([...delayedFrames].map((respond) => respond())); - await browser.close(); + await closeBrowser(browser); } if (failures) process.exitCode = 1; diff --git a/scripts/qa-cctv-v2.mjs b/scripts/qa-cctv-v2.mjs index 712d8c865..21a95c841 100644 --- a/scripts/qa-cctv-v2.mjs +++ b/scripts/qa-cctv-v2.mjs @@ -1,3 +1,6 @@ +// Cesium-only probes: native raycast fault injection, camera transforms and entity sentinels test the Cesium adapter. +import { withGeo } from './qa/geometry.mjs'; +import { whenFramesDrawn, whenSettled, pollPage, whenTilesLoaded } from './qa/ready.mjs'; /** * qa-cctv-v2.mjs — CCTV v2 subsystem proof harness * @@ -76,7 +79,7 @@ * Exit 0 = all asserts passed. Non-zero = a hard failure. */ -import puppeteer from 'puppeteer'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import { watchBrowserPage, redact } from './qa-browserEvidence.mjs'; import { blockPaidQaMaps } from './qa-map-policy.mjs'; import { installCctvFixture } from './fixtures/layers/cctv.mjs'; @@ -126,30 +129,7 @@ const saveEvidence = () => { ); }; -const CHROME_EXECUTABLE_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - // Prefer puppeteer's version-pinned Chrome-for-Testing over the system - // Chrome: /Applications auto-updates underneath the harnesses, and its - // software-GL behavior shifts across majors (system Chrome 150 blew the - // tile-gated drain budget under SwiftShader on 2026-07-30 — six - // false-negative qa-cctv-v2 runs against a healthy build). A deterministic - // pinned browser beats the newest one for regression harnesses. - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -function findChromeExecutable() { - for (const candidate of CHROME_EXECUTABLE_CANDIDATES) { - try { - if (fs.existsSync(candidate)) return candidate; - } catch { - /* ignore */ - } - } - return null; -} const results = evidence.checks; function record(name, ok, detail) { @@ -189,32 +169,6 @@ const PROTOCOL_TIMEOUT_MS = 300_000; * The predicate must return a boolean: its result crosses CDP by value. * @returns {Promise} True if the predicate held within the budget. */ -async function waitForPredicate( - page, - predicate, - { timeoutMs, intervalMs = 250, args = [] }, -) { - // performance.now() is monotonic and doesn't advance while the machine - // sleeps, so neither a clock change nor a laptop sleep eats the budget. - const deadline = performance.now() + timeoutMs; - for (;;) { - try { - if (await page.evaluate(predicate, ...args)) return true; - } catch (err) { - // A navigation or Vite reload replaces the execution context: poll again. - if ( - !/Execution context was destroyed|Cannot find context/.test( - err?.message ?? '', - ) - ) { - throw err; - } - } - const remaining = deadline - performance.now(); - if (remaining <= 0) return false; - await sleep(Math.min(intervalMs, remaining)); - } -} // SwiftShader can spend tens of seconds servicing the hover drillPick emitted // by page.mouse.move(), before the LEFT_DOWN under test even runs. Position the @@ -266,22 +220,21 @@ function installRenderFramesHelper(page) { return page.evaluate(() => { window.__qaRenderFrames = (count, capMs) => new Promise((resolve) => { - const scene = window.__godsEyeView.viewer.scene; let frames = 0; const finish = () => { clearTimeout(timer); remove(); resolve(frames); }; - const remove = scene.postRender.addEventListener(() => { + const remove = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { if (++frames >= count) { finish(); return; } - scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); const timer = setTimeout(finish, capMs); - scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); }); } @@ -462,23 +415,6 @@ const BEARING_SHIFT_SRC = ` * @param {number} [timeoutMs=15000] * @returns {Promise} True if tilesLoaded was observed within the timeout. */ -function waitForTilesLoaded(page, timeoutMs = 15000) { - return waitForPredicate( - page, - () => { - const scene = window.__godsEyeView.viewer.scene; - const prims = scene.primitives; - for (let i = 0; i < prims.length; i++) { - const p = prims.get(i); - if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') { - return p.tilesLoaded === true; - } - } - return true; // no tileset in the scene — nothing to wait for - }, - { timeoutMs }, - ); -} /** * Searches for a calibration patch (pitchDeg/fovDeg/rangeScale offsets) that @@ -542,24 +478,13 @@ async function main() { fs.mkdirSync(SHOTS_DIR, { recursive: true }); - const chromeExecutable = findChromeExecutable(); - const browser = await puppeteer.launch({ + const browser = await launchBrowser({ headless: true, protocolTimeout: PROTOCOL_TIMEOUT_MS, // Talk CDP over a pipe, so no debugging port stays open on localhost for // the length of a headful run. pipe: true, - ...(chromeExecutable ? { executablePath: chromeExecutable } : {}), - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--use-angle=metal', - '--enable-gpu', - '--disable-dev-shm-usage', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--window-size=1280,800', - ], + args: ['--no-sandbox', '--disable-setuid-sandbox', '--enable-gpu', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1280,800'], }); const consoleErrors = evidence.browserErrors; @@ -596,7 +521,7 @@ async function main() { }); console.log('Loading app...'); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); - const appReady = await waitForPredicate( + const appReady = await pollPage( page, () => Boolean( @@ -638,7 +563,7 @@ async function main() { if (!keyless) throw new Error('Expected a keyless globe before CCTV QA'); } // Let the initial fly-to Austin and first tiles settle. - await sleep(4000); + await whenSettled(page); // ----------------------------------------------------------------------- // Install raycast counters BEFORE the layer is enabled (§9.1-amended: the @@ -653,7 +578,7 @@ async function main() { if ( !scene || typeof scene.pickFromRay !== 'function' || - typeof scene.sampleHeight !== 'function' + typeof window.__godsEyeView.engine.surface.sampleHeight !== 'function' ) { return false; } @@ -663,7 +588,7 @@ async function main() { window.__qaCounters.pickFromRay += 1; return origPick(...args); }; - const origSample = scene.sampleHeight.bind(scene); + const origSample = window.__godsEyeView.engine.surface.sampleHeight.bind(scene); scene.sampleHeight = (...args) => { window.__qaCounters.sampleHeight += 1; return origSample(...args); @@ -719,7 +644,7 @@ async function main() { console.log( `Waiting for geometry-load queue to drain (N=${camCount}, budget ${Math.round(drainBudgetMs / 1000)}s)...`, ); - const drained = await waitForPredicate( + const drained = await pollPage( page, () => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; @@ -763,7 +688,7 @@ async function main() { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.focusNearest({ durationSec: 0.1 }); }); - await sleep(500); // let the activation's synchronous work (probe + geometry rewrite) land + await whenFramesDrawn(page); // let the activation's synchronous work (probe + geometry rewrite) land const cAfterActivate = await readCounters(); const activeId = await page.evaluate( () => @@ -793,7 +718,7 @@ async function main() { .get('cctv') .module.setParams({ selectedCameraId: id }); }, activeId); - await sleep(400); + await whenFramesDrawn(page); const cAfterReselect = await readCounters(); const geomAfterReselect = await page.evaluate(`(() => { ${SERIALIZE_GEOM_SRC} @@ -816,7 +741,7 @@ async function main() { // latency of the completion pass (update interval is 10s). // ----------------------------------------------------------------------- console.log('Waiting for tiles + the one-shot completion pass...'); - const tilesSeenLoaded = await waitForTilesLoaded(page, 45000); + const tilesSeenLoaded = await whenTilesLoaded(page, { timeoutMs: 45000 }).then(() => true).catch(() => false); let sampleFloorOk = false; if (tilesSeenLoaded && !terrainGlobe) { sampleFloorOk = await page @@ -866,10 +791,10 @@ async function main() { terrain.ok && heights?.length === 2 && heights.every(Number.isFinite); const mountResolved = terrainReady && - (await waitForPredicate( + (await pollPage( page, - (expected, base) => { - const { viewer, dataManager, engine } = window.__godsEyeView; + await withGeo((qaGeo, expected, base) => { + const { dataManager, engine } = window.__godsEyeView; const module = dataManager.layers.get('cctv').module; const camera = module.getUIState().activeCamera; // The corner ray is an engine line; its first point (the mount) @@ -883,7 +808,7 @@ async function main() { const mount = drawn ? ray?.polyline?.positions?.[0] : undefined; const height = mount && - viewer.scene.globe.ellipsoid.cartesianToCartographic(mount) + qaGeo.cartographic.fromCartesian(mount) .height; return ( Number.isFinite(camera.groundPriorM) && @@ -895,8 +820,8 @@ async function main() { ) && window.__qaCounters.sampleHeight === base ); - }, - { timeoutMs: 30000, args: [heights, c0.sampleHeight] }, + }, heights, c0.sampleHeight), + { timeoutMs: 30000 }, )); record( 'terrain-globe completion uses a real DEM height at the rendered mount without mesh samples', @@ -905,10 +830,10 @@ async function main() { ); } else { console.log('Waiting for tiles + the one-shot completion pass...'); - const tilesSeenLoaded = await waitForTilesLoaded(page, 45000); + const tilesSeenLoaded = await whenTilesLoaded(page, { timeoutMs: 45000 }).then(() => true).catch(() => false); let sampleFloorOk = false; if (tilesSeenLoaded) { - sampleFloorOk = await waitForPredicate( + sampleFloorOk = await pollPage( page, (base) => window.__qaCounters.sampleHeight - base >= 1, { timeoutMs: 30000, args: [c0.sampleHeight] }, @@ -993,7 +918,7 @@ async function main() { calibration: { cameraId: camId, patch: { headingDeg: 30 } }, }); }, activeId); - await sleep(500); + await whenFramesDrawn(page); const cAfterCal = await readCounters(); const sampleDeltaCal = cAfterCal.sampleHeight - cBeforeCal.sampleHeight; const pickDeltaCal = cAfterCal.pickFromRay - cBeforeCal.pickFromRay; @@ -1016,7 +941,7 @@ async function main() { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ calibration: { cameraId: camId, reset: true } }); }, activeId); - await sleep(300); + await whenFramesDrawn(page); // ========================================================================= // Group 2: geometry contract @@ -1098,7 +1023,7 @@ async function main() { ? JSON.stringify(geomInfo.plane.dimensions) : null; for (let attempt = 0; attempt < 3 && !patchGeometryApplied; attempt++) { - await waitForTilesLoaded(page, 15000); + await whenTilesLoaded(page, { timeoutMs: 15000 }).then(() => true).catch(() => false); await page.evaluate( ({ camId, patch }) => { const mod = @@ -1107,7 +1032,7 @@ async function main() { }, { camId: activeId, patch: safePatch }, ); - await sleep(400); + await whenFramesDrawn(page); geomForCorners = await page.evaluate( `(function(camId){ ${SERIALIZE_GEOM_SRC} return serializeGeom(camId); })(${JSON.stringify(activeId)})`, ); @@ -1204,7 +1129,7 @@ async function main() { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ calibration: { cameraId: camId, reset: true } }); }, activeId); - await sleep(300); + await whenFramesDrawn(page); } // Byte-stable over 30s idle: serialize + compare after 30s. Nothing @@ -1283,7 +1208,7 @@ async function main() { calibration: { cameraId: camId, patch: { headingDeg: 30 } }, }); }, activeId); - await sleep(300); + await whenFramesDrawn(page); // Save-gated persistence (v3 design §3e): a PATCH edits the live pose // only — the store must stay untouched and the camera must read as @@ -1321,7 +1246,7 @@ async function main() { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ calibration: { cameraId: camId, save: true } }); }, activeId); - await sleep(200); + await whenFramesDrawn(page); const storeEntry = await page.evaluate((camId) => { const raw = localStorage.getItem('godsEyeView.cctv.calibration.v2'); @@ -1371,7 +1296,7 @@ async function main() { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ calibration: { cameraId: camId, reset: true } }); }, activeId); - await sleep(300); + await whenFramesDrawn(page); const storeAfterReset = await page.evaluate((camId) => { const raw = localStorage.getItem('godsEyeView.cctv.calibration.v2'); @@ -1483,7 +1408,7 @@ async function main() { const gev = window.__godsEyeView; const viewer = gev.viewer; const scene = viewer.scene; - const canvas = scene.canvas; + const canvas = window.__godsEyeView.engine.host.canvas; const rect = canvas.getBoundingClientRect(); const { hitTest } = ( await import('/src/overlays/worldOverlay.js') @@ -1535,28 +1460,28 @@ async function main() { const mod = gev.dataManager.layers.get('cctv').module; const snapshotPose = () => ({ position: [ - viewer.camera.positionWC.x, - viewer.camera.positionWC.y, - viewer.camera.positionWC.z, + window.__godsEyeView.engine.camera.pose().position.x, + window.__godsEyeView.engine.camera.pose().position.y, + window.__godsEyeView.engine.camera.pose().position.z, ], direction: [ - viewer.camera.directionWC.x, - viewer.camera.directionWC.y, - viewer.camera.directionWC.z, + window.__godsEyeView.engine.camera.pose().direction.x, + window.__godsEyeView.engine.camera.pose().direction.y, + window.__godsEyeView.engine.camera.pose().direction.z, ], - up: [viewer.camera.upWC.x, viewer.camera.upWC.y, viewer.camera.upWC.z], + up: [window.__godsEyeView.engine.camera.pose().up.x, window.__godsEyeView.engine.camera.pose().up.y, window.__godsEyeView.engine.camera.pose().up.z], right: [ - viewer.camera.rightWC.x, - viewer.camera.rightWC.y, - viewer.camera.rightWC.z, + window.__godsEyeView.engine.camera.pose().right.x, + window.__godsEyeView.engine.camera.pose().right.y, + window.__godsEyeView.engine.camera.pose().right.z, ], transform: Array.from( { length: 16 }, (_, i) => viewer.camera.transform[i], ), - heading: viewer.camera.heading, - pitch: viewer.camera.pitch, - roll: viewer.camera.roll, + heading: window.__godsEyeView.engine.camera.pose().heading, + pitch: window.__godsEyeView.engine.camera.pose().pitch, + roll: window.__godsEyeView.engine.camera.pose().roll, trackedId: viewer.trackedEntity?.id ?? null, }); const cameraPoseMatches = (a, b, epsilon = 1e-5) => { @@ -1626,7 +1551,7 @@ async function main() { .module.setParams({ calibrationMode: true }); }); await page.mouse.click(emptyClickPoint.x, emptyClickPoint.y); - await sleep(250); + await whenFramesDrawn(page); const adjustClick = await page.evaluate(() => { const evidence = window.__qaCctvClickEvidence; const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; @@ -1673,7 +1598,7 @@ async function main() { distance, new ray.origin.constructor(), ); - const screen = scene.cartesianToCanvasCoordinates(candidate); + const screen = window.__godsEyeView.engine.projector.worldToWindow(candidate); if ( screen && Math.hypot(screen.x - canvasPoint.x, screen.y - canvasPoint.y) < 2 @@ -1695,7 +1620,7 @@ async function main() { }); window.__qaCctvSiblingOwner = owner; const framesRendered = await window.__qaRenderFrames(3, 10000); - const rect = scene.canvas.getBoundingClientRect(); + const rect = window.__godsEyeView.engine.host.canvas.getBoundingClientRect(); const picked = scene.pick(canvasPoint); const pickedId = picked?.id?.id ?? picked?.id ?? null; window.__qaCctvClickEvidence.siblingBaselineTransitions = @@ -1735,7 +1660,7 @@ async function main() { ); if (siblingOwnsPick) { await page.mouse.click(siblingTarget.x, siblingTarget.y); - await sleep(250); + await whenFramesDrawn(page); } const siblingClick = await page.evaluate(() => { const evidence = window.__qaCctvClickEvidence; @@ -1769,7 +1694,7 @@ async function main() { if (emptyClickPoint) { await page.mouse.click(emptyClickPoint.x, emptyClickPoint.y); - await sleep(250); + await whenFramesDrawn(page); } const firstEmptyClick = await page.evaluate(() => { const evidence = window.__qaCctvClickEvidence; @@ -1810,7 +1735,7 @@ async function main() { if (emptyClickPoint) { await page.mouse.click(emptyClickPoint.x, emptyClickPoint.y); - await sleep(250); + await whenFramesDrawn(page); } const repeatEmptyClick = await page.evaluate(() => { const evidence = window.__qaCctvClickEvidence; @@ -1894,7 +1819,7 @@ async function main() { mod.setParams({ coverageMode: 'viewshed' }); return mod.getUIState().coverageMode; }); - await sleep(400); + await whenFramesDrawn(page); record( 'coverageMode setParams round-trip (viewshed)', modeAfterSet === 'viewshed', @@ -1918,7 +1843,7 @@ async function main() { const on = mod.getUIState().coverageMode; return { off, on }; }); - await sleep(200); + await whenFramesDrawn(page); const volumesInOnMode = await countVolumes(); record( 'boolean showCoverage compat (false→off, true→on)', @@ -1938,7 +1863,7 @@ async function main() { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ coverageMode: 'viewshed' }); }); - await sleep(400); + await whenFramesDrawn(page); // NEXT starts an asynchronous footprint-height lookup. Its completion is // allowed to update geometry; the unchanged 8s idle window starts after it. const terrainDeadline = Date.now() + 35000; @@ -1998,7 +1923,7 @@ async function main() { // Frame that actual camera before requiring an on-screen physical drag. await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv') .module.setParams({ focusSelected: true, focusDurationSec: 0.2 })); - await sleep(800); + await whenFramesDrawn(page); const GIZMO_PARTS = [ 'ring-heading', 'ring-pitch', @@ -2113,7 +2038,7 @@ async function main() { grab.x + eastDrag.ux * i * 12, grab.y + eastDrag.uy * i * 12, ); - await sleep(40); + await whenFramesDrawn(page); transientDragStates.push( await page.evaluate(() => { const mod = @@ -2132,7 +2057,7 @@ async function main() { } const candidateCountersBeforeRelease = await readCounters(); await page.mouse.up(); - await sleep(400); + await whenFramesDrawn(page); const candidateEast = await page.evaluate(() => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; @@ -2328,7 +2253,7 @@ async function main() { // Screenshots supplement this state/geometry oracle. Positive rendered // media pixels are a separate mandatory pixel gate. // ----------------------------------------------------------------------- - await sleep(1000); + await whenFramesDrawn(page); const shot = path.join(SHOTS_DIR, 'cctv-v2-active.png'); await saveReviewShot(page, shot, 'visual review only, not a gate'); @@ -2344,7 +2269,7 @@ async function main() { } } finally { await fixture?.dispose(); - await browser.close(); + await closeBrowser(browser); evidence.pass = results.length > 0 && results.every((r) => r.ok === true) && diff --git a/scripts/qa-cockpit-plates.mjs b/scripts/qa-cockpit-plates.mjs index 3d3aece03..adf7861e9 100644 --- a/scripts/qa-cockpit-plates.mjs +++ b/scripts/qa-cockpit-plates.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { evaluateWithGeo } from './qa/geometry.mjs'; /** * Rendered proof for backdrop-selective callout plates at GROUND LEVEL. * @@ -46,13 +47,14 @@ * node scripts/qa-cockpit-plates.mjs --url http://localhost:4268 --tag before * node scripts/qa-cockpit-plates.mjs --url http://localhost:4268 --teeth * - * Defaults to ANGLE/Metal on macOS and software GL elsewhere. `--swiftshader` selects software GL, + * QA_ANGLE selects the renderer (Metal by default); use swiftshader for software GL, * which is deterministic but is NOT real-GPU evidence; the banner says which * backend actually ran and the run records it alongside the results. */ +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; const argv = process.argv.slice(2); const getOpt = (name, fallback) => { @@ -64,7 +66,7 @@ const APP_ORIGIN = new URL(APP_URL).origin; const TAG = getOpt('--tag', 'after'); const HEADFUL = argv.includes('--headful'); const TEETH = argv.includes('--teeth'); -const SWIFTSHADER = argv.includes('--swiftshader') || process.platform !== 'darwin'; +const SWIFTSHADER = /swiftshader|software/i.test(process.env.QA_ANGLE || 'metal'); const SHOT_DIR = path.resolve('qa-shots/cockpitplates'); /** Mirrors `SKY_PLATE_SCALE` in src/overlays/worldOverlayTokens.js. */ @@ -134,14 +136,6 @@ const POSES = [ }, ]; -const CHROME_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - // Version-pinned Chrome-for-Testing over the auto-updating system Chrome: - // its software-GL behavior shifts across majors and has produced false - // negatives in this repo's harnesses before. - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', -].filter(Boolean); const results = []; function record(name, ok, detail) { @@ -213,25 +207,9 @@ async function main() { } fs.mkdirSync(SHOT_DIR, { recursive: true }); - const executablePath = CHROME_CANDIDATES.find((candidate) => { - try { return fs.existsSync(candidate); } catch { return false; } - }); - const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', - ...(executablePath ? { executablePath } : {}), - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - // Metal is a macOS-only ANGLE backend; off-Mac the GPU branch has to fall - // back to the software rasterizer or WebGL init fails outright. - ...(SWIFTSHADER - ? ['--use-gl=angle', '--use-angle=swiftshader'] - : ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist']), - '--disable-dev-shm-usage', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--window-size=1280,800', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', ...(SWIFTSHADER ? [] : ['--enable-gpu', '--ignore-gpu-blocklist']), '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1280,800'], }); const consoleErrors = []; @@ -276,10 +254,7 @@ async function main() { await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90_000 }); await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 90_000 }); - await page.waitForFunction( - () => document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 90_000 }, - ); + await whenSettled(page); renderer = await page.evaluate(() => { const gl = document.createElement('canvas').getContext('webgl2') @@ -302,10 +277,10 @@ async function main() { // Inject the field. The layers keep their real pipelines; only the source // of observations is replaced, exactly as scripts/qa-labels.mjs does. - await page.evaluate((payload) => { - const { viewer, dataManager, styleManager } = window.__godsEyeView; - viewer.camera.cancelFlight(); - const Cartesian3 = viewer.camera.position.constructor; + await evaluateWithGeo(page, async (qaGeo, payload) => { + const { dataManager, styleManager } = window.__godsEyeView; + window.__godsEyeView.engine.camera.cancelFlight(); + const Cartesian3 = qaGeo.cartesian3; window.__PLATE_QA_FIELD = payload.contacts.map((contact) => ({ sourceId: contact.id.toLowerCase(), id: contact.id, @@ -330,17 +305,16 @@ async function main() { for (const pose of POSES) { console.log(`\n — pose "${pose.name}" (${pose.note})`); - await page.evaluate((payload) => { - const { viewer } = window.__godsEyeView; - const Cartesian3 = viewer.camera.position.constructor; - viewer.camera.setView({ + await evaluateWithGeo(page, async (qaGeo, payload) => { + const Cartesian3 = qaGeo.cartesian3; + window.__godsEyeView.engine.camera.setView({ destination: Cartesian3.fromDegrees(payload.lon, payload.lat, payload.height), orientation: { heading: 0, pitch: payload.pitchDeg * Math.PI / 180, roll: 0 }, }); }, { lon: JFK_LON, lat: JFK_LAT, height: pose.height, pitchDeg: pose.pitchDeg }); // Let the tiles settle and the label solve run before the tap window. - await new Promise((resolve) => setTimeout(resolve, 6_000)); + await whenTilesLoaded(page); await page.evaluate(() => { window.__PLATE_TAP.events = []; window.__PLATE_TAP.on = true; @@ -356,10 +330,9 @@ async function main() { // OUTPUT has to ask for its own renders. Production is unaffected: real // Cockpit holds, and a parked map keeps showing the last correct frame // precisely because nothing changed. - const scene = window.__godsEyeView.viewer.scene; const pump = () => { if (!window.__PLATE_TAP.on) return; - scene.requestRender(); + window.__godsEyeView.engine.render.request(); requestAnimationFrame(pump); }; pump(); @@ -522,7 +495,7 @@ async function main() { consoleErrors.slice(0, 3).join(' | ') || 'clean', ); } finally { - await browser.close(); + await closeBrowser(browser); } fs.writeFileSync( diff --git a/scripts/qa-cockpit-utility.mjs b/scripts/qa-cockpit-utility.mjs index 8aa712c01..d6571af42 100644 --- a/scripts/qa-cockpit-utility.mjs +++ b/scripts/qa-cockpit-utility.mjs @@ -1,9 +1,11 @@ #!/usr/bin/env node +// Cesium-only probes: tracked-entity and local-frame coordinates verify the native cockpit handoff. /** Focused rendered proof for adaptive Cockpit Display/Radio layout. */ +import { whenSettled, whenUiSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import puppeteer from 'puppeteer'; import { blockPaidQaMaps } from './qa-map-policy.mjs'; import { stubPlanespotters } from './qa-photo-stub.mjs'; @@ -13,22 +15,9 @@ const appUrl = process.env.QA_BASE_URL || 'http://localhost:4173'; const headful = process.argv.includes('--headful'); fs.mkdirSync(shotsDir, { recursive: true }); -const chromeCandidates = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', -].filter(Boolean); -const executablePath = chromeCandidates.find((candidate) => fs.existsSync(candidate)); -const browser = await puppeteer.launch({ - headless: headful ? false : 'new', - ...(executablePath ? { executablePath } : {}), - // Metal is a macOS-only ANGLE backend; anywhere else it fails WebGL init. - args: [ - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), - '--no-sandbox', - ], +const browser = await launchBrowser({ + headless: true, + args: [...(process.platform === 'darwin' ? ['--enable-gpu'] : []), '--no-sandbox'], }); const page = await browser.newPage(); await blockPaidQaMaps(page); @@ -171,10 +160,7 @@ try { page.on('request', routeQaRequest); await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 60_000 }); - await page.waitForFunction( - () => document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 60_000 }, - ); + await whenSettled(page); await page.waitForFunction(() => ( typeof window.__gevQaRegisterLayer === 'function' && typeof window.__gevQaUnregisterLayer === 'function' @@ -675,6 +661,7 @@ try { () => Boolean(window.__godsEyeView.viewer.trackedEntity?.position), { timeout: 10_000 }, ); + await whenSettled(page); const preselectedContactAdoption = await page.evaluate(async () => { const { styleManager, dataManager, viewer } = window.__godsEyeView; const flights = dataManager.layers.get('flights')?.module; @@ -767,7 +754,7 @@ try { } : null, navigation: snapshot?.navigation || null, afterId: trackedInfo?.icao24 || null, - viewerTrackedId: viewer.trackedEntity?.gevTrackedId || null, + viewerTrackedId: window.__godsEyeView.engine.camera.following()?.label?.id || null, contextVisible: !document.getElementById('military-awareness-panel')?.hidden, entryAvailableAfterSettlement: Boolean(entry && !entry.hidden), blockerStillRegistered: dataManager.layers.has(blockerId), @@ -854,8 +841,8 @@ try { const readinessUrl = new URL(appUrl); readinessUrl.searchParams.set('welcome', '0'); await readinessPage.goto(readinessUrl.href, { waitUntil: 'domcontentloaded' }); - await readinessPage.waitForFunction(() => window.__godsEyeView?.dataManager - && document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 60000 }); + await readinessPage.waitForFunction(() => window.__godsEyeView?.dataManager, { timeout: 60000 }); + await whenSettled(readinessPage); await readinessPage.evaluate(async () => { const manager = window.__godsEyeView.dataManager; await manager.setEnabled('flights', true, { origin: 'user' }); @@ -1100,7 +1087,7 @@ try { const awareness = dataManager.layers.get('military-awareness')?.module; const before = awareness?.getContextSnapshot?.()?.subject || null; const entityBefore = viewer.trackedEntity; - viewer.camera.zoomOut(1_500_000); + window.__godsEyeView.engine.camera.zoomOut(1_500_000); const cameraRange = () => Math.hypot( viewer.camera.position.x, viewer.camera.position.y, @@ -1465,7 +1452,7 @@ try { remove(); reject(new Error('Cockpit camera ownership check did not render a frame')); }, 5000); - const remove = viewer.scene.postRender.addEventListener(() => { + const remove = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { remove(); clearTimeout(timer); resolve(); @@ -1473,10 +1460,10 @@ try { }); // Let revoked listeners retire before comparing ownership across exit. await nextFrame(); - const listenersBeforeExit = viewer.scene.preUpdate.numberOfListeners; + const listenersBeforeExit = window.__godsEyeView.engine.frames.preUpdate.numberOfListeners; const exited = styleManager.cockpitView.exit() === true; await nextFrame(); - const listenersAfterExit = viewer.scene.preUpdate.numberOfListeners; + const listenersAfterExit = window.__godsEyeView.engine.frames.preUpdate.numberOfListeners; const refocused = awareness?.focusCurrent?.() === true; await nextFrame(); return { @@ -1485,7 +1472,7 @@ try { sameEntity: viewer.trackedEntity === entity, listenersBeforeExit, listenersAfterExit, - listenersAfterFocus: viewer.scene.preUpdate.numberOfListeners, + listenersAfterFocus: window.__godsEyeView.engine.frames.preUpdate.numberOfListeners, }; }); check( @@ -2088,7 +2075,7 @@ try { JSON.stringify(cyberStyleStates)); for (const height of [987, 720, 640, 900]) { await page.setViewport({ width: 1440, height, deviceScaleFactor: 1 }); - await new Promise((resolve) => setTimeout(resolve, 400)); + await whenUiSettled(page); const geometry = await page.evaluate(() => { const signal = document.getElementById('cockpit-signal-stream'); const utility = document.getElementById('cockpit-utility-controls'); @@ -2379,7 +2366,7 @@ try { await page.waitForFunction(() => { const viewer = window.__godsEyeView.viewer; if (!viewer) return false; - const height = viewer.camera.positionCartographic?.height; + const height = window.__godsEyeView.engine.camera.pose().cartographic?.height; return Math.abs(height - 18_000_000) < 150_000; }, { timeout: 6_000 }); const resetState = await page.evaluate(() => { @@ -2387,7 +2374,7 @@ try { const qa = window.__qaCockpitReset; const awareness = gev.dataManager.layers.get('military-awareness')?.module; const subjectId = awareness?.getContextSnapshot?.()?.subject?.id || null; - const height = gev.viewer.camera.positionCartographic?.height; + const height = window.__godsEyeView.engine.camera.pose().cartographic?.height; gev.styleManager._locationNavigation.resetToGlobeView = qa.original; delete window.__qaCockpitReset; return { @@ -2440,7 +2427,7 @@ try { else hud.style.removeProperty('--cockpit-utility-top'); if (!prior.cockpit) document.body.classList.remove('cockpit-mode'); }).catch(() => {}); - await browser.close(); + await closeBrowser(browser); } console.log(`RESULT: ${failures.length ? 'NOT_READY' : 'READY'} (${failures.length} failures)`); diff --git a/scripts/qa-context-arrivals.mjs b/scripts/qa-context-arrivals.mjs index 85be4ce9c..30a98ae13 100644 --- a/scripts/qa-context-arrivals.mjs +++ b/scripts/qa-context-arrivals.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { evaluateWithGeo } from './qa/geometry.mjs'; /** * qa-context-arrivals.mjs — search arrivals above the surface, and Contacts * listing mapped installations around a moving tracked aircraft. @@ -25,9 +26,10 @@ * Writes /result.json, screenshots and /clip.mp4 (CDP screencast * with real frame timing; needs ffmpeg). */ +import { whenSettled, whenFramesDrawn, whenTilesLoaded } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; import { stubPlanespotters } from './qa-photo-stub.mjs'; import { createScreencast, moveCamera } from './qa-journey-recorder.mjs'; @@ -62,14 +64,12 @@ const check = (name, ok, detail = '') => { if (!ok) result.failures.push(`${name}${detail ? ` — ${detail}` : ''}`); }; -const browser = await puppeteer.launch({ - headless: !argv.includes('--headful'), +const browser = await launchBrowser({ + headless: true, protocolTimeout: 300_000, args: [ '--no-sandbox', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', @@ -171,12 +171,10 @@ async function openPage({ aircraft = false } = {}) { const url = new URL(appUrl); url.searchParams.set('welcome', '0'); await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 90_000 }); - await page.waitForFunction( - () => - window.__godsEyeView?.dataManager && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 120_000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.dataManager, { + timeout: 120_000, + }); + await whenSettled(page); await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, ); @@ -185,31 +183,16 @@ async function openPage({ aircraft = false } = {}) { } async function waitTiles(page, maxMs = 30_000) { - await page - .waitForFunction( - () => { - const { scene } = window.__godsEyeView.viewer; - if (scene.globe.show) return scene.globe.tilesLoaded; - for (let i = 0; i < scene.primitives.length; i++) { - const p = scene.primitives.get(i); - if (p.show && p.tilesLoaded === false) return false; - } - return true; - }, - { timeout: maxMs, polling: 250 }, - ) - .catch(() => {}); + await whenTilesLoaded(page, { timeoutMs: maxMs }).catch(() => {}); } /** Camera height above the rendered surface under it and under the view centre. */ function clearance(page) { - return page.evaluate(() => { - const { viewer } = window.__godsEyeView; - const { scene, camera } = viewer; - const carto = camera.positionCartographic.clone(); + return evaluateWithGeo(page, async (qaGeo) => { + const carto = window.__godsEyeView.engine.camera.pose().cartographic; const sample = (c) => { try { - const h = scene.sampleHeight(c); + const h = window.__godsEyeView.engine.surface.sampleHeight(c); return Number.isFinite(h) ? h : null; } catch { return null; @@ -217,12 +200,12 @@ function clearance(page) { }; const under = sample(carto); let centre = null; - const canvas = scene.canvas; - const hit = scene.pickPosition?.({ + const canvas = window.__godsEyeView.engine.host.canvas; + const hit = window.__godsEyeView.engine.picker.pickPosition?.({ x: canvas.clientWidth / 2, y: canvas.clientHeight / 2, }); - if (hit) centre = scene.globe.ellipsoid.cartesianToCartographic(hit); + if (hit) centre = qaGeo.cartographic.fromCartesian(hit); const centreSurface = centre ? sample(centre) : null; const surface = Math.max(under ?? -Infinity, centreSurface ?? -Infinity); return { @@ -234,7 +217,9 @@ function clearance(page) { clearanceM: Number.isFinite(surface) ? Math.round(carto.height - surface) : null, - pitchDeg: Math.round((camera.pitch * 180) / Math.PI), + pitchDeg: Math.round( + (window.__godsEyeView.engine.camera.pose().pitch * 180) / Math.PI, + ), }; }); } @@ -255,47 +240,32 @@ async function arrivals() { }); await page.type('#location-search', query, { delay: 40 }); await page.keyboard.press('Enter'); - // Wait for the flight to land: the camera still for 1.5 s. - const landed = Date.now(); - let last = null; - let stillSince = null; - while (Date.now() - landed < 30_000) { - const now = await page.evaluate(() => { - const c = window.__godsEyeView.viewer.camera.positionWC; - return [c.x, c.y, c.z]; - }); - const moved = - !last || - Math.hypot(now[0] - last[0], now[1] - last[1], now[2] - last[2]) > 0.5; - last = now; - if (moved) stillSince = null; - else stillSince ??= Date.now(); - if ( - Date.now() - landed > 4000 && - stillSince && - Date.now() - stillSince > 1500 - ) - break; - await sleep(250); - } + await whenSettled(page); await waitTiles(page); - await sleep(2500); // the ground guard measures after tiles stream in + await whenSettled(page); // the ground guard measures after tiles stream in const arrived = await clearance(page); const slug = query.replace(/\W+/g, '-').replace(/^-|-$/g, '').toLowerCase(); await page.screenshot({ path: path.join(dir, `arrival-${slug}.png`) }); // Look around: a gentle orbit, then check the eye still clears the ground. const view = await page.evaluate(() => { - const c = window.__godsEyeView.viewer.camera; return { - lat: (c.positionCartographic.latitude * 180) / Math.PI, - lon: (c.positionCartographic.longitude * 180) / Math.PI, - height: c.positionCartographic.height, - heading: (c.heading * 180) / Math.PI, - pitch: (c.pitch * 180) / Math.PI, + lat: + (window.__godsEyeView.engine.camera.pose().cartographic.latitude * + 180) / + Math.PI, + lon: + (window.__godsEyeView.engine.camera.pose().cartographic.longitude * + 180) / + Math.PI, + height: window.__godsEyeView.engine.camera.pose().cartographic.height, + heading: + (window.__godsEyeView.engine.camera.pose().heading * 180) / Math.PI, + pitch: + (window.__godsEyeView.engine.camera.pose().pitch * 180) / Math.PI, }; }); await moveCamera(page, { ...view, heading: view.heading + 25 }, 2.5); - await sleep(1500); + await whenFramesDrawn(page); const lookedAround = await clearance(page); result.arrivals.push({ query, arrived, lookedAround }); check( @@ -323,7 +293,7 @@ async function contacts() { { lat: 30.35, lon: -97.76, height: 900_000, pitch: -90 }, 0, ); - await sleep(3000); + await whenFramesDrawn(page); // In-page probe: row text and installation entity churn every 250 ms. await page.evaluate(() => { const samples = []; @@ -368,7 +338,7 @@ async function contacts() { entities: sites.length, created, tracked: - window.__godsEyeView.viewer.trackedEntity?.gevTrackedId ?? null, + window.__godsEyeView.engine.camera.following()?.label?.id ?? null, cockpit: document.body.classList.contains('cockpit-mode'), }); if (samples.length > 5000) samples.splice(0, 1000); @@ -383,7 +353,7 @@ async function contacts() { await page .waitForFunction( () => - window.__godsEyeView.viewer.trackedEntity?.gevTrackedId === + window.__godsEyeView.engine.camera.following()?.label?.id === 'flights:aaa051', { timeout: 60_000 }, ) @@ -478,7 +448,7 @@ async function contacts() { await page.evaluate(() => window.__godsEyeView.styleManager.cockpitView.exit?.(), ); - await sleep(2000); + await whenFramesDrawn(page); // SEARCH NEARBY SITES, as the user presses it. await page.evaluate(() => { @@ -504,7 +474,7 @@ async function contacts() { const toast = await page.evaluate(() => document.getElementById('toast').textContent.trim(), ); - await sleep(1500); + await whenFramesDrawn(page); const afterSearch = ( await readSamples((await page.evaluate(() => performance.now())) - 1000) ).at(-1); @@ -519,13 +489,13 @@ async function contacts() { await page.evaluate(() => document.getElementById('global-context-flights-btn').click(), ); - await sleep(2000); + await whenFramesDrawn(page); await page.evaluate(() => window.__godsEyeView.dataManager.layers .get('flights') .module.trackById('aaa052'), ); - await sleep(3000); + await whenFramesDrawn(page); const t1 = await page.evaluate(() => performance.now()); await page.evaluate(() => document.getElementById('global-context-flights-btn').click(), @@ -554,7 +524,7 @@ async function contacts() { } await sleep(500); } - await sleep(2000); + await whenFramesDrawn(page); await page.screenshot({ path: path.join(dir, 'contacts-fort-cavazos.png') }); result.contacts.cavazos = cavazos; check( @@ -600,7 +570,7 @@ try { path.join(outDir, 'result.json'), JSON.stringify(result, null, 1), ); - await browser.close(); + await closeBrowser(browser); } console.log( `${result.failures.length ? 'FAIL' : 'PASS'} — ${path.join(outDir, 'result.json')}`, diff --git a/scripts/qa-directions.mjs b/scripts/qa-directions.mjs index 1bc9886c3..17e3db98d 100644 --- a/scripts/qa-directions.mjs +++ b/scripts/qa-directions.mjs @@ -1,4 +1,7 @@ #!/usr/bin/env node +// Cesium-only probes: native primitive inspection independently verifies the Cesium route geometry. +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled, whenUiSettled, whenTilesLoaded } from './qa/ready.mjs'; /** * Browser proof of the Directions layer: real chip clicks, real globe clicks, * a real route from the real proxy, a real camera flight. @@ -6,10 +9,10 @@ * Usage: node scripts/qa-directions.mjs [--url http://localhost:4173] * [--shots qa-shots/directions] [--label keyed] [--skip denver] */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import puppeteer from 'puppeteer'; import { chromeExecutablePath } from './qa-chrome.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -34,9 +37,8 @@ const check = (name, passed, detail = '') => { if (!passed) failures += 1; }; -const browser = await puppeteer.launch({ - headless: process.env.QA_HEADFUL === '1' ? false : true, - executablePath: chromeExecutablePath(), +const browser = await launchBrowser({ + headless: true, args: [ '--no-sandbox', '--disable-background-timer-throttling', @@ -62,12 +64,12 @@ const shot = async (name) => { /** Put the camera over a place, instantly, and let the scene settle. */ async function lookAt(lat, lon, height) { - await page.evaluate( - (la, lo, h) => { - const viewer = window.__godsEyeView.viewer; - const Cartesian3 = viewer.camera.positionWC.constructor; - viewer.camera.cancelFlight(); - viewer.camera.setView({ + await evaluateWithGeo( + page, + async (qaGeo, la, lo, h) => { + const Cartesian3 = qaGeo.cartesian3; + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ destination: Cartesian3.fromDegrees(lo, la, h), orientation: { heading: 0, pitch: -Math.PI / 2.2, roll: 0 }, }); @@ -80,17 +82,16 @@ async function lookAt(lat, lon, height) { /** Canvas position of a lon/lat on the rendered surface, or null. */ const screenAt = (lat, lon) => - page.evaluate( - (la, lo) => { - const viewer = window.__godsEyeView.viewer; - const scene = viewer.scene; - const Cartesian3 = viewer.camera.positionWC.constructor; - const Cartographic = viewer.camera.positionCartographic.constructor; + evaluateWithGeo( + page, + async (qaGeo, la, lo) => { + const Cartesian3 = qaGeo.cartesian3; + const Cartographic = qaGeo.cartographic; const carto = Cartographic.fromDegrees(lo, la); - let ground = scene.globe?.getHeight?.(carto); + let ground = window.__godsEyeView.engine.surface.terrainHeight?.(carto); if (!Number.isFinite(ground)) ground = 0; const world = Cartesian3.fromDegrees(lo, la, ground); - const win = scene.cartesianToCanvasCoordinates(world); + const win = window.__godsEyeView.engine.projector.worldToWindow(world); if (!win || !Number.isFinite(win.x)) return null; const x = Math.round(win.x); const y = Math.round(win.y); @@ -150,7 +151,7 @@ const clickChip = async (chipId) => { button.click(); return true; }, chipId); - await sleep(150); + await whenUiSettled(page); return clicked; }; @@ -180,7 +181,7 @@ const waitForMode = (word) => const cameraPose = () => page.evaluate(() => { - const c = window.__godsEyeView.viewer.camera.positionWC; + const c = window.__godsEyeView.engine.camera.pose().position; return { x: c.x, y: c.y, z: c.z }; }); const poseDelta = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z); @@ -191,7 +192,7 @@ async function placeEndpoints(a, b) { const atA = await screenAt(a.lat, a.lon); if (!atA) return false; await page.mouse.click(atA.x, atA.y); - await sleep(700); + await whenUiSettled(page); await clickChip('set-b'); const atB = await screenAt(b.lat, b.lon); if (!atB) return false; @@ -207,6 +208,7 @@ try { await page.waitForFunction(() => window.__godsEyeView?.dataManager, { timeout: 90000, }); + await whenSettled(page); // Two module reads the assertions need. Both are the app's OWN module // instances (Vite serves one copy), so this observes rather than simulates. await page.evaluate(async () => { @@ -311,7 +313,7 @@ try { }, }; }); - await sleep(3000); + await whenSettled(page); // ── 1. Open the UTILITIES panel, then toggle the row on. ──────────────── const opened = await page.evaluate(() => { @@ -328,7 +330,7 @@ try { .getElementById('utilities-panel') .classList.contains('collapsed'); }); - await sleep(700); + await whenUiSettled(page); check('the UTILITIES panel opens from its own header control', opened); const toggled = await page.evaluate(() => { const row = document.querySelector( @@ -340,7 +342,7 @@ try { button.click(); return true; }); - await sleep(1200); + await whenUiSettled(page); check( 'the Directions row is in the Data Layers panel and toggles on', toggled, @@ -388,13 +390,13 @@ try { ); close?.click(); }); - await sleep(500); + await whenUiSettled(page); // ── 2. Place A on the Ferry Building, B in Golden Gate Park. ───────────── const FERRY = { lat: 37.7955, lon: -122.3937 }; const GGPARK = { lat: 37.7694, lon: -122.4862 }; await lookAt(37.782, -122.44, 16000); - await sleep(5000); + await whenSettled(page); await clickChip('set-a'); state = await layerState(); @@ -411,7 +413,7 @@ try { let at = await screenAt(FERRY.lat, FERRY.lon); check('the Ferry Building is on screen', !!at, JSON.stringify(at)); await page.mouse.click(at.x, at.y); - await sleep(800); + await whenUiSettled(page); state = await layerState(); check( 'placing A releases the pointer claim', @@ -425,7 +427,7 @@ try { // used to place B and deselect whatever was under it in the same gesture. await page.evaluate(() => { window.__gevQaLateHandler = []; - const canvas = window.__godsEyeView.viewer.scene.canvas; + const canvas = window.__godsEyeView.engine.host.canvas; canvas.addEventListener('pointerup', () => { window.__gevQaLateHandler.push(window.__gevQa.pointerOwner()); }); @@ -443,7 +445,7 @@ try { `saw ${JSON.stringify(lateOwners)}`, ); await waitForRoute(); - await sleep(2000); + await whenSettled(page); state = await layerState(); check( 'once that click is over, the pointer claim is returned', @@ -488,7 +490,7 @@ try { items[2]?.click(); return { active, tabIndex: items[2]?.tabIndex }; }); - await sleep(600); + await whenUiSettled(page); const afterListClick = await layerState(); check( 'a list row takes keyboard focus and selects its maneuver', @@ -525,7 +527,7 @@ try { // ── 4. Swap endpoints. ────────────────────────────────────────────────── const beforeSwap = await layerState(); await clickChip('swap'); - await sleep(3500); + await waitForRoute(); const afterSwap = await layerState(); check( 'SWAP reverses the route', @@ -534,7 +536,7 @@ try { `"${beforeSwap.listText[0]}" -> "${afterSwap.listText[0]}"`, ); await clickChip('swap'); - await sleep(3500); + await waitForRoute(); // ── 5. Three rapid reroutes leave exactly one route. ───────────────────── await page.evaluate(() => { @@ -543,7 +545,7 @@ try { manager.setLayerParams('directions', { mode: 'bike' }, { origin: 'user' }); manager.setLayerParams('directions', { mode: 'car' }, { origin: 'user' }); }); - await sleep(6000); + await waitForRoute(); const rapid = await layerState(); check( 'three rapid reroutes leave exactly one route', @@ -656,7 +658,7 @@ try { ), ); await waitForRoute(); - await sleep(1200); + await whenUiSettled(page); } await cdp.send('Fetch.disable').catch(() => {}); @@ -686,7 +688,7 @@ try { // A reroute replaces the route the dolly is flying; the dolly has to land // first rather than keep flying a route that no longer exists. await setMode('foot'); - await sleep(1200); + await whenUiSettled(page); const afterReroute = await layerState(); check( 'changing profile mid-flight lands the flight it replaced', @@ -696,7 +698,7 @@ try { await waitForMode('Walk'); await setMode('car'); await waitForMode('Drive'); - await sleep(800); + await whenUiSettled(page); check('FLY is offered again after the reroute', await clickChip('fly')); await sleep(2000); @@ -768,7 +770,7 @@ try { // ── 9. Denver: maneuver dots sit on the ground, not at sea level. ──────── if (!SKIP.has('denver')) { await lookAt(39.744, -104.999, 7000); - await sleep(6000); + await whenSettled(page); const placed = await placeEndpoints( { lat: 39.7392, lon: -104.9903 }, { lat: 39.7487, lon: -105.0077 }, @@ -777,10 +779,9 @@ try { if (placed) { await waitForRoute(); // Let the shared ground-floor cells warm and the dots re-anchor. - await sleep(9000); - const denver = await page.evaluate(() => { - const viewer = window.__godsEyeView.viewer; - const Cartographic = viewer.camera.positionCartographic.constructor; + await whenTilesLoaded(page); + const denver = await evaluateWithGeo(page, async (qaGeo) => { + const Cartographic = qaGeo.cartographic; const { stepCount, stepsShown } = window.__gevQaDirections.drawn(); const heights = stepsShown.map( (position) => Cartographic.fromCartesian(position).height, @@ -817,7 +818,7 @@ try { origin: 'user', }), ); - await sleep(1500); + await whenSettled(page); const off = await page.evaluate(() => { const drawn = window.__gevQaDirections.drawn(); const row = document.querySelector( @@ -877,7 +878,7 @@ try { check('harness completed', false, error?.stack || String(error)); await shot(`99-${LABEL}-failure.jpg`).catch(() => {}); } finally { - await browser.close(); + await closeBrowser(browser); await fs.writeFile( path.join(SHOTS, `qa-directions-${LABEL}.log`), `${lines.join('\n')}\n${failures} failure(s)\n`, diff --git a/scripts/qa-director-camera.mjs b/scripts/qa-director-camera.mjs index 62f0af5ff..40ef764e8 100644 --- a/scripts/qa-director-camera.mjs +++ b/scripts/qa-director-camera.mjs @@ -1,11 +1,12 @@ #!/usr/bin/env node /** Verify imported camera directions through real rendering and camera authority handoffs. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; const output = path.resolve('qa-shots/director-camera'); fs.mkdirSync(output, { recursive: true }); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'], }); @@ -23,12 +24,10 @@ try { `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, { waitUntil: 'domcontentloaded' }, ); - await page.waitForFunction( - () => - window.__godsEyeView?.sceneDirector && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 60000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.sceneDirector, { + timeout: 60000, + }); + await whenSettled(page); const result = await page.evaluate(async () => { const { sceneDirector: d, styleManager: style } = window.__godsEyeView; const file = { @@ -137,7 +136,7 @@ try { }; }); for (const [name, passed] of Object.entries(result)) check(name, passed); - await new Promise((r) => setTimeout(r, 6000)); + await whenSettled(page); await page.screenshot({ path: path.join(output, 'austin.png') }); await page.evaluate(() => { const d = window.__godsEyeView.sceneDirector; @@ -145,7 +144,7 @@ try { d._setCameraView({ ...pose, heading: pose.heading + 25, pitch: -60 }); d.viewer.scene.requestRender(); }); - await new Promise((r) => setTimeout(r, 3000)); + await whenSettled(page); await page.screenshot({ path: path.join(output, 'angle.png') }); const inputStarted = await page.evaluate(async () => { const d = window.__godsEyeView.sceneDirector; @@ -205,6 +204,6 @@ try { ); check('no uncaught browser errors', errors.length === 0); } finally { - await browser.close(); + await closeBrowser(browser); } if (failed) process.exitCode = 1; diff --git a/scripts/qa-director-interactions.mjs b/scripts/qa-director-interactions.mjs index a6d3cb83a..0e41d6a4e 100644 --- a/scripts/qa-director-interactions.mjs +++ b/scripts/qa-director-interactions.mjs @@ -1,10 +1,11 @@ #!/usr/bin/env node /** Real pointer/keyboard acceptance using a synthetic, self-authored feature pack. */ +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; -import puppeteer from 'puppeteer'; const output = 'qa-shots/director-interactions'; fs.mkdirSync(output, { recursive: true }); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'], }); @@ -52,12 +53,10 @@ try { `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0#v=2&lat=30.2672&lon=-97.742&alt=4000&heading=0&pitch=-90&l=`, { waitUntil: 'domcontentloaded' }, ); - await page.waitForFunction( - () => - window.__godsEyeView?.sceneDirector && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 60000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.sceneDirector, { + timeout: 60000, + }); + await whenSettled(page); await page.evaluate(async () => { const d = window.__godsEyeView.sceneDirector; const camera = { @@ -163,11 +162,8 @@ try { window.__godsEyeView.sceneDirector.getInteractionState().count === 4, ), ); - await page.waitForFunction( - () => window.__godsEyeView.sceneDirector.engine.surface.tilesLoaded(), - { timeout: 60000 }, - ); - await new Promise((r) => setTimeout(r, 2000)); + await whenTilesLoaded(page, { timeoutMs: 60000 }); + await whenSettled(page); const point = await page.evaluate(async () => { const d = window.__godsEyeView.sceneDirector; const { cartesian3 } = await import('/src/geo/index.js'); @@ -366,6 +362,6 @@ try { ); check('no uncaught browser errors', errors.length === 0); } finally { - await browser.close(); + await closeBrowser(browser); } process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-director-packs.mjs b/scripts/qa-director-packs.mjs index 9ef68d420..45fe50a43 100644 --- a/scripts/qa-director-packs.mjs +++ b/scripts/qa-director-packs.mjs @@ -1,8 +1,10 @@ #!/usr/bin/env node +// Cesium-only probes: native primitive readiness and entity counts detect Cesium resource leaks. /** Synthetic, self-authored assets exercise import, rendering and pack resource lifetime. */ +import { whenSettled, whenTilesLoaded, until } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; import sharp from 'sharp'; const output = path.resolve('qa-shots/director-packs'); fs.mkdirSync(output, { recursive: true }); @@ -65,7 +67,7 @@ const geojson = { }, ], }; -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'], }); @@ -105,12 +107,10 @@ try { `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, { waitUntil: 'domcontentloaded' }, ); - await page.waitForFunction( - () => - window.__godsEyeView?.sceneDirector && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 60000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.sceneDirector, { + timeout: 60000, + }); + await whenSettled(page); await page.evaluate(async () => { const d = window.__godsEyeView.sceneDirector; const manifest = (id, format, file, placement) => ({ @@ -230,14 +230,11 @@ try { () => window.__godsEyeView.mapStackController.getActiveId() === 'photoreal', { timeout: 15000 }, ); - await new Promise((r) => setTimeout(r, 5000)); - await page.waitForFunction( - () => window.__godsEyeView.tileset?.tilesLoaded === true, - { timeout: 60000 }, - ); + await whenSettled(page); + await whenTilesLoaded(page, { timeoutMs: 60000 }); const evidence = await page.evaluate(() => ({ mapStack: window.__godsEyeView.mapStackController.getActiveId(), - tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true, + tilesSettled: window.__godsEyeView.engine.surface.tilesLoaded() === true, })); fs.writeFileSync( path.join(output, 'report.json'), @@ -262,11 +259,8 @@ try { pitch: -85, }); }); - await page.waitForFunction( - () => window.__godsEyeView.tileset?.tilesLoaded === true, - { timeout: 60000 }, - ); - await new Promise((r) => setTimeout(r, 2000)); + await whenTilesLoaded(page, { timeoutMs: 60000 }); + await whenSettled(page); await page.screenshot({ path: path.join(output, 'angle.png') }); const life = await page.evaluate(async () => { const d = window.__godsEyeView.sceneDirector; @@ -306,9 +300,10 @@ try { 'slow', ); }); - const deadline = Date.now() + 15000; - while (!pending && Date.now() < deadline) - await new Promise((r) => setTimeout(r, 50)); + await until(() => pending, { + timeoutMs: 15000, + label: 'pending pack transport', + }); check('pending transport reached', !!pending); const cancelled = await page.evaluate(async () => { const d = window.__godsEyeView.sceneDirector; @@ -354,6 +349,6 @@ try { if (errors.length) console.error(errors.map((e) => e.replace(/https?:\/\/\S+/g, '[URL]'))); } finally { - await browser.close(); + await closeBrowser(browser); } if (failures) process.exitCode = 1; diff --git a/scripts/qa-director-sharing.mjs b/scripts/qa-director-sharing.mjs index 4a4a93d48..c9979b1ac 100644 --- a/scripts/qa-director-sharing.mjs +++ b/scripts/qa-director-sharing.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node /** Sharing/authoring acceptance with local synthetic assets and real installed controls. */ +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; const output = path.resolve('qa-shots/director-sharing'); fs.mkdirSync(output, { recursive: true }); const geometry = { @@ -66,7 +67,7 @@ const project = { }; const file = path.join(output, 'project.json'); fs.writeFileSync(file, JSON.stringify(project)); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'], }); @@ -110,12 +111,10 @@ try { `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, { waitUntil: 'domcontentloaded' }, ); - await page.waitForFunction( - () => - window.__godsEyeView?.sceneDirector && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 60000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.sceneDirector, { + timeout: 60000, + }); + await whenSettled(page); await page.click('[data-collapse-target="scene-panel"]'); const before = await page.evaluate(() => JSON.stringify(window.__godsEyeView.sceneDirector._project), @@ -304,16 +303,14 @@ try { window.__godsEyeView.sceneDirector.getInteractionState().count === 1, )), ); - await page.waitForFunction( - () => window.__godsEyeView.tileset?.tilesLoaded === true, - { timeout: 60000 }, - ); + await whenTilesLoaded(page, { timeoutMs: 60000 }); await page.screenshot({ path: path.join(output, 'rendered.png') }); fs.writeFileSync( path.join(output, 'report.json'), JSON.stringify( await page.evaluate(() => ({ - tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true, + tilesSettled: + window.__godsEyeView.engine.surface.tilesLoaded() === true, })), ), ); @@ -480,6 +477,6 @@ try { ); check('no uncaught browser errors', errors.length === 0); } finally { - await browser.close(); + await closeBrowser(browser); } process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-director-timing.mjs b/scripts/qa-director-timing.mjs index 27f5705ff..aae5129d4 100644 --- a/scripts/qa-director-timing.mjs +++ b/scripts/qa-director-timing.mjs @@ -1,11 +1,12 @@ #!/usr/bin/env node /** Exercise scene-clock ownership and authored seek behavior in the composed browser app. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; const shots = path.resolve('qa-shots/director-timing'); fs.mkdirSync(shots, { recursive: true }); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'], }); @@ -23,12 +24,10 @@ try { `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, { waitUntil: 'domcontentloaded' }, ); - await page.waitForFunction( - () => - window.__godsEyeView?.sceneDirector && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 60000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.sceneDirector, { + timeout: 60000, + }); + await whenSettled(page); const result = await page.evaluate(async () => { const director = window.__godsEyeView.sceneDirector; const camera = director.styleManager.getCameraState(); @@ -172,7 +171,7 @@ try { 'existing Nepal scene and authored shots load', content.exists && content.shots >= 25 && content.loaded, ); - await new Promise((resolve) => setTimeout(resolve, 2500)); + await whenSettled(page); await page.screenshot({ path: path.join(shots, 'nepal-regional.png') }); await page.evaluate(() => { const g = window.__godsEyeView; @@ -184,7 +183,7 @@ try { pitch: -55, }); }); - await new Promise((resolve) => setTimeout(resolve, 1000)); + await whenSettled(page); await page.screenshot({ path: path.join(shots, 'nepal-angle.png') }); check( 'no pending clocks after Stop', @@ -196,6 +195,6 @@ try { ); check('no browser exceptions', errors.length === 0); } finally { - await browser.close(); + await closeBrowser(browser); } if (failures) process.exitCode = 1; diff --git a/scripts/qa-draw-tool.mjs b/scripts/qa-draw-tool.mjs index 135ca035a..fef6ebc46 100644 --- a/scripts/qa-draw-tool.mjs +++ b/scripts/qa-draw-tool.mjs @@ -1,8 +1,11 @@ #!/usr/bin/env node +// Cesium-only probes: the stock input action and DataSource counts verify Cesium input restoration. +import { evaluateWithGeo } from './qa/geometry.mjs'; /** Rendered acceptance for UTILITIES ▸ Draw: real clicks, real keys, real teardown. */ +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; import { chromeExecutablePath } from './qa-chrome.mjs'; const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; @@ -19,21 +22,14 @@ const VIEW = { pitch: -85, }; -const browser = await puppeteer.launch({ - headless: !HEADFUL, - ...(HEADFUL - ? { - executablePath: chromeExecutablePath(), - } - : {}), +const browser = await launchBrowser({ + headless: true, args: [ '--no-sandbox', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', ...(HEADFUL ? ['--window-size=1280,860'] : []), - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), ], }); const page = await browser.newPage(); @@ -68,7 +64,6 @@ const shot = (name) => type: 'jpeg', quality: 82, }); -const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); /** A clip of the globe canvas, clear of the panels on every side. */ const CANVAS_CLIP = { x: 340, y: 200, width: 560, height: 380 }; @@ -163,7 +158,7 @@ async function pressFinish() { /** One click on the globe canvas, the way a person makes one. */ async function clickWorld(x, y, options = {}) { await page.mouse.click(x, y, options); - await wait(options.settle ?? 140); + await whenSettled(page); } const drawState = () => @@ -211,50 +206,34 @@ try { { waitUntil: 'domcontentloaded' }, ); await page.waitForFunction( - () => - window.__gevDrawTool && - window.__gevAnnotations && - document.getElementById('loading-screen')?.classList.contains('hidden'), + () => window.__gevDrawTool && window.__gevAnnotations, { timeout: 90_000 }, ); - await page.evaluate((view) => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.cancelFlight?.(); - viewer.scene.tweens?.removeAll?.(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ - longitude: (view.lon * Math.PI) / 180, - latitude: (view.lat * Math.PI) / 180, - height: view.height, - }), - orientation: { - heading: (view.heading * Math.PI) / 180, - pitch: (view.pitch * Math.PI) / 180, - roll: 0, - }, - }); - viewer.scene.requestRender(); - }, VIEW); - const settled = await page - .waitForFunction( - () => { - const primitives = window.__godsEyeView.viewer.scene.primitives; - for (let index = 0; index < primitives.length; index++) { - const primitive = primitives.get(index); - if ( - typeof primitive.tilesLoaded === 'boolean' && - !primitive.tilesLoaded - ) - return false; - } - return true; - }, - { timeout: 90_000 }, - ) - .then( - () => true, - () => false, - ); + await whenSettled(page); + await evaluateWithGeo( + page, + async (qaGeo, view) => { + window.__godsEyeView.engine.camera.cancelFlight?.(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ + longitude: (view.lon * Math.PI) / 180, + latitude: (view.lat * Math.PI) / 180, + height: view.height, + }), + orientation: { + heading: (view.heading * Math.PI) / 180, + pitch: (view.pitch * Math.PI) / 180, + roll: 0, + }, + }); + window.__godsEyeView.engine.render.request(); + }, + VIEW, + ); + const settled = await whenTilesLoaded(page, { timeoutMs: 90_000 }).then( + () => true, + () => false, + ); check('visible tile content settles before drawing', settled); // UTILITIES starts collapsed on a first run: open it the way a user does. @@ -321,9 +300,9 @@ try { const before = await drawState(); for (let cycle = 0; cycle < 3; cycle += 1) { await page.click('#draw-toggle'); - await wait(120); + await whenSettled(page); await page.click('#draw-toggle'); - await wait(120); + await whenSettled(page); } const after = await drawState(); check( @@ -379,7 +358,7 @@ try { await clickWorld(600, 470); const beforeBackspace = (await drawState()).vertices; await page.keyboard.press('Backspace'); - await wait(120); + await whenSettled(page); const afterBackspace = (await drawState()).vertices; check( 'Backspace removes the last vertex only', @@ -415,7 +394,7 @@ try { ); await page.mouse.click(410, 500, { count: 2 }); const areaLanded = await waitForMarks(3); - await wait(900); + await whenSettled(page); const afterDoubleClick = await page.evaluate(() => window.__gevAnnotations.count(), ); @@ -440,7 +419,7 @@ try { await page.click(rowControl(areaId, 'name')); await page.keyboard.type('Civic Center block'); await page.keyboard.press('Enter'); - await wait(300); + await whenSettled(page); const areaMark = (await marks()).find( (mark) => mark.label === 'Civic Center block', ); @@ -453,7 +432,7 @@ try { 'naming a shape keeps Draw on and the shape in progress untouched', (await drawState()).active === true, ); - await wait(1200); + await whenSettled(page); const amberAfter = await amberPixels(); check( 'the finished area actually PAINTS — amber pixels appear on this surface', @@ -512,32 +491,31 @@ try { // Fly-to frames the line by its whole path: once the flight settles, the // middle of the line sits near the middle of the view. await page.click(rowControl(ids[1], 'fly')); - await page - .waitForFunction(() => !window.__godsEyeView.viewer.camera._currentFlight, { - timeout: 15_000, - }) - .catch(() => {}); - await wait(300); - const framed = await page.evaluate((id) => { - const { viewer } = window.__godsEyeView; - // No Cesium global in the page: borrow Cartesian3 from a live object. - const Cartesian3 = viewer.camera.positionWC.constructor; - const mark = window.__gevAnnotations.list().find((m) => m.id === id); - const lons = mark.path.map((p) => p.lon); - const lats = mark.path.map((p) => p.lat); - const middle = Cartesian3.fromDegrees( - (Math.min(...lons) + Math.max(...lons)) / 2, - (Math.min(...lats) + Math.max(...lats)) / 2, - ); - const at = viewer.scene.cartesianToCanvasCoordinates(middle); - const canvas = viewer.scene.canvas; - return ( - at && { - dx: Math.abs(at.x - canvas.clientWidth / 2) / canvas.clientWidth, - dy: Math.abs(at.y - canvas.clientHeight / 2) / canvas.clientHeight, - } - ); - }, ids[1]); + await whenSettled(page).catch(() => {}); + await whenSettled(page); + const framed = await evaluateWithGeo( + page, + async (qaGeo, id) => { + // No Cesium global in the page: borrow Cartesian3 from a live object. + const Cartesian3 = qaGeo.cartesian3; + const mark = window.__gevAnnotations.list().find((m) => m.id === id); + const lons = mark.path.map((p) => p.lon); + const lats = mark.path.map((p) => p.lat); + const middle = Cartesian3.fromDegrees( + (Math.min(...lons) + Math.max(...lons)) / 2, + (Math.min(...lats) + Math.max(...lats)) / 2, + ); + const at = window.__godsEyeView.engine.projector.worldToWindow(middle); + const canvas = window.__godsEyeView.engine.host.canvas; + return ( + at && { + dx: Math.abs(at.x - canvas.clientWidth / 2) / canvas.clientWidth, + dy: Math.abs(at.y - canvas.clientHeight / 2) / canvas.clientHeight, + } + ); + }, + ids[1], + ); check( 'fly to frames that shape: its middle is near the middle of the view', Boolean(framed) && framed.dx < 0.2 && framed.dy < 0.2, @@ -550,11 +528,11 @@ try { await page.click(rowControl(lineId, 'swatch')); await page.click(rowControl(lineId, 'color-amber')); await markReaches(lineId, { color: 'amber', alpha: 1 }); - await wait(800); + await whenSettled(page); const shown = await canvasShot(); await page.click(rowControl(lineId, 'toggle')); await markReaches(lineId, { hidden: true, alpha: 0 }); - await wait(800); + await whenSettled(page); const hiddenShot = await canvasShot(); // Amber the shown line had and the hidden one lost; amber that appears // only once it is hidden is noise, and must stay small beside it. @@ -566,7 +544,7 @@ try { `${gone} amber pixels went with the line, ${noise} appeared`, ); await page.click(rowControl(lineId, 'toggle')); - await wait(1200); + await whenSettled(page); const back = await amberOnlyIn(await canvasShot(), hiddenShot); check( 'and paints again when shown', @@ -581,7 +559,7 @@ try { await clickWorld(560, 340); check('a new shape is in progress', (await drawState()).vertices === 2); await page.keyboard.press('Escape'); - await wait(150); + await whenSettled(page); const afterFirstEscape = await drawState(); check( 'Escape cancels the shape in progress and stays in draw mode', @@ -589,7 +567,7 @@ try { `vertices ${afterFirstEscape.vertices}, active ${afterFirstEscape.active}`, ); await page.keyboard.press('Escape'); - await wait(150); + await whenSettled(page); const afterSecondEscape = await drawState(); check( 'a second Escape leaves draw mode and gives the pointer back', @@ -667,7 +645,7 @@ try { ); await page.keyboard.press('Escape'); await page.keyboard.press('Escape'); - await wait(150); + await whenSettled(page); } // ── what a reload actually does ───────────────────────────────────────── @@ -675,12 +653,10 @@ try { window.__gevAnnotations.count(), ); await page.reload({ waitUntil: 'domcontentloaded' }); - await page.waitForFunction( - () => - window.__gevAnnotations && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 90_000 }, - ); + await page.waitForFunction(() => window.__gevAnnotations, { + timeout: 90_000, + }); + await whenSettled(page); const afterReload = await page.evaluate(() => window.__gevAnnotations.count(), ); @@ -722,25 +698,28 @@ try { // flight left a low, near-horizontal view there) with a boot flight that can // land after a setView: set the view until it holds. for (let attempt = 0; attempt < 6; attempt += 1) { - await page.evaluate((view) => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.cancelFlight?.(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ - longitude: (view.lon * Math.PI) / 180, - latitude: (view.lat * Math.PI) / 180, - height: view.height, - }), - orientation: { - heading: 0, - pitch: (view.pitch * Math.PI) / 180, - roll: 0, - }, - }); - }, VIEW); - await wait(2500); + await evaluateWithGeo( + page, + async (qaGeo, view) => { + window.__godsEyeView.engine.camera.cancelFlight?.(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ + longitude: (view.lon * Math.PI) / 180, + latitude: (view.lat * Math.PI) / 180, + height: view.height, + }), + orientation: { + heading: 0, + pitch: (view.pitch * Math.PI) / 180, + roll: 0, + }, + }); + }, + VIEW, + ); + await whenSettled(page); const held = await page.evaluate((view) => { - const carto = window.__godsEyeView.viewer.camera.positionCartographic; + const carto = window.__godsEyeView.engine.camera.pose().cartographic; return ( Math.abs((carto.latitude * 180) / Math.PI - view.lat) < 0.01 && Math.abs((carto.longitude * 180) / Math.PI - view.lon) < 0.01 && @@ -767,7 +746,7 @@ try { const beforeButtonEnter = await drawState(); await page.focus('#draw-clear'); await page.keyboard.press('Enter'); - await wait(600); + await whenSettled(page); const afterButtonEnter = await drawState(); check( 'Enter on a keyboard-focused button activates the button, it does not finish the shape', @@ -790,7 +769,7 @@ try { await waitForMarks(1), ); await page.click('#draw-clear'); - await wait(400); + await whenSettled(page); const cleared = await drawState(); check( 'Clear removes every mark from the board', @@ -815,7 +794,7 @@ try { `${degenerateVertices} vertices`, ); await pressFinish(); - await wait(600); + await whenSettled(page); const degenerate = await drawState(); check( 'three points in a line are refused with a reason, not placed', @@ -824,20 +803,19 @@ try { ); // ── a shape drawn across the antimeridian lands where it was drawn ────── - await page.evaluate(() => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.cancelFlight?.(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo) => { + window.__godsEyeView.engine.camera.cancelFlight?.(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: (179.9995 * Math.PI) / 180, latitude: 0, height: 3000, }), orientation: { heading: 0, pitch: (-88 * Math.PI) / 180, roll: 0 }, }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); - await wait(2500); + await whenSettled(page); await page.evaluate(() => { const tool = window.__gevDrawTool; tool.cancel(); @@ -865,7 +843,7 @@ try { await page.evaluate(() => window.__gevAnnotations.clear()); await page.click('#draw-toggle'); - await wait(200); + await whenSettled(page); const finalState = await drawState(); check( 'leaving draw mode gives back the pointer, the handler and the double-click', @@ -880,7 +858,7 @@ try { () => window.__godsEyeView.viewer.dataSources.length, ); await page.click('#draw-toggle'); // destroy from an ACTIVE session: the harder case - await wait(200); + await whenSettled(page); const destroyed = await page.evaluate(async () => { const tool = window.__gevDrawTool; const wasActive = tool.active; @@ -936,7 +914,7 @@ try { errors.join(' | '), ); } finally { - await browser.close(); + await closeBrowser(browser); } console.log(`RESULT: ${failures} failures`); process.exitCode = failures ? 1 : 0; @@ -975,20 +953,23 @@ async function findClickableTarget() { .catch(() => null); if (camera) { - await page.evaluate((spot) => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.cancelFlight?.(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ - longitude: (spot.lon * Math.PI) / 180, - latitude: (spot.lat * Math.PI) / 180, - height: 600, - }), - orientation: { heading: 0, pitch: (-80 * Math.PI) / 180, roll: 0 }, - }); - viewer.scene.requestRender(); - }, camera); - await wait(4000); + await evaluateWithGeo( + page, + async (qaGeo, spot) => { + window.__godsEyeView.engine.camera.cancelFlight?.(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ + longitude: (spot.lon * Math.PI) / 180, + latitude: (spot.lat * Math.PI) / 180, + height: 600, + }), + orientation: { heading: 0, pitch: (-80 * Math.PI) / 180, roll: 0 }, + }); + window.__godsEyeView.engine.render.request(); + }, + camera, + ); + await whenSettled(page); const spot = await onScreen(['cctv']); if (spot) return { ...spot, kind: 'cctv', layer: 'CCTV camera' }; } @@ -1021,20 +1002,23 @@ async function findClickableTarget() { }) .catch(() => null); if (!site) return null; - await page.evaluate((spot) => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.cancelFlight?.(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ - longitude: (spot.lon * Math.PI) / 180, - latitude: (spot.lat * Math.PI) / 180, - height: 2500, - }), - orientation: { heading: 0, pitch: (-88 * Math.PI) / 180, roll: 0 }, - }); - viewer.scene.requestRender(); - }, site); - await wait(4000); + await evaluateWithGeo( + page, + async (qaGeo, spot) => { + window.__godsEyeView.engine.camera.cancelFlight?.(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ + longitude: (spot.lon * Math.PI) / 180, + latitude: (spot.lat * Math.PI) / 180, + height: 2500, + }), + orientation: { heading: 0, pitch: (-88 * Math.PI) / 180, roll: 0 }, + }); + window.__godsEyeView.engine.render.request(); + }, + site, + ); + await whenSettled(page); const spot = await onScreen([ 'local-datacenters-tips', 'local-datacenters-pins', diff --git a/scripts/qa-journey-recorder.mjs b/scripts/qa-journey-recorder.mjs index cefb2c713..b63432a93 100644 --- a/scripts/qa-journey-recorder.mjs +++ b/scripts/qa-journey-recorder.mjs @@ -16,6 +16,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; +import { evaluateWithGeo } from './qa/geometry.mjs'; const execFileAsync = promisify(execFile); @@ -52,8 +53,8 @@ export function frameStats(frames) { export async function installFrameProbe(page) { await page.evaluate(() => { if (window.__gevFrameProbe) return; - const { viewer, engine } = window.__godsEyeView; - const events = viewer?.scene ?? engine.frames; + const { engine } = window.__godsEyeView; + const events = engine.frames; const probe = { frames: [], started: 0 }; window.__gevFrameProbe = probe; let last = performance.now(); @@ -102,58 +103,25 @@ export function pageNow(page) { * @param {number} seconds Flight duration; 0 jumps. */ export async function moveCamera(page, view, seconds) { - if ( - await page.evaluate(() => window.__godsEyeView.engine?.kind === 'navara') - ) { - await page.evaluate( - async (v, duration) => { - const geo = await import('/src/geo/index.js'); - const camera = window.__godsEyeView.engine.camera; - camera.cancelFlight(); - await new Promise((resolve) => - camera.flyTo({ - destination: geo.cartesian3.fromDegrees(v.lon, v.lat, v.height), - orientation: { - heading: ((v.heading ?? 0) * Math.PI) / 180, - pitch: ((v.pitch ?? -45) * Math.PI) / 180, - roll: 0, - }, - duration, - maximumHeight: Math.max(v.height, camera.height()), - complete: resolve, - cancel: resolve, - }), - ); - }, - view, - seconds, - ); - return; - } - await page.evaluate( - (v, duration) => - new Promise((resolve) => { - const { viewer } = window.__godsEyeView; - const C = viewer.camera; - C.cancelFlight(); - C.flyTo({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ - latitude: (v.lat * Math.PI) / 180, - longitude: (v.lon * Math.PI) / 180, - height: v.height, - }), + await evaluateWithGeo( + page, + async (geo, v, duration) => { + const camera = window.__godsEyeView.engine.camera; + camera.cancelFlight(); + await new Promise((resolve) => + camera.flyTo({ + destination: geo.cartesian3.fromDegrees(v.lon, v.lat, v.height), orientation: { heading: ((v.heading ?? 0) * Math.PI) / 180, pitch: ((v.pitch ?? -45) * Math.PI) / 180, roll: 0, }, duration, - // Straight, eased motion like a drag or wheel zoom, not an arc. - maximumHeight: Math.max(v.height, C.positionCartographic.height), complete: resolve, cancel: resolve, - }); - }), + }), + ); + }, view, seconds, ); From 373edf298924806dd38f4e9a5c5d184ad19e0f7b Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 16:43:58 +0400 Subject: [PATCH 3/6] Move QA scripts to the shared harness, batch 2 of 4 Each script launches and closes Chrome through scripts/qa/browser.mjs, waits on the application through scripts/qa/ready.mjs instead of the loading screen and fixed pauses, and reads the contract instead of Cesium where the check is not about Cesium itself. --- scripts/qa-emergency-squawk.mjs | 62 +++++------ scripts/qa-enrich-ambient.mjs | 66 +++--------- scripts/qa-failstate-b10.mjs | 53 ++-------- scripts/qa-final-polish-credits.mjs | 25 +++-- scripts/qa-firms.mjs | 60 ++++------- scripts/qa-firstrun.mjs | 61 ++++------- scripts/qa-floor-hold.mjs | 45 ++++---- scripts/qa-floor-verify.mjs | 46 ++++----- scripts/qa-flyroute-cinema.mjs | 69 +++++-------- scripts/qa-focus-evidence.mjs | 38 +++---- scripts/qa-form-fields.mjs | 19 ++-- scripts/qa-gbfs-names.mjs | 26 ++--- scripts/qa-gbfs-names.test.mjs | 1 + scripts/qa-geo-import-large.mjs | 61 +++++------ scripts/qa-geo-import-regressions.mjs | 51 ++++----- scripts/qa-geo-import.mjs | 45 ++++---- scripts/qa-ghostmaps.mjs | 119 ++++++++++----------- scripts/qa-gnss.mjs | 138 ++++++++++++------------- scripts/qa-infra-lod.mjs | 93 +++++++---------- scripts/qa-installation-navigation.mjs | 42 +++----- scripts/qa-installation-polish.mjs | 29 +++--- scripts/qa-l9-matrix.mjs | 54 ++++------ scripts/qa-label-readability.mjs | 55 +++------- scripts/qa-labels.mjs | 49 ++------- scripts/qa-layer-panel.mjs | 12 +-- scripts/qa-layer-token-twochar.mjs | 10 +- 26 files changed, 526 insertions(+), 803 deletions(-) diff --git a/scripts/qa-emergency-squawk.mjs b/scripts/qa-emergency-squawk.mjs index aeedd6125..c23965bfb 100644 --- a/scripts/qa-emergency-squawk.mjs +++ b/scripts/qa-emergency-squawk.mjs @@ -1,4 +1,7 @@ #!/usr/bin/env node +// Cesium-only probes: native primitive colors independently verify the Cesium aircraft tint. +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled, whenFramesDrawn, whenTilesLoaded } from './qa/ready.mjs'; /** * qa-emergency-squawk.mjs — headless proof for transponder emergency highlighting. * @@ -26,7 +29,7 @@ * Exits non-zero on any FAIL. Does not commit anything. */ -import puppeteer from 'puppeteer'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import { stubPlanespotters } from './qa-photo-stub.mjs'; import fs from 'node:fs'; import path from 'node:path'; @@ -52,7 +55,6 @@ function record(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)); /** OpenSky state vector: squawk sits at index 14. */ const civilRow = (icao24, callsign, lon, lat, squawk) => [ @@ -175,13 +177,9 @@ const isAmber = (c) => Boolean(c) && c.r > 180 && c.g > 110 && c.b < 140 && !isRed(c); async function shoot(page, name) { - await page - .waitForFunction( - () => window.__godsEyeView.viewer.scene.globe.tilesLoaded, - { timeout: 30000 }, - ) - .catch(() => {}); - await sleep(600); + await whenTilesLoaded(page, { timeoutMs: 30000 }).catch(() => {}); + await whenSettled(page); + await whenFramesDrawn(page); await page.screenshot({ path: path.join(SHOTS_DIR, `emergency-${name}.png`), }); @@ -200,19 +198,10 @@ async function main() { 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', + const browser = await launchBrowser({ + headless: true, protocolTimeout: 180000, - ...(executablePath && fs.existsSync(executablePath) - ? { executablePath } - : {}), args: [ - '--use-gl=angle', - '--use-angle=swiftshader', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', @@ -252,31 +241,30 @@ async function main() { timeout: TIMEOUT_MS, }); await page.waitForFunction( - () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + () => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager, { timeout: TIMEOUT_MS }, ); - await sleep(5000); + await whenSettled(page); + await whenFramesDrawn(page); await page.keyboard.press('Escape'); - await page.evaluate( - (lo, la) => { - const gev = window.__godsEyeView; + await evaluateWithGeo( + page, + async (qaGeo, lo, la) => { const d2r = Math.PI / 180; try { - gev.viewer.camera.cancelFlight(); + window.__godsEyeView.engine.camera.cancelFlight(); } catch { /* no flight active */ } - gev.viewer.camera.setView({ - destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian( - { - longitude: lo * d2r, - latitude: la * d2r, - height: 260000, - }, - ), + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ + longitude: lo * d2r, + latitude: la * d2r, + height: 260000, + }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); - gev.viewer.scene.requestRender?.(); + window.__godsEyeView.engine.render.request?.(); }, lon, lat, @@ -297,7 +285,7 @@ async function main() { polling: 500, }) .catch(() => {}); - await sleep(1500); + await whenFramesDrawn(page); const colors = await page.evaluate(probe); const show = (id) => JSON.stringify(colors[id] ?? null); record( @@ -538,7 +526,7 @@ async function main() { console.error('\x1b[31mHarness error:\x1b[0m', err); exitCode = 3; } finally { - await browser.close(); + await closeBrowser(browser); } const pass = results.filter((r) => r.ok).length; diff --git a/scripts/qa-enrich-ambient.mjs b/scripts/qa-enrich-ambient.mjs index 59fc6a6c0..d3676bc0a 100644 --- a/scripts/qa-enrich-ambient.mjs +++ b/scripts/qa-enrich-ambient.mjs @@ -50,9 +50,10 @@ * Exits non-zero if any assertion fails. Never commits anything. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; import { classifyAircraft, CLASS_SCALE_2D } from '../src/data/aircraftClass.js'; import { aircraftIcon } from '../src/data/aircraftIcons.js'; @@ -91,26 +92,7 @@ const SESSION_CAP = 300; // ENRICH_AMBIENT_BUDGET_CEIL (rolling-bucket cei const BUDGET_QA = { ceil: SESSION_CAP, refillTokens: 6, windowMs: 3600000 }; const RESUME_WINDOW_MS = 1000; // E11 swaps windowMs to this to unlock refills -const CHROME_EXECUTABLE_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - // Prefer puppeteer's version-pinned Chrome-for-Testing over the system - // Chrome: /Applications auto-updates underneath the harnesses, and its - // software-GL behavior shifts across majors (system Chrome 150 blew the - // tile-gated drain budget under SwiftShader on 2026-07-30 — six - // false-negative qa-cctv-v2 runs against a healthy build). A deterministic - // pinned browser beats the newest one for regression harnesses. - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -function findChromeExecutable() { - for (const candidate of CHROME_EXECUTABLE_CANDIDATES) { - try { if (fs.existsSync(candidate)) return candidate; } catch { /* skip */ } - } - return null; -} const results = []; function record(name, ok, detail) { @@ -186,21 +168,9 @@ async function main() { fs.mkdirSync(SHOT_DIR, { recursive: true }); - const chromeExecutable = findChromeExecutable(); - const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', - ...(chromeExecutable ? { executablePath: chromeExecutable } : {}), - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--use-gl=angle', - `--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`, - '--disable-dev-shm-usage', - '--disable-web-security', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--window-size=1280,800', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-web-security', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1280,800'], }); const consoleErrors = []; @@ -303,8 +273,9 @@ async function main() { // A cold dev server compiles the Navara chunks on first load. { timeout: 180000, polling: 200 } ); + await whenSettled(page); await page.evaluate(() => window.__godsEyeView.styleManager.initialRestorePromise); - await page.waitForFunction(() => document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 180000 }); + await whenSettled(page); await page.evaluate(() => window.__godsEyeView.styleManager.orbitController.stop()); console.log(' App globals ready.'); @@ -339,17 +310,16 @@ async function main() { console.log('Priming straight-flight history through the render delay (30 s)...'); const primed = await page.evaluate(async () => { const dm = window.__godsEyeView.dataManager; - const v = window.__godsEyeView.viewer ?? null; window.__ENR.timeOffsetSec = -32; dm.setLayerParams('flights', { models3d: false }); // ambient budget excludes model-priority enrichment await dm.setEnabled('flights', true); const fl = dm.layers.get('flights').module; for (const off of [-24, -16, -8, 0]) { window.__ENR.timeOffsetSec = off; - await fl.update(v); + await fl.update(); } window.__ENR.timeOffsetSec = 0; - window.__ENR_DRIVER = setInterval(() => { fl.update(v); }, 15000); + window.__ENR_DRIVER = setInterval(() => { fl.update(); }, 15000); return { count: fl.getStats().count, starts: window.__ENRICH_LOG.starts.length, models3d: fl.getParams().models3d }; }); console.log(` flights count=${primed.count} | adsbdb requests started (held): ${primed.starts}`); @@ -373,7 +343,7 @@ async function main() { record('E2 baseline: all 12 category-0 planes default to the airliner glyph', baselineBad.length === 0, baselineBad.length ? baselineBad.join(' ') : 'all 12 identical airliner silhouettes (the field-data problem)'); - await sleep(1200); // let a fleet tick land rotations before the "before" shot + await whenSettled(page); // let a fleet tick land rotations before the "before" shot await page.screenshot({ path: path.join(SHOT_DIR, 'enrich-before-monoculture.png') }); console.log(' saved enrich-before-monoculture.png (12 identical airliner glyphs)'); @@ -461,10 +431,9 @@ async function main() { console.log('\nE8 — next poll after enrichment'); await page.evaluate(async () => { const dm = window.__godsEyeView.dataManager; - const v = window.__godsEyeView.viewer ?? null; - await dm.layers.get('flights').module.update(v); + await dm.layers.get('flights').module.update(); }); - await sleep(500); + await whenSettled(page); const afterPoll = await page.evaluate(() => ({ billboards: window.__collectBillboards(), starts: window.__ENRICH_LOG.starts.length, @@ -497,7 +466,6 @@ async function main() { const startsBeforeBatch = await page.evaluate(() => window.__ENRICH_LOG.starts.length); await page.evaluate(async ({ hexes, rowLat, rowLon0, stepDeg }) => { const dm = window.__godsEyeView.dataManager; - const v = window.__godsEyeView.viewer ?? null; // Two fresh rows just north of the original one — still inside the // top-down 20 km frame, so the sweep's frustum test keeps them. // STATIONARY (speed 0): moving planes drift north out of the frustum at @@ -525,8 +493,8 @@ async function main() { await new Promise((resolve) => requestAnimationFrame(resolve)); } const fl = dm.layers.get('flights').module; - await fl.update(v); // sweep enqueues up to 150 ambient jobs - await fl.update(v); // spend the remaining initial tokens; later polls must stall + await fl.update(); // sweep enqueues up to 150 ambient jobs + await fl.update(); // spend the remaining initial tokens; later polls must stall }, { hexes: batchHexes, rowLat: ROW_LAT, rowLon0: ROW_LON0, stepDeg: ROW_STEP_DEG }); // Wait for the drip to drain whatever was enqueued, then settle. await page.waitForFunction( @@ -537,7 +505,7 @@ async function main() { // One more poll while exhausted — still nothing new. await page.evaluate(async () => { const dm = window.__godsEyeView.dataManager; - await dm.layers.get('flights').module.update(window.__godsEyeView.viewer ?? null); + await dm.layers.get('flights').module.update(); }); await sleep(1000); const exhausted = await page.evaluate(() => ({ @@ -562,7 +530,7 @@ async function main() { await sleep(RESUME_WINDOW_MS + 200); // a full (shortened) refill window elapses await page.evaluate(async () => { const dm = window.__godsEyeView.dataManager; - await dm.layers.get('flights').module.update(window.__godsEyeView.viewer ?? null); + await dm.layers.get('flights').module.update(); }); const expectedTotal = startsBeforeBatch + BATCH_COUNT; // every batch plane eventually requested const resumed = await page.waitForFunction( @@ -581,7 +549,7 @@ async function main() { finish(); } finally { - await browser.close(); + await closeBrowser(browser); } function finish() { diff --git a/scripts/qa-failstate-b10.mjs b/scripts/qa-failstate-b10.mjs index 2033a2731..8514b453c 100644 --- a/scripts/qa-failstate-b10.mjs +++ b/scripts/qa-failstate-b10.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +// Cesium-only probes: render-loop shutdown and manual rendering inject a Cesium failure. +import { whenUiSettled, whenSettled } from './qa/ready.mjs'; /** * qa-failstate-b10.mjs * @@ -31,7 +33,7 @@ * Exits non-zero if any assertion fails. DOES NOT COMMIT anything. */ -import puppeteer from 'puppeteer'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; @@ -58,28 +60,7 @@ function withDetectDebug(url) { const HEADFUL = getFlag('--headful'); const ARTIFACT_DIR = getOpt('--artifact-dir', null); -const CHROME_EXECUTABLE_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - // Prefer puppeteer's version-pinned Chrome-for-Testing over the system - // Chrome: /Applications auto-updates underneath the harnesses, and its - // software-GL behavior shifts across majors (system Chrome 150 blew the - // tile-gated drain budget under SwiftShader on 2026-07-30 — six - // false-negative qa-cctv-v2 runs against a healthy build). A deterministic - // pinned browser beats the newest one for regression harnesses. - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -function findChromeExecutable() { - for (const candidate of CHROME_EXECUTABLE_CANDIDATES) { - try { - if (fs.existsSync(candidate)) return candidate; - } catch { /* ignore */ } - } - return null; -} const results = []; function record(name, ok, detail) { @@ -88,7 +69,6 @@ function record(name, ok, detail) { console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); } -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function readLayerControl(page, layerId, expectedLabel) { await page.waitForFunction( @@ -127,7 +107,7 @@ async function captureLayerControl(page, layerId, filename) { const rect = element?.getBoundingClientRect(); return !!rect && rect.width > 0 && rect.height > 0; }, { timeout: 5_000 }, layerId); - await sleep(100); + await whenUiSettled(page); await row.screenshot({ path: path.join(ARTIFACT_DIR, filename) }); } @@ -156,21 +136,9 @@ async function main() { process.exit(2); } - const chromeExecutable = findChromeExecutable(); - const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', - ...(chromeExecutable ? { executablePath: chromeExecutable } : {}), - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--use-gl=angle', - '--use-angle=swiftshader', - '--disable-dev-shm-usage', - '--disable-web-security', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--window-size=1280,800', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-web-security', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1280,800'], }); let exitCode = 0; @@ -221,7 +189,8 @@ async function main() { && window.__godsEyeView.styleManager, { timeout: 60000 }, ); - await sleep(1500); + await whenSettled(page); + await whenUiSettled(page); // ── (0) Manager-owned periodic refresh → shared work/failure/recovery ── console.log('\n(0) Universal periodic refresh feedback...'); @@ -524,7 +493,7 @@ async function main() { } finally { if (tileset) tileset.show = priorTilesetShow; viewer.useDefaultRenderLoop = priorDefaultLoop; - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); } const detState = gev.styleManager.getDetectionState?.() || null; return { present: true, nonEmpty, solid, sampled, mode: detState?.detectionMode ?? null }; @@ -550,7 +519,7 @@ async function main() { console.error('\x1b[31mHarness error:\x1b[0m', e); exitCode = 3; } finally { - await browser.close(); + await closeBrowser(browser); } const pass = results.filter((r) => r.ok === true).length; diff --git a/scripts/qa-final-polish-credits.mjs b/scripts/qa-final-polish-credits.mjs index dde1431b9..3e0e2aab5 100644 --- a/scripts/qa-final-polish-credits.mjs +++ b/scripts/qa-final-polish-credits.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node /** Persistent inline attribution through actual OSM layer display lifetimes. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; -import puppeteer from 'puppeteer'; import { moveCamera } from './qa-journey-recorder.mjs'; const url = process.argv.find((a) => /^https?:/.test(a)); if (!url) throw new Error('Supply the running server URL'); @@ -10,9 +11,9 @@ const dir = process.env.QA_POLISH_DIR || 'qa-shots/final-polish'; await fs.mkdir(dir, { recursive: true }); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const result = { states: [], errors: [] }; -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, - args: ['--use-angle=metal'], + args: [], }); try { const page = await browser.newPage(); @@ -25,12 +26,10 @@ try { sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'), ); await page.goto(`${url}/?welcome=0`, { waitUntil: 'domcontentloaded' }); - await page.waitForFunction( - () => - window.__godsEyeView?.dataManager && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 120000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.dataManager, { + timeout: 120000, + }); + await whenSettled(page); await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, ); @@ -53,7 +52,7 @@ try { value, ); const shot = async (name, osm, tiles) => { - await sleep(300); + await whenSettled(page); const state = await page.$eval('#cesium-credits', (e) => { const nodes = [ ...e.querySelectorAll('a,img,.cesium-credit-expand-link'), @@ -111,7 +110,7 @@ try { }); result.states.push({ name, ...state }); }; - await sleep(1600); + await whenSettled(page); await shot('nothing', false, false); await toggle('alpr-cameras', true); await page.waitForFunction( @@ -137,7 +136,7 @@ try { await shot('traffic', true, true); for (const width of [1280, 1920]) { await page.setViewport({ width, height: 900 }); - await sleep(500); + await whenSettled(page); await shot(`traffic-${width}`, true, true); } await page.setViewport({ width: 1440, height: 900 }); @@ -154,5 +153,5 @@ try { console.log(JSON.stringify(result, null, 2)); } finally { await fs.writeFile(`${dir}/credits.json`, JSON.stringify(result, null, 2)); - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-firms.mjs b/scripts/qa-firms.mjs index 673ac9498..141e03cb8 100644 --- a/scripts/qa-firms.mjs +++ b/scripts/qa-firms.mjs @@ -1,4 +1,7 @@ #!/usr/bin/env node +// Cesium-only probes: entity sentinels and native flight interception verify the Cesium selection path. +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled, whenFramesDrawn } from './qa/ready.mjs'; /** * qa-firms.mjs — headless proof for the LIVE NASA FIRMS fires layer. * @@ -29,7 +32,7 @@ * Exits non-zero on any FAIL. Does not commit anything. */ -import puppeteer from 'puppeteer'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import { isExpectedPreviewSetupError, redact, @@ -54,33 +57,14 @@ const APP_URL = appUrl.href; const HEADFUL = argv.includes('--headful'); const FIXTURES = argv.includes('--fixtures'); const PREVIEW = argv.includes('--preview'); -const ANGLE = getOpt('--angle', 'swiftshader'); +const ANGLE = process.env.QA_ANGLE || 'metal'; const OUT = getOpt('--out', null); const SHOTS_DIR = OUT ? path.resolve(OUT).replace(/\.json$/, '') + '-shots' : path.join(REPO_ROOT, 'qa-shots'); if (!['metal', 'swiftshader'].includes(ANGLE)) throw new Error('Unknown ANGLE backend'); const runtimeErrors = []; const expectedSourceErrors = []; -const CHROME_EXECUTABLE_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - // Prefer puppeteer's version-pinned Chrome-for-Testing over the system - // Chrome: /Applications auto-updates underneath the harnesses, and its - // software-GL behavior shifts across majors (system Chrome 150 blew the - // tile-gated drain budget under SwiftShader on 2026-07-30 — six - // false-negative qa-cctv-v2 runs against a healthy build). A deterministic - // pinned browser beats the newest one for regression harnesses. - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -function findChromeExecutable() { - for (const candidate of CHROME_EXECUTABLE_CANDIDATES) { - try { if (fs.existsSync(candidate)) return candidate; } catch { /* ignore */ } - } - return null; -} const results = []; function record(name, ok, detail) { @@ -126,7 +110,7 @@ async function waitForCardCanvasInk(page, { timeoutMs = 12000 } = {}) { let consecutive = 0; let sample = null; while (Date.now() < deadline) { - await page.evaluate(() => window.__godsEyeView?.viewer?.scene?.requestRender?.()); + await page.evaluate(() => window.__godsEyeView.engine.render.request?.()); await sleep(150); sample = await cardCanvasInk(page); if (sample.present && sample.entries > 0 && sample.painted > 0 && sample.ink > 500) { @@ -160,7 +144,7 @@ async function waitForFirmsActionCount(page, expected, { timeoutMs = 12000 } = { const deadline = Date.now() + timeoutMs; let snapshot = await firmsActionSnapshot(page); while (Date.now() < deadline && snapshot.count !== expected) { - await page.evaluate(() => window.__godsEyeView?.viewer?.scene?.requestRender?.()); + await page.evaluate(() => window.__godsEyeView.engine.render.request?.()); await sleep(175); snapshot = await firmsActionSnapshot(page); } @@ -174,9 +158,10 @@ async function bootAndEnable(page, { timeoutS = 45 } = {}) { () => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager, { timeout: 60000 }, ); + await whenSettled(page); const actualEngine = await page.evaluate(() => window.__godsEyeView.engine.kind); if (actualEngine !== ENGINE) throw new Error('Requested FIRMS engine did not load'); - await sleep(5000); + await whenSettled(page); await page.keyboard.press('Escape'); return page.evaluate(async (tS) => { const dm = window.__godsEyeView.dataManager; @@ -194,7 +179,7 @@ async function bootAndEnable(page, { timeoutS = 45 } = {}) { /** Teleport the camera (duck-typed cartographic — no Cesium global). */ async function setView(page, lon, lat, height) { - await page.evaluate(async (lo, la, h) => { + await evaluateWithGeo(page, async (qaGeo, lo, la, h) => { const gev = window.__godsEyeView; if (!gev.viewer) { const geo = await import('/src/geo/index.js'); @@ -203,15 +188,14 @@ async function setView(page, lon, lat, height) { gev.engine.render.request('firms-legacy-view'); return; } - const ell = gev.viewer.scene.globe.ellipsoid; const d2r = Math.PI / 180; // The app's intro flyTo animation clobbers a setView issued mid-flight. - try { gev.viewer.camera.cancelFlight(); } catch { /* no flight active */ } - gev.viewer.camera.setView({ - destination: ell.cartographicToCartesian({ longitude: lo * d2r, latitude: la * d2r, height: h }), + try { window.__godsEyeView.engine.camera.cancelFlight(); } catch { /* no flight active */ } + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: lo * d2r, latitude: la * d2r, height: h }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); - gev.viewer.scene.requestRender?.(); + window.__godsEyeView.engine.render.request?.(); }, lon, lat, height); } @@ -270,15 +254,9 @@ async function main() { } fs.mkdirSync(SHOTS_DIR, { recursive: true }); - const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', - ...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}), - args: [ - '--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', `--use-angle=${ANGLE}`, - '--disable-dev-shm-usage', '--disable-web-security', - '--disable-background-timer-throttling', '--disable-renderer-backgrounding', - '--window-size=1440,900', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-web-security', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1440,900'], }); let exitCode = 0; @@ -484,7 +462,7 @@ async function main() { if (settled) break; await sleep(100); } - await sleep(250); + await whenFramesDrawn(page); const proof = await page.evaluate(() => { const gev = window.__godsEyeView; @@ -558,7 +536,7 @@ async function main() { console.error('\x1b[31mHarness error:\x1b[0m', e); exitCode = 3; } finally { - await browser.close(); + await closeBrowser(browser); } record('RUNTIME: no unexpected browser errors', runtimeErrors.length === 0, diff --git a/scripts/qa-firstrun.mjs b/scripts/qa-firstrun.mjs index c8291ed62..a5cdee601 100644 --- a/scripts/qa-firstrun.mjs +++ b/scripts/qa-firstrun.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { whenUiSettled } from './qa/ready.mjs'; /** * qa-firstrun — the mission launcher's contract, proved in the real app. * @@ -18,10 +19,10 @@ * every rendered/behavioral assertion below must go RED. A green --teeth run * means the assertions are not measuring what they claim to. */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import puppeteer from 'puppeteer'; const args = process.argv.slice(2); const getOpt = (flag, fallback) => { @@ -35,12 +36,6 @@ const HEADFUL = args.includes('--headful'); const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const SHOT_DIR = path.join(ROOT, 'qa-shots', 'firstrun'); -const CHROME_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - // Version-pinned Chrome-for-Testing over the auto-updating system Chrome. - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', -].filter(Boolean); const results = []; let currentSection = 'setup'; @@ -96,7 +91,7 @@ async function open(page, { hash = '', query = '', clearAll = true, clearSession { timeout: 45000 }, LAUNCHER, ).catch(() => {}); - await sleep(400); + await whenUiSettled(page); if (errorSink) errorSink.length = 0; } @@ -184,7 +179,7 @@ async function pick(page, choice, { timeout = 40000, settle = [] } = {}) { }, settle).catch(() => {}); } // Plus a render beat: settled data still has to reach the framebuffer. - await sleep(settle.length ? 3500 : 1500); + await whenUiSettled(page); } /** Every section the run is expected to reach; the teeth verdict requires all. */ @@ -236,7 +231,7 @@ async function runArbitrationSection(page, { shots, consoleErrors }) { record('the launcher is up before the scene starts', beforeScene.onScreen); await page.evaluate(() => document.body.classList.add('scene-playback-mode')); - await sleep(300); + await whenUiSettled(page); const duringScene = await launcherState(); record( 'a scene taking the screen makes the launcher YIELD, not just hide', @@ -249,7 +244,7 @@ async function runArbitrationSection(page, { shots, consoleErrors }) { // The key that used to dismiss an invisible launcher must now do nothing // to it — proven by the scene class still being set afterwards. await page.keyboard.press('Escape'); - await sleep(300); + await whenUiSettled(page); const afterEsc = await page.evaluate(() => ({ sceneStillOn: document.body.classList.contains('scene-playback-mode'), })); @@ -259,7 +254,7 @@ async function runArbitrationSection(page, { shots, consoleErrors }) { afterEsc.sceneStillOn ? 'scene class untouched by the launcher' : 'something else consumed the key', ); await page.evaluate(() => document.body.classList.remove('scene-playback-mode')); - await sleep(200); + await whenUiSettled(page); const afterScene = await launcherState(); record('a yielded launcher does not pop back when the scene ends', !afterScene.classVisible, `classVisible=${afterScene.classVisible}`); @@ -269,7 +264,7 @@ async function runArbitrationSection(page, { shots, consoleErrors }) { const beforeCockpit = await launcherState(); record('the launcher is up before cockpit engages', beforeCockpit.onScreen); await page.evaluate(() => document.body.classList.add('cockpit-mode')); - await sleep(300); + await whenUiSettled(page); const duringCockpit = await launcherState(); record( 'cockpit engaging makes the launcher YIELD before it can contest ESC', @@ -277,7 +272,7 @@ async function runArbitrationSection(page, { shots, consoleErrors }) { `classVisible=${duringCockpit.classVisible} — cockpit registers its capture listener first, so stacking must never arise`, ); await page.evaluate(() => document.body.classList.remove('cockpit-mode')); - await sleep(200); + await whenUiSettled(page); // ── Blocker repro: a surface that takes the screen with NO class ──────── // The attribution lightbox is full-screen at z-index 200 against the card's @@ -303,7 +298,7 @@ async function runArbitrationSection(page, { shots, consoleErrors }) { link?.click(); return !!link; }); - await sleep(300); + await whenUiSettled(page); const lightboxUp = await lightboxState(); record( 'the real "Data attribution" lightbox opens above the card', @@ -318,7 +313,7 @@ async function runArbitrationSection(page, { shots, consoleErrors }) { ); await page.keyboard.press('Escape'); - await sleep(400); + await whenUiSettled(page); const escUnderLightbox = await launcherState(); const lightboxAfterEsc = await lightboxState(); record( @@ -343,12 +338,12 @@ async function runArbitrationSection(page, { shots, consoleErrors }) { document.activeElement === document.querySelector('#cesium-credits .cesium-credit-expand-link') )); record('closing attribution restores its disclosure focus', attributionFocusRestored); - await sleep(300); + await whenUiSettled(page); const uncovered = await launcherState(); record('closing the lightbox hands the card back the key', uncovered.topmost, `topmost=${uncovered.topmost}`); await page.keyboard.press('Escape'); - await sleep(600); + await whenUiSettled(page); const afterUncoveredEsc = await launcherState(); record( 'ESC dismisses normally again once nothing is on top', @@ -407,7 +402,7 @@ async function runArbitrationSection(page, { shots, consoleErrors }) { // ...and appears once that surface clears. await page.evaluate(() => document.body.classList.remove('cockpit-mode')); - await sleep(800); + await whenUiSettled(page); const afterCockpitCleared = await launcherState(); record('the launcher appears once the surface clears', afterCockpitCleared.onScreen, `onScreen=${afterCockpitCleared.onScreen}`); @@ -439,21 +434,9 @@ async function main() { } fs.mkdirSync(SHOT_DIR, { recursive: true }); - const executablePath = CHROME_CANDIDATES.find((candidate) => { - try { return fs.existsSync(candidate); } catch { return false; } - }); - const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', - ...(executablePath ? { executablePath } : {}), - args: [ - '--no-sandbox', '--disable-setuid-sandbox', - // Metal is a macOS-only ANGLE backend; anywhere else it fails WebGL init. - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist'] - : ['--use-gl=angle', '--use-angle=swiftshader']), - '--disable-dev-shm-usage', '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', '--window-size=1440,900', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', ...(process.platform === 'darwin' ? ['--enable-gpu', '--ignore-gpu-blocklist'] : []), '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1440,900'], }); const shots = []; @@ -510,7 +493,7 @@ async function main() { // ESC dismisses. Stated as a TRANSITION (was up, now gone) — "not visible" // alone passes vacuously whenever the launcher never appeared at all. await page.keyboard.press('Escape'); - await sleep(600); + await whenUiSettled(page); const state = await appState(page); record('ESC dismisses the launcher', freshVisible && !(await launcherVisible(page)), freshVisible ? 'visible → dismissed' : 'never appeared, so nothing was dismissed'); @@ -530,11 +513,11 @@ async function main() { // Checkbox → durable suppression. await page.click('[data-first-run-suppress]'); - await sleep(200); + await whenUiSettled(page); const ticked = await appState(page); record('the checkbox writes durable suppression immediately', ticked.durable === 'suppressed', `durable=${ticked.durable}`); await page.keyboard.press('Escape'); - await sleep(400); + await whenUiSettled(page); await open(page, { clearAll: false, clearSession: true }); record('a suppressed profile stays quiet in later sessions', ticked.durable === 'suppressed' && !(await launcherVisible(page)), @@ -789,7 +772,7 @@ async function main() { if (list) list.scrollTop = 0; document.activeElement?.blur?.(); }, LAUNCHER); - await sleep(250); + await whenUiSettled(page); shots.push(await shoot(page, `launcher-${vp.name}`)); } await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 }); @@ -800,7 +783,7 @@ async function main() { consoleErrors.slice(0, 3).join(' | ') || 'clean'); }); } finally { - await browser.close(); + await closeBrowser(browser); } const failed = results.filter((entry) => !entry.ok); diff --git a/scripts/qa-floor-hold.mjs b/scripts/qa-floor-hold.mjs index 147301480..0a3f6ea7f 100644 --- a/scripts/qa-floor-hold.mjs +++ b/scripts/qa-floor-hold.mjs @@ -1,4 +1,7 @@ #!/usr/bin/env node +// Cesium-only probes: native primitive positions provide the Cesium floor oracle. +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled } from './qa/ready.mjs'; /** * scripts/qa-floor-hold.mjs — a grounded contact holds its floor through a * terrain-proxy outage (owner incident, 2026-08-21). @@ -25,13 +28,13 @@ * under Metal — a backend comparison at one point, not to be confused with the * same-backend run-to-run spread (122.1 m vs 20.6 m on a cell centre) that * retired the hold chain's unvalidated tier. The - * default is Metal on macOS and SwiftShader elsewhere; `--angle` overrides it. + * default is Metal; QA_ANGLE overrides it. * Mesh samples describe the selected backend, not another GPU's rendered LOD. * An unavailable rendered-mesh oracle remains a failed check, even if the * separate bare-earth DEM check passes. Software evidence does not establish * real-GPU visual correctness. */ -import puppeteer from 'puppeteer'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; @@ -39,7 +42,7 @@ const APP_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; const argv = Object.fromEntries(process.argv.slice(2) .map((a) => a.replace(/^--/, '').split('=')).map(([k, v]) => [k, v ?? true])); // Metal is a macOS-only ANGLE backend; defaulting to it off-Mac fails WebGL init. -const ANGLE = String(argv.angle || (process.platform === 'darwin' ? 'metal' : 'swiftshader')); +const ANGLE = process.env.QA_ANGLE || 'metal'; const HEADFUL = !!argv.headful; // Austin airport apron by default — the site qa-floor-verify pins, where the // mesh sits ~150 m above the geoid and burial is unmistakable. @@ -58,12 +61,9 @@ function record(name, ok, detail) { } fs.mkdirSync(OUT_DIR, { recursive: true }); -const chrome = await puppeteer.executablePath().catch(() => undefined); -const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', - executablePath: chrome, - args: ['--use-gl=angle', `--use-angle=${ANGLE}`, '--enable-webgl', - '--ignore-gpu-blocklist', '--no-sandbox'], +const browser = await launchBrowser({ + headless: true, + args: ['--enable-webgl', '--ignore-gpu-blocklist', '--no-sandbox'], }); const page = await browser.newPage(); await page.setViewport({ width: 1400, height: 900 }); @@ -109,14 +109,14 @@ console.log(`\nFloor hold through a terrain outage\n App : ${APP_URL}\n GPU await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 120000 }); await page.waitForFunction(() => window.__godsEyeView?.layers && window.__godsEyeView?.dataManager, { timeout: 150000 }); -await sleep(12000); // let the boot fly-to settle before pinning +await whenSettled(page); // let the boot fly-to settle before pinning await page.evaluate(() => { document.querySelector('#first-run-launcher:not([hidden]) [data-first-run-choice="explore"]')?.click(); }); -await sleep(600); // let the launcher dismissal finish before taking evidence +await whenSettled(page); // let the launcher dismissal finish before taking evidence await page.evaluate((site) => { window.__SITE = site; }, SITE); -const pin = () => page.evaluate(async () => { +const pin = () => evaluateWithGeo(page, async (qaGeo) => { const app = window.__godsEyeView; if (!app.viewer) { const geo = await import('/src/geo/index.js'); @@ -128,16 +128,15 @@ const pin = () => page.evaluate(async () => { app.engine.camera.moveEnd.raiseEvent(); return; } - const v = app.viewer; - const C = v.camera.positionCartographic.constructor; - v.camera.cancelFlight(); - v.camera.setView({ - destination: v.scene.globe.ellipsoid.cartographicToCartesian( + const C = qaGeo.cartographic; + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian( C.fromDegrees(window.__SITE.lon, window.__SITE.lat - 0.008, 900), ), orientation: { heading: 0, pitch: -0.45, roll: 0 }, }); - v.camera.moveEnd.raiseEvent(); + window.__godsEyeView.engine.camera.moveEnd.raiseEvent(); }); await pin(); const billboardMode = await page.evaluate(async () => { @@ -152,7 +151,7 @@ const billboardMode = await page.evaluate(async () => { record('the billboard floor test is explicitly in 2D aircraft mode', billboardMode); /** Reads the contact's rendered height and the rendered mesh beneath it. */ -const measure = () => page.evaluate(async (icao) => { +const measure = () => evaluateWithGeo(page, async (qaGeo, icao) => { const app = window.__godsEyeView; if (!app.viewer) { const geo = await import('/src/geo/index.js'); @@ -171,7 +170,7 @@ const measure = () => page.evaluate(async (icao) => { lat: +(carto.latitude * 180 / Math.PI).toFixed(5), lon: +(carto.longitude * 180 / Math.PI).toFixed(5), terrain: { ...window.__TERRAIN } }; } const v = app.viewer; - const C = v.camera.positionCartographic.constructor; + const C = qaGeo.cartographic; // The contact as the layer draws it (the probe's drawn rows: the layer owns // no Cesium billboard of its own any more; the engine's batched billboards // keep a hidden spare's old id, so an id match there can be stale). @@ -201,7 +200,7 @@ const measure = () => page.evaluate(async (icao) => { // harness does, and accept the coarse cell centre as a fallback point. const pump = () => new Promise((resolve) => { try { window.__godsEyeView.requestRender?.(); } catch { /* no governor */ } - try { v.scene.requestRender(); } catch { /* explicit-render off */ } + try { window.__godsEyeView.engine.render.request(); } catch { /* explicit-render off */ } requestAnimationFrame(() => requestAnimationFrame(resolve)); }); let mesh = null; @@ -210,7 +209,7 @@ const measure = () => page.evaluate(async (icao) => { for (const [pLat, pLon] of [[lat, lon], [+lat.toFixed(3), +lon.toFixed(3)]]) { for (const exclude of [[...sprites, ...models], []]) { try { - const h = v.scene.sampleHeight(C.fromDegrees(pLon, pLat, 0), exclude); + const h = window.__godsEyeView.engine.surface.sampleHeight(C.fromDegrees(pLon, pLat, 0), exclude); if (Number.isFinite(h)) { mesh = h; break; } } catch { /* mid-stream */ } } @@ -278,7 +277,7 @@ for (let i = 1; i <= 4; i++) { await sample('B', `poll+${i}`); } fs.writeFileSync(path.join(OUT_DIR, `hold-B-outage-${ANGLE}.png`), await page.screenshot({ type: 'png' })); -await browser.close(); +await closeBrowser(browser); console.log(''); const outage = samples.filter((s) => s.phase === 'B'); diff --git a/scripts/qa-floor-verify.mjs b/scripts/qa-floor-verify.mjs index a6baf1d43..523d0aeec 100644 --- a/scripts/qa-floor-verify.mjs +++ b/scripts/qa-floor-verify.mjs @@ -1,3 +1,6 @@ +// Cesium-only probes: native primitive and streamed-tile positions provide the Cesium floor oracle. +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled } from './qa/ready.mjs'; // scripts/qa-floor-verify.mjs — live floor verification at AUS (round 5). // Pins the camera at Austin airport, enables flights, waits ~3 polls, then // measures every nearby contact's render height against the ACTUAL rendered @@ -5,7 +8,7 @@ // coarse-LOD poison and the taxiing cold-cell regression on 2026-07-06. // Run: node scripts/qa-floor-verify.mjs (dev server on :4173, real GPU best) // with the poison fix + simplified chain live. -import puppeteer from 'puppeteer'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; // QA_BASE_URL matches the sibling harnesses (qa-height-datum / qa-cctv-v2) so a @@ -29,29 +32,23 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // (2026-07-30 lesson, already applied to the other harnesses): /Applications // auto-updates underneath the harnesses and its software-GL behavior shifts // across majors. -const chrome = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', -].filter(Boolean).find((p) => fs.existsSync(p)); -const browser = await puppeteer.launch({ - headless: 'new', executablePath: chrome, - args: ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`, '--enable-webgl', '--ignore-gpu-blocklist', '--no-sandbox'], +const browser = await launchBrowser({ + headless: true, args: ['--enable-webgl', '--ignore-gpu-blocklist', '--no-sandbox'], }); const page = await browser.newPage(); await page.setViewport({ width: 1400, height: 900 }); page.on('pageerror', (e) => console.log('[pageerror]', e.message)); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 120000 }); await page.waitForFunction(() => window.__godsEyeView?.dataManager && window.__godsEyeView?.engine, { timeout: 150000 }); -await sleep(2000); +await whenSettled(page); // Let the app's boot fly-to fully settle FIRST (it was overriding an early // setView, leaving the camera away from Austin — every probe then read // coarse global tiles at ~25 m and the oracle was garbage). -await sleep(12000); +await whenSettled(page); await page.evaluate((site) => { window.__QA_SITE = site; }, SITE); -const camPin = async () => page.evaluate(async () => { +const camPin = async () => evaluateWithGeo(page, async (qaGeo) => { const v = window.__godsEyeView.viewer; if (!v) { const geo = await import('/src/geo/index.js'); @@ -61,11 +58,11 @@ const camPin = async () => page.evaluate(async () => { const c = camera.pose().cartographic; return { lat: +(c.latitude * 180 / Math.PI).toFixed(3), lon: +(c.longitude * 180 / Math.PI).toFixed(3), h: Math.round(c.height) }; } - const C = v.camera.positionCartographic.constructor; - v.camera.cancelFlight(); - v.camera.setView({ destination: v.scene.globe.ellipsoid.cartographicToCartesian(C.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 3000)) }); - v.camera.moveEnd.raiseEvent(); - const c = v.camera.positionCartographic; + const C = qaGeo.cartographic; + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ destination: qaGeo.cartographic.toCartesian(C.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 3000)) }); + window.__godsEyeView.engine.camera.moveEnd.raiseEvent(); + const c = window.__godsEyeView.engine.camera.pose().cartographic; return { lat: +(c.latitude * 180 / Math.PI).toFixed(3), lon: +(c.longitude * 180 / Math.PI).toFixed(3), h: Math.round(c.height) }; }); console.log('camera pinned:', JSON.stringify(await camPin())); @@ -87,7 +84,7 @@ const ready = await page.evaluate(() => { console.log('tileset ready:', JSON.stringify(ready)); await sleep(8000); -const report = await page.evaluate(async () => { +const report = await evaluateWithGeo(page, async (qaGeo) => { const gev = window.__godsEyeView; const v = gev.viewer; const layer = gev.dataManager.layers.get('flights')?.module; @@ -141,9 +138,8 @@ const report = await page.evaluate(async () => { } return { ausContacts: out.length, contacts: out.slice(0, 16), spritesShown: shown, spritesHidden: hidden }; } - const ell = v.scene.globe.ellipsoid; - const C = v.camera.positionCartographic.constructor; - const center = ell.cartographicToCartesian(C.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 200)); + const C = qaGeo.cartographic; + const center = qaGeo.cartographic.toCartesian(C.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 200)); const nearby = layer.getNearby(center, 15000, 60); // Exclude EVERY billboard from the probes — sprites are pickable, so an // unexcluded probe can return another aircraft's height as "the mesh". @@ -172,11 +168,11 @@ const report = await page.evaluate(async () => { const out = []; for (const p of nearby) { const modelAt = drawnAt(p); - const c = ell.cartesianToCartographic(modelAt ?? p.position); + const c = qaGeo.cartographic.fromCartesian(modelAt ?? p.position); const latDeg = c.latitude * 180 / Math.PI, lonDeg = c.longitude * 180 / Math.PI; let meshH = null; try { - const h = v.scene.sampleHeight(C.fromDegrees(lonDeg, latDeg), excludes); + const h = window.__godsEyeView.engine.surface.sampleHeight(C.fromDegrees(lonDeg, latDeg), excludes); if (Number.isFinite(h)) meshH = h; } catch { /* ignore */ } out.push({ @@ -201,7 +197,7 @@ const report = await page.evaluate(async () => { let shown = 0, hidden = 0; for (const row of gev.layers?.drawn('flights') ?? []) { if (!row.position || row.trackedModel) continue; - const cc = ell.cartesianToCartographic(row.position); + const cc = qaGeo.cartographic.fromCartesian(row.position); const la = cc.latitude * 180 / Math.PI, lo = cc.longitude * 180 / Math.PI; if (Math.abs(la - window.__QA_SITE.lat) > 0.08 || Math.abs(lo - window.__QA_SITE.lon) > 0.10) continue; if (row.show) shown++; else hidden++; @@ -223,7 +219,7 @@ for (const b of buried) { } const verdict = lows.length === 0 ? 'INCONCLUSIVE' : (buried.length === 0 ? 'PASS' : 'FAIL'); console.log(`VERDICT: ${verdict}`); -await browser.close(); +await closeBrowser(browser); // Exit code (2026-08-19): this harness printed VERDICT: FAIL and still exited 0, // so a real regression (taxiing contacts rendering under the mesh) stayed // invisible to every automated caller. Now it reports like its siblings diff --git a/scripts/qa-flyroute-cinema.mjs b/scripts/qa-flyroute-cinema.mjs index 3d4088a32..205a858e1 100644 --- a/scripts/qa-flyroute-cinema.mjs +++ b/scripts/qa-flyroute-cinema.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { whenSettled } from './qa/ready.mjs'; /** * fly_route cinematic evidence — drives the REAL voice runner headlessly and * measures the REAL camera (Cesium heading/pitch/roll + position) every @@ -9,10 +10,10 @@ * * Writes a screenshot sequence plus a JSON trace to qa-shots/flyroute/. */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import puppeteer from 'puppeteer'; import sharp from 'sharp'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -25,11 +26,6 @@ const OUT_DIR = getOpt('--out', path.join(ROOT, 'qa-shots', 'flyroute')); const MIRROR_DIR = getOpt('--mirror', ''); const SHOT_EVERY_MS = Number(getOpt('--shot-ms', '2000')); -const CHROME_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - await puppeteer.executablePath().catch(() => null), -].filter(Boolean); -const CHROME_EXECUTABLE = CHROME_CANDIDATES.find((c) => { try { return fs.existsSync(c); } catch { return false; } }); // A 6-waypoint downtown Austin route: north, right, left, right, left. const ROUTE_POINTS = [ @@ -57,22 +53,9 @@ const wrapDeg = (deg) => ((deg + 540) % 360) - 180; fs.mkdirSync(OUT_DIR, { recursive: true }); if (MIRROR_DIR) fs.mkdirSync(MIRROR_DIR, { recursive: true }); -const browser = await puppeteer.launch({ - headless: 'new', - ...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}), - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - // Real GPU when the host has one: the dolly is frame-rate independent, but - // a higher sample rate makes the roll and the ease ramps far easier to see. - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), - '--disable-dev-shm-usage', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--window-size=1500,950', - ], +const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1500,950'], protocolTimeout: 240000, }); const page = await browser.newPage(); @@ -99,21 +82,19 @@ if (TERRAIN_DELAY_MS > 0) { /** Install a postRender sampler: one row per RENDERED frame. */ async function installSampler() { await page.evaluate(() => { - const viewer = window.__godsEyeView.viewer; window.__gevFlyTrace = { rows: [], marks: [] }; if (window.__gevFlyTraceRemove) window.__gevFlyTraceRemove(); let frame = 0; const listener = () => { - const cam = viewer.camera; - const carto = cam.positionCartographic; + const carto = window.__godsEyeView.engine.camera.pose().cartographic; // Every 6th frame, ask the RENDERED WORLD what is under the camera. This // is the only measurement that can prove "never below terrain": it reads // the surface the user is actually looking at, not our own floor cache. let surfaceM = null; frame += 1; - if (frame % 6 === 0 && typeof viewer.scene.sampleHeight === 'function') { + if (frame % 6 === 0 && typeof window.__godsEyeView.engine.surface.sampleHeight === 'function') { try { - const probe = viewer.scene.sampleHeight(carto.clone()); + const probe = window.__godsEyeView.engine.surface.sampleHeight(carto); if (Number.isFinite(probe)) surfaceM = probe; } catch { /* tiles not loaded under the camera */ } } @@ -122,14 +103,14 @@ async function installSampler() { lon: (carto.longitude * 180) / Math.PI, lat: (carto.latitude * 180) / Math.PI, height: carto.height, - headingDeg: (cam.heading * 180) / Math.PI, - pitchDeg: (cam.pitch * 180) / Math.PI, - rollDeg: (cam.roll * 180) / Math.PI, + headingDeg: (window.__godsEyeView.engine.camera.pose().heading * 180) / Math.PI, + pitchDeg: (window.__godsEyeView.engine.camera.pose().pitch * 180) / Math.PI, + rollDeg: (window.__godsEyeView.engine.camera.pose().roll * 180) / Math.PI, surfaceM, }); }; - viewer.scene.postRender.addEventListener(listener); - window.__gevFlyTraceRemove = () => viewer.scene.postRender.removeEventListener(listener); + window.__godsEyeView.engine.frames.postRender.addEventListener(listener); + window.__gevFlyTraceRemove = () => window.__godsEyeView.engine.frames.postRender.removeEventListener(listener); }); } @@ -153,24 +134,25 @@ try { console.log(`\nfly_route cinematic evidence — ${APP_URL}`); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90000 }); await page.waitForFunction( - () => window.__godsEyeView?.viewer && window.__gevVoiceCommands?.runner && window.__gevAnnotations, + () => window.__godsEyeView?.engine && window.__gevVoiceCommands?.runner && window.__gevAnnotations, { timeout: 150000, polling: 250 }, ); + await whenSettled(page); const run = (name, args = {}) => page.evaluate( (n, a) => window.__gevVoiceCommands.runner(n, a), name, args, ); // ── Setup: get over downtown Austin, draw the route ─────────────────── await run('fly_to_location', { query: 'Texas State Capitol, Austin' }); - await sleep(9000); + await whenSettled(page); await run('clear_annotations', {}); - await sleep(500); + await whenSettled(page); const drawn = await run('annotate_map', { annotations: [{ type: 'route', mode: 'driving', label: 'cinema evidence route', points: ROUTE_POINTS, }], }); - await sleep(6000); + await whenSettled(page); const routeInfo = await page.evaluate(() => { const route = (window.__gevAnnotations.list?.() || []).filter((a) => a.type === 'route').at(-1); return route ? { label: route.label, waypoints: route.path?.length ?? 0 } : null; @@ -421,12 +403,12 @@ try { // ── Run 2: interrupt the dolly MID-BANK ─────────────────────────────── // Cutting a level camera proves nothing about levelling, so this waits for // the live camera to actually be rolled before it grabs the controls. - await sleep(1500); + await whenSettled(page); await installSampler(); const second = await run('fly_route', { label: 'cinema evidence', speed: 'normal' }); report(second?.ok === true, 'second flight starts for the interrupt case'); const liveRollDeg = () => page.evaluate( - () => (window.__godsEyeView.viewer.camera.roll * 180) / Math.PI, + () => (window.__godsEyeView.engine.camera.pose().roll * 180) / Math.PI, ); let rollBeforeCut = 0; for (let waited = 0; waited < 90000; waited += 400) { @@ -439,8 +421,7 @@ try { await page.screenshot({ path: path.join(OUT_DIR, 'interrupt-0-banked.png') }); const cut = await page.evaluate(async () => { - const viewer = window.__godsEyeView.viewer; - const canvas = viewer.scene.canvas; + const canvas = window.__godsEyeView.engine.host.canvas; // Read the motion slot BEFORE the cut too: under Vite a dynamic import can // hand back a second module instance whose slot is always empty, and an // "empty after" that was already empty before proves nothing. @@ -452,11 +433,11 @@ try { read = () => mod.getActiveCameraMotion?.() ?? null; slotBefore = read(); } catch { /* dev-only module read */ } - const rollBefore = (viewer.camera.roll * 180) / Math.PI; + const rollBefore = (window.__godsEyeView.engine.camera.pose().roll * 180) / Math.PI; window.__gevFlyTrace.marks.push({ label: 'pointerdown', t: performance.now() }); canvas.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true })); // Same synchronous turn as the pointerdown — no frame has rendered yet. - const rollAfter = (viewer.camera.roll * 180) / Math.PI; + const rollAfter = (window.__godsEyeView.engine.camera.pose().roll * 180) / Math.PI; if (read) slotAfter = read(); return { slotBefore, slotAfter, rollBefore, rollAfter }; }); @@ -474,7 +455,7 @@ try { note('motion-slot read', 'the dev server handed the harness a second module instance (HMR); the camera-side roll and freeze checks carry the proof'); } - await sleep(2500); + await whenSettled(page); const rollAfterSettle = wrapDeg(await liveRollDeg()); report(Math.abs(rollAfterSettle) < 0.01, 'and the horizon STAYS level after the cut', `roll ${rollAfterSettle.toFixed(4)}° 2.5 s later`); @@ -596,7 +577,7 @@ try { } catch (error) { report(false, 'harness completed', String(error?.message || error).slice(0, 200)); } finally { - await browser.close(); + await closeBrowser(browser); } const failed = results.filter((r) => r.ok === false).length; diff --git a/scripts/qa-focus-evidence.mjs b/scripts/qa-focus-evidence.mjs index a057d9e8a..aa0b09d56 100644 --- a/scripts/qa-focus-evidence.mjs +++ b/scripts/qa-focus-evidence.mjs @@ -1,4 +1,7 @@ #!/usr/bin/env node +// Cesium-only probes: native clock, tracking and manual rendering provide the Cesium focus oracle. +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled } from './qa/ready.mjs'; /** * Moving visual evidence for focus de-emphasis and aircraft recession. * @@ -17,11 +20,11 @@ * --headful drops those flags and is the real-GPU visual sign-off path. */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import { redact } from './qa-browserEvidence.mjs'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import puppeteer from 'puppeteer'; import { stubPlanespotters } from './qa-photo-stub.mjs'; const argv = process.argv.slice(2); @@ -303,6 +306,7 @@ async function waitForApp(page) { () => Boolean(window.__godsEyeView?.layers && window.__godsEyeView?.dataManager), { timeout: 60_000, polling: 200 }, ); + await whenSettled(page); await page.evaluate(async () => { window.__focusGeo = await import('/src/geo/index.js'); }); } @@ -336,7 +340,7 @@ async function requireEvidenceSeams(page) { } async function setCamera(page, values) { - await page.evaluate(([lon, lat, height, heading, pitch]) => { + await evaluateWithGeo(page, async (qaGeo, [lon, lat, height, heading, pitch]) => { const app = window.__godsEyeView; if (!app.viewer) { const geo = window.__focusGeo; @@ -349,12 +353,10 @@ async function setCamera(page, values) { return; } const viewer = app.viewer; - const ellipsoid = viewer.scene.globe.ellipsoid; viewer.trackedEntity = undefined; - viewer.camera.cancelFlight?.(); - viewer.scene.tweens?.removeAll?.(); - viewer.camera.setView({ - destination: ellipsoid.cartographicToCartesian({ + window.__godsEyeView.engine.camera.cancelFlight?.(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: lon * Math.PI / 180, latitude: lat * Math.PI / 180, height, @@ -654,11 +656,7 @@ async function browserContext(page, browser) { platform: os.platform(), arch: os.arch(), }, - caveat: HEADFUL - ? 'Headful capture omits SwiftShader flags and is the real-GPU visual sign-off path.' - : process.env.QA_ANGLE === 'metal' - ? 'Headless ANGLE/Metal capture; renderer and DPR recorded above.' - : 'SwiftShader/headless output is relative-only.', + caveat: `Headless capture with QA_ANGLE=${process.env.QA_ANGLE || 'metal'}; renderer and DPR recorded above.`, }; } @@ -671,18 +669,10 @@ async function main() { fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true }); fs.mkdirSync(path.dirname(JSON_PATH), { recursive: true }); - const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', + const browser = await launchBrowser({ + headless: true, protocolTimeout: 300000, - args: [ - ...(HEADFUL ? [] : process.env.QA_ANGLE ? ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE}`] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']), - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - `--window-size=${VIEWPORT.width},${VIEWPORT.height}`, - ], + args: [...(HEADFUL ? [] : process.env.QA_ANGLE ? [] : ['--enable-unsafe-swiftshader']), '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', `--window-size=${VIEWPORT.width},${VIEWPORT.height}`], }); const page = await browser.newPage(); await stubPlanespotters(page); @@ -740,7 +730,7 @@ async function main() { } } finally { if (!page.isClosed()) await releaseFrameClock(page).catch(() => {}); - await browser.close(); + await closeBrowser(browser); } fs.writeFileSync(JSON_PATH, `${JSON.stringify(report, null, 2)}\n`); diff --git a/scripts/qa-form-fields.mjs b/scripts/qa-form-fields.mjs index c2d3af6e5..26981e25f 100644 --- a/scripts/qa-form-fields.mjs +++ b/scripts/qa-form-fields.mjs @@ -1,11 +1,12 @@ #!/usr/bin/env node /** Native form-field regression gate. CCTV ports are fixtures, never live writes. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; -import puppeteer from 'puppeteer'; -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, - args: ['--use-gl=angle', '--use-angle=swiftshader'], + args: [], }); const page = await browser.newPage(); page.setDefaultNavigationTimeout(60000); @@ -29,11 +30,10 @@ try { }, ); await page.waitForFunction( - () => - window.__godsEyeView?.styleManager?._cctvControls && - document.getElementById('loading-screen')?.classList.contains('hidden'), + () => window.__godsEyeView?.styleManager?._cctvControls, { timeout: 60000 }, ); + await whenSettled(page); const inventory = await page.evaluate(() => { const selects = [ 'scene-select', @@ -400,11 +400,10 @@ try { localized.searchParams.set('lang', 'ru'); await page.goto(localized.href, { waitUntil: 'domcontentloaded' }); await page.waitForFunction( - () => - window.__godsEyeView?.styleManager?._cctvControls && - document.getElementById('loading-screen')?.classList.contains('hidden'), + () => window.__godsEyeView?.styleManager?._cctvControls, { timeout: 60000 }, ); + await whenSettled(page); await page.evaluate(() => { for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel']) window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, { @@ -439,6 +438,6 @@ try { await page.screenshot({ path: 'qa-shots/form-fields/ru-390.png' }); check('no uncaught browser errors', errors.length === 0, errors); } finally { - await browser.close(); + await closeBrowser(browser); } process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-gbfs-names.mjs b/scripts/qa-gbfs-names.mjs index 863f919ab..7364b48e5 100644 --- a/scripts/qa-gbfs-names.mjs +++ b/scripts/qa-gbfs-names.mjs @@ -5,10 +5,11 @@ * --w2 adds raster checks that import source modules: dev server only. * GBFS documents are synthetic; no operator feed or provider key is required. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; import sharp from 'sharp'; import { fromDegrees } from '../src/geo/cartesian3.js'; import { watchBrowserPage, redact } from './qa-browserEvidence.mjs'; @@ -67,15 +68,9 @@ const status = { })), }, }; -const browser = await puppeteer.launch({ - headless: 'new', - ...(process.env.PUPPETEER_EXECUTABLE_PATH - ? { executablePath: process.env.PUPPETEER_EXECUTABLE_PATH } - : {}), - args: - process.platform === 'darwin' - ? ['--use-angle=metal'] - : ['--use-gl=angle', '--use-angle=swiftshader'], +const browser = await launchBrowser({ + headless: true, + args: process.platform === 'darwin' ? [] : [], }); let checks = 0; const check = (name, actual, expected) => { @@ -151,14 +146,7 @@ async function open(mode = 'normal') { window.__godsEyeView?.dataManager && window.__godsEyeView?.voiceCommands, { timeout: 60000 }, ); - await page.waitForFunction( - () => - !document.getElementById('loading-screen') || - (document.getElementById('loading-screen').classList.contains('hidden') && - getComputedStyle(document.getElementById('loading-screen')).opacity === - '0'), - { timeout: 60000 }, - ); + await whenSettled(page); await page.evaluate( async (destination) => { const { dataManager, engine } = window.__godsEyeView; @@ -619,5 +607,5 @@ try { } finally { if (reportPath) fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n'); - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-gbfs-names.test.mjs b/scripts/qa-gbfs-names.test.mjs index f0eeaa44b..32163ecc9 100644 --- a/scripts/qa-gbfs-names.test.mjs +++ b/scripts/qa-gbfs-names.test.mjs @@ -30,6 +30,7 @@ function fixture({ extended, dev }) { information: {}, status: {}, fromDegrees: () => ({}), + whenSettled: async () => {}, }; return new Function( ...Object.keys(dependencies), diff --git a/scripts/qa-geo-import-large.mjs b/scripts/qa-geo-import-large.mjs index 350aff344..f80fe968e 100644 --- a/scripts/qa-geo-import-large.mjs +++ b/scripts/qa-geo-import-large.mjs @@ -1,12 +1,14 @@ #!/usr/bin/env node +// Cesium-only probes: KML DataSources, imagery and the Julian clock form the Cesium reference oracle. /** Large KML acceptance: style equivalence, 27.6 MB import and optional real file. * QA_BASE_URL defaults to http://localhost:4173. Optional --file /path/file.kml. * For a USGS fixture with blocked icons, add --expect-local-pins to check paint. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import puppeteer from 'puppeteer'; import { installImportProbe } from './qa-geo-import-probe.mjs'; import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs'; @@ -61,12 +63,9 @@ const small = wrap( ) .join(''), ); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, - args: - process.platform === 'darwin' - ? ['--use-gl=angle', '--use-angle=metal'] - : ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'], + args: process.platform === 'darwin' ? [] : ['--no-sandbox'], }); const page = await browser.newPage(); await hookRenderErrors(page); @@ -88,12 +87,8 @@ const check = (name, ok, detail) => { try { await page.setViewport({ width: 1440, height: 900 }); await page.goto(`${base}/?welcome=0`, { waitUntil: 'domcontentloaded' }); - await page.waitForFunction( - () => - window.__gevGeoImport && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 60000 }, - ); + await page.waitForFunction(() => window.__gevGeoImport, { timeout: 60000 }); + await whenSettled(page); await installImportProbe(page); await page.evaluate(async () => { const { viewer, dataManager } = window.__godsEyeView; @@ -103,8 +98,8 @@ try { viewer.clock.shouldAnimate = false; viewer.imageryLayers.removeAll(); viewer.terrainProvider = new C.EllipsoidTerrainProvider(); - viewer.camera.cancelFlight(); - viewer.camera.setView({ + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ destination: C.Cartesian3.fromDegrees(-122.5, 37.72, 50000), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); @@ -222,7 +217,7 @@ try { el.remove(); const source = await C.KmlDataSource.load(doc, { camera: viewer.scene.camera, - canvas: viewer.scene.canvas, + canvas: window.__godsEyeView.engine.host.canvas, clampToGround: true, }); for (const e of source.importedEntities || source.entities.values) @@ -231,7 +226,6 @@ try { }; }); const equivalent = await page.evaluate(async (text) => { - const { viewer } = window.__godsEyeView; const control = await window.__qaUnsharedKml(text); const outcomes = await window.__gevGeoImport.importFiles([ new File([text], 'style-equivalence.kml'), @@ -269,9 +263,8 @@ try { page.evaluate(async () => { const C = window.__CESIUM__, { viewer } = window.__godsEyeView; - const scene = viewer.scene; - viewer.camera.cancelFlight(); - viewer.camera.setView({ + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ destination: C.Cartesian3.fromDegrees(-122.5, 37.72, 50000), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); @@ -279,13 +272,14 @@ try { samples = []; let last, frames = 0; - const remove = scene.postRender.addEventListener(() => { - const t = performance.now(); - if (t - started < 2000) return; - frames++; - if (last !== undefined) samples.push(t - last); - last = t; - }); + const remove = + window.__godsEyeView.engine.frames.postRender.addEventListener(() => { + const t = performance.now(); + if (t - started < 2000) return; + frames++; + if (last !== undefined) samples.push(t - last); + last = t; + }); await new Promise((resolve) => { let previous = performance.now(); function tick(t) { @@ -294,7 +288,7 @@ try { Math.min(t - previous, 100) * 0.0000002, ); previous = t; - scene.requestRender(); + window.__godsEyeView.engine.render.request(); if (t - started < 7000) requestAnimationFrame(tick); else resolve(); } @@ -302,7 +296,7 @@ try { }); remove(); samples.sort((a, b) => a - b); - const gl = scene.context._gl, + const gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2'), debug = gl.getExtension('WEBGL_debug_renderer_info'); return { fps: frames / 5, @@ -401,8 +395,7 @@ try { ); if (suppliedItem && process.argv.includes('--expect-local-pins')) { const markers = await page.evaluate(() => { - const { viewer } = window.__godsEyeView, - C = window.__CESIUM__; + const C = window.__CESIUM__; const sets = window.__qaImports .records() .filter((record) => record.kind === 'movers'); @@ -412,8 +405,8 @@ try { .flatMap((record) => record.items); if (images.length) { const at = C.Cartographic.fromCartesian(images[0].position); - viewer.camera.cancelFlight(); - viewer.camera.setView({ + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ destination: C.Cartesian3.fromRadians( at.longitude, at.latitude, @@ -462,7 +455,7 @@ try { { id: suppliedItem.id, visible }, ); await setVisible(false); - await new Promise((resolve) => setTimeout(resolve, 1000)); + await whenSettled(page); const hidden = await yellowPixels(); await setVisible(true); let shown = hidden; @@ -510,6 +503,6 @@ try { ); console.log('Fixture retained at', file); } finally { - await browser.close(); + await closeBrowser(browser); } process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-geo-import-regressions.mjs b/scripts/qa-geo-import-regressions.mjs index 9bd3b2435..f296ea6c8 100644 --- a/scripts/qa-geo-import-regressions.mjs +++ b/scripts/qa-geo-import-regressions.mjs @@ -1,13 +1,15 @@ #!/usr/bin/env node +// Cesium-only probes: primitive failure injection and terrain isolation exercise the Cesium import adapter. /** Import regressions through the real UI controller and globe primitives. * Run against dev and preview: QA_BASE_URL=http://localhost:4173 node scripts/qa-geo-import-regressions.mjs */ -import puppeteer from 'puppeteer'; +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import * as Cesium from 'cesium'; import { installImportProbe } from './qa-geo-import-probe.mjs'; import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs'; -const browser = await puppeteer.launch({ headless: true }); +const browser = await launchBrowser({ headless: true }); const page = await browser.newPage(); const meshBytes = Buffer.alloc(60); [ @@ -92,12 +94,8 @@ try { `${process.argv.find((arg) => /^https?:/.test(arg)) || process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, { waitUntil: 'domcontentloaded' }, ); - await page.waitForFunction( - () => - window.__gevGeoImport && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 90000 }, - ); + await page.waitForFunction(() => window.__gevGeoImport, { timeout: 90000 }); + await whenSettled(page); await page.evaluate(async () => { const { viewer, dataManager } = window.__godsEyeView, C = window.__CESIUM__; @@ -106,8 +104,8 @@ try { viewer.imageryLayers.removeAll(); viewer.terrainProvider = new C.EllipsoidTerrainProvider(); viewer.clock.shouldAnimate = false; - viewer.camera.cancelFlight(); - viewer.camera.setView({ + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ destination: C.Cartesian3.fromDegrees(-122.5, 37.7, 100000), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); @@ -198,7 +196,7 @@ try { Math.abs(pinHeight()) >= 0.01 && performance.now() < groundDeadline ) { - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); await new Promise((resolve) => setTimeout(resolve, 100)); } const pin = pins(r)[0], @@ -402,11 +400,13 @@ try { check( 'symmetric global extents keep a finite camera', r.outcome.ok && - Number.isFinite(viewer.camera.positionCartographic.height), + Number.isFinite( + window.__godsEyeView.engine.camera.pose().cartographic.height, + ), ); remove(r); - viewer.camera.cancelFlight(); - viewer.camera.setView({ + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ destination: C.Cartesian3.fromDegrees(-122.5, 37.7, 100000), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); @@ -418,17 +418,18 @@ try { // Let the new camera/tiles reach a rendered frame before testing sampling. await new Promise((resolve) => { let frames = 0; - const remove = viewer.scene.postRender.addEventListener(() => { - if (++frames >= 2) { - remove(); - resolve(); - } else viewer.scene.requestRender(); - }); - viewer.scene.requestRender(); + const remove = + window.__godsEyeView.engine.frames.postRender.addEventListener(() => { + if (++frames >= 2) { + remove(); + resolve(); + } else window.__godsEyeView.engine.render.request(); + }); + window.__godsEyeView.engine.render.request(); }); const fixtureDeadline = performance.now() + 5000; while (!mesh.tilesLoaded && performance.now() < fixtureDeadline) { - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); await new Promise((resolve) => setTimeout(resolve, 50)); } const globeShown = viewer.scene.globe.show; @@ -461,8 +462,8 @@ try { { screen: meshScreen && [meshScreen.x, meshScreen.y], camera: { - height: viewer.camera.positionCartographic.height, - pitch: viewer.camera.pitch, + height: window.__godsEyeView.engine.camera.pose().cartographic.height, + pitch: window.__godsEyeView.engine.camera.pose().pitch, }, meshHeight, }, @@ -551,6 +552,6 @@ try { ); if (unexpected.length) failed++; } finally { - await browser.close(); + await closeBrowser(browser); } process.exitCode = failed ? 1 : 0; diff --git a/scripts/qa-geo-import.mjs b/scripts/qa-geo-import.mjs index ab7b0d641..05abff977 100644 --- a/scripts/qa-geo-import.mjs +++ b/scripts/qa-geo-import.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// Cesium-only probes: imagery, terrain and the native timeline validate Cesium import integration. /** * Rendered acceptance for UTILITIES ▸ Import: real files through the real file * input and a real drop, drawn through the globe contract. @@ -19,10 +20,11 @@ * Usage: dev server up (`npm run dev`), then `node scripts/qa-geo-import.mjs`. * QA_BASE_URL (default http://localhost:4173), QA_SHOT_DIR, QA_HEADFUL=1. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import puppeteer from 'puppeteer'; import { installImportProbe } from './qa-geo-import-probe.mjs'; import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs'; @@ -165,15 +167,13 @@ const FILES = [ /* ── harness ───────────────────────────────────────────────────────────── */ -const browser = await puppeteer.launch({ - headless: !HEADFUL, +const browser = await launchBrowser({ + headless: true, args: [ '--no-sandbox', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), ], }); const page = await browser.newPage(); @@ -211,8 +211,8 @@ const CLIP = { x: 380, y: 240, width: 480, height: 320 }; /** Pixels in the clip close to the first import color (#39d0ff). */ async function cyanPixels() { - await page.evaluate(() => window.__godsEyeView.viewer.scene.requestRender()); - await wait(600); + await page.evaluate(() => window.__godsEyeView.engine.render.request()); + await whenSettled(page); const base64 = await page.screenshot({ clip: CLIP, type: 'png', @@ -247,12 +247,10 @@ try { await page.setViewport({ width: 1280, height: 860 }); await page.goto(`${BASE_URL}/?welcome=0`, { waitUntil: 'domcontentloaded' }); await page.waitForFunction( - () => - window.__gevGeoImport && - window.__godsEyeView?.viewer && - document.getElementById('loading-screen')?.classList.contains('hidden'), + () => window.__gevGeoImport && window.__godsEyeView?.engine, { timeout: 90_000 }, ); + await whenSettled(page); await page.evaluate(async (view) => { const viewer = window.__godsEyeView.viewer; for (const id of window.__godsEyeView.dataManager.layers.keys()) @@ -261,8 +259,8 @@ try { viewer.terrainProvider = new window.__CESIUM__.EllipsoidTerrainProvider(); viewer.scene.globe.baseColor = window.__CESIUM__.Color.BLACK; viewer.scene.globe.enableLighting = false; - viewer.camera.cancelFlight?.(); - viewer.camera.setView({ + window.__godsEyeView.engine.camera.cancelFlight?.(); + window.__godsEyeView.engine.camera.setView({ destination: window.__CESIUM__.Cartesian3.fromDegrees( view.lon, view.lat, @@ -270,9 +268,9 @@ try { ), orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 }, }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }, VIEW); - await wait(4000); + await whenSettled(page); await page.evaluate(() => { const panel = document.getElementById('utilities-panel'); if (panel?.classList.contains('collapsed')) @@ -367,9 +365,8 @@ try { // Over the files: their pins on the ground show unless beyond the // horizon, so all of them show here. await page.evaluate((view) => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.cancelFlight?.(); - viewer.camera.setView({ + window.__godsEyeView.engine.camera.cancelFlight?.(); + window.__godsEyeView.engine.camera.setView({ destination: window.__CESIUM__.Cartesian3.fromDegrees( view.lon, view.lat, @@ -378,7 +375,7 @@ try { orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 }, }); }, VIEW); - await wait(2500); + await whenSettled(page); const inside = await page.evaluate(() => { const groups = window.__qaImports.groups(); const pins = (group) => @@ -506,7 +503,7 @@ try { // Dropped onto the globe, as a person drags from their file manager. await page.evaluate((text) => { - const container = window.__godsEyeView.viewer.container; + const container = window.__godsEyeView.engine.host.container; const transfer = new DataTransfer(); transfer.items.add( new File([text], 'dropped.geojson', { type: 'application/geo+json' }), @@ -536,7 +533,7 @@ try { transfer.items.add( new File(['just some notes'], 'notes.txt', { type: 'text/plain' }), ); - window.__godsEyeView.viewer.container.dispatchEvent( + window.__godsEyeView.engine.host.container.dispatchEvent( new DragEvent('drop', { dataTransfer: transfer, bubbles: true, @@ -544,7 +541,7 @@ try { }), ); }); - await wait(800); + await whenSettled(page); const refused = await state(); check( 'a file that is not geodata is refused with a reason', @@ -576,7 +573,7 @@ try { } catch (error) { check('harness ran to completion', false, error?.stack || String(error)); } finally { - await browser.close(); + await closeBrowser(browser); fs.rmSync(dir, { recursive: true, force: true }); } console.log(failures ? `\n${failures} check(s) failed` : '\nall checks passed'); diff --git a/scripts/qa-ghostmaps.mjs b/scripts/qa-ghostmaps.mjs index 4f70b73de..f88240be6 100644 --- a/scripts/qa-ghostmaps.mjs +++ b/scripts/qa-ghostmaps.mjs @@ -1,4 +1,7 @@ #!/usr/bin/env node +// Cesium-only probes: native primitive failure injection tests the Cesium paint path. +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled, whenFramesDrawn, whenTilesLoaded } from './qa/ready.mjs'; /** * qa-ghostmaps.mjs — headless proof for the S2 Underground GhostMaps layer * (id `s2-ghostmaps`): S2's public KMZ intel products, kept current from @@ -39,7 +42,7 @@ * Exits non-zero on any FAIL. Does not commit anything. */ -import puppeteer from 'puppeteer'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -64,7 +67,6 @@ function record(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)); async function boot(page) { await page.goto(`${APP_URL}/?welcome=0`, { @@ -72,10 +74,11 @@ async function boot(page) { timeout: TIMEOUT_MS, }); await page.waitForFunction( - () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + () => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager, { timeout: TIMEOUT_MS }, ); - await sleep(5000); + await whenSettled(page); + await whenFramesDrawn(page); await page.keyboard.press('Escape'); } @@ -186,14 +189,14 @@ async function showRow(page) { .querySelector(`[data-layer-id="${id}"]`) ?.scrollIntoView({ block: 'center' }); }, LAYER_ID); - await sleep(400); + await whenFramesDrawn(page); } async function main() { fs.mkdirSync(SHOTS_DIR, { recursive: true }); - const browser = await puppeteer.launch({ - headless: !HEADFUL, - args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'], + const browser = await launchBrowser({ + headless: true, + args: ['--enable-unsafe-swiftshader'], defaultViewport: { width: 1440, height: 900 }, }); try { @@ -330,6 +333,7 @@ async function main() { await page.waitForFunction(() => window.__godsEyeView?.dataManager, { timeout: TIMEOUT_MS, }); + await whenSettled(page); await enableAndSettle(page); const persistedAge = await stats(page); record( @@ -351,41 +355,38 @@ async function main() { // ---- (ii) PICK ---------------------------------------------------------- await installPins(page); - const target = await page.evaluate((id) => { - const gev = window.__godsEyeView; - const pin = window.__qaGhostPins(`${id}:cip:`)[0]; - const carto = gev.viewer.scene.globe.ellipsoid.cartesianToCartographic( - pin.position, - ); - try { - gev.viewer.camera.cancelFlight(); - } catch { - /* no flight */ - } - gev.viewer.camera.setView({ - destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({ - longitude: carto.longitude, - latitude: carto.latitude, - height: 40000, - }), - orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, - }); - gev.viewer.scene.requestRender?.(); - return pin.id; - }, LAYER_ID); - await page - .waitForFunction( - () => window.__godsEyeView.viewer.scene.globe.tilesLoaded, - { timeout: 30000 }, - ) - .catch(() => {}); + const target = await evaluateWithGeo( + page, + async (qaGeo, id) => { + const pin = window.__qaGhostPins(`${id}:cip:`)[0]; + const carto = qaGeo.cartographic.fromCartesian(pin.position); + try { + window.__godsEyeView.engine.camera.cancelFlight(); + } catch { + /* no flight */ + } + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ + longitude: carto.longitude, + latitude: carto.latitude, + height: 40000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + window.__godsEyeView.engine.render.request?.(); + return pin.id; + }, + LAYER_ID, + ); + await whenTilesLoaded(page, { timeoutMs: 30000 }).catch(() => {}); + await whenSettled(page); // The horizon cull reveals the pin on the frame after the camera moves. await page .waitForFunction( () => { - const { viewer: v, engine } = window.__godsEyeView; - v.scene.requestRender?.(); - const c = v.scene.canvas; + const { engine } = window.__godsEyeView; + window.__godsEyeView.engine.render.request?.(); + const c = window.__godsEyeView.engine.host.canvas; return engine.picker .drillPick({ x: c.clientWidth / 2, y: c.clientHeight / 2 }, 8) .some((p) => String(p?.id ?? '').startsWith('ghostmaps:cip:')); @@ -394,8 +395,8 @@ async function main() { ) .catch(() => {}); const probe = await page.evaluate(() => { - const { viewer: v, engine } = window.__godsEyeView; - const c = v.scene.canvas; + const { engine } = window.__godsEyeView; + const c = window.__godsEyeView.engine.host.canvas; const box = c.getBoundingClientRect(); const centre = { x: c.clientWidth / 2, y: c.clientHeight / 2 }; const picked = engine.picker.drillPick(centre, 8); @@ -486,27 +487,26 @@ async function main() { // far side. A pin clearly behind the planet (sphere test with margin) must // be hidden, and pins facing the camera must still show. const horizonCounts = (lon, lat) => - page.evaluate( - async (id, lo, la) => { - const gev = window.__godsEyeView; + evaluateWithGeo( + page, + async (qaGeo, id, lo, la) => { const d2r = Math.PI / 180; try { - gev.viewer.camera.cancelFlight(); + window.__godsEyeView.engine.camera.cancelFlight(); } catch { /* no flight */ } - gev.viewer.camera.setView({ - destination: - gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({ - longitude: lo * d2r, - latitude: la * d2r, - height: 18_000_000, - }), + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ + longitude: lo * d2r, + latitude: la * d2r, + height: 18_000_000, + }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); - gev.viewer.scene.requestRender?.(); + window.__godsEyeView.engine.render.request?.(); await new Promise((r) => setTimeout(r, 1200)); - const c = gev.viewer.camera.positionWC; + const c = window.__godsEyeView.engine.camera.pose().position; const norm = (v) => Math.hypot(v.x, v.y, v.z); let far = 0; let nearShown = 0; @@ -583,7 +583,8 @@ async function main() { await page.waitForFunction(() => window.__godsEyeView?.dataManager, { timeout: TIMEOUT_MS, }); - await sleep(3000); + await whenSettled(page); + await whenFramesDrawn(page); await enableAndSettle(page); const kept = await stats(page); record( @@ -612,7 +613,7 @@ async function main() { (id) => window.__godsEyeView.dataManager.setEnabled(id, false), LAYER_ID, ); - await sleep(500); + await whenFramesDrawn(page); record( 'DISABLE: every GhostMaps data source leaves the viewer', (await sources(page)).length === 0, @@ -679,7 +680,7 @@ async function main() { { timeout: 20000, polling: 100 }, LAYER_ID, ); - await sleep(500); + await whenFramesDrawn(page); const second = await identity(); record( 'PUSH: a new edition reloads only that product', @@ -698,7 +699,7 @@ async function main() { .filter((s) => !s.closed) .forEach((s) => s.emit(catalog)); }); - await sleep(1000); + await whenFramesDrawn(page); const third = await identity(); record( 'PUSH: a product leaving the catalog leaves the globe', @@ -738,7 +739,7 @@ async function main() { }); await phone.close(); } finally { - await browser.close(); + await closeBrowser(browser); } const failed = results.filter((r) => !r.ok); console.log( diff --git a/scripts/qa-gnss.mjs b/scripts/qa-gnss.mjs index 72dc4f416..b9f691777 100644 --- a/scripts/qa-gnss.mjs +++ b/scripts/qa-gnss.mjs @@ -1,4 +1,7 @@ #!/usr/bin/env node +// Cesium-only probes: native ground-primitive failure injection tests the Cesium paint path. +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; /** * qa-gnss.mjs — headless proof for the GNSS Integrity layer * (id `gnss-integrity`): low navigation accuracy reported by ADS-B. @@ -45,7 +48,7 @@ * Exits non-zero on any FAIL. Does not commit anything. */ -import puppeteer from 'puppeteer'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -74,7 +77,6 @@ function record(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 }) { @@ -83,30 +85,30 @@ async function boot(page, { lon, lat, height }) { timeout: TIMEOUT_MS, }); await page.waitForFunction( - () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + () => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager, { timeout: TIMEOUT_MS }, ); - await sleep(5000); + await whenSettled(page); await page.keyboard.press('Escape'); - await page.evaluate( - (lo, la, h) => { - const gev = window.__godsEyeView; + await evaluateWithGeo( + page, + async (qaGeo, lo, la, h) => { const d2r = Math.PI / 180; // The app's intro flyTo animation clobbers a setView issued mid-flight. try { - gev.viewer.camera.cancelFlight(); + window.__godsEyeView.engine.camera.cancelFlight(); } catch { /* no flight active */ } - gev.viewer.camera.setView({ - destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({ + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: lo * d2r, latitude: la * d2r, height: h, }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); - gev.viewer.scene.requestRender?.(); + window.__godsEyeView.engine.render.request?.(); }, lon, lat, @@ -168,7 +170,7 @@ async function showLayerRow(page) { .querySelector(`[data-layer-id="${id}"]`) ?.scrollIntoView({ block: 'center' }); }, LAYER_ID); - await sleep(800); + await whenSettled(page); } /** Actual card pixels on the shared canvas, available in dev and production. */ @@ -184,12 +186,8 @@ function paintedOverlayPixels() { } async function shoot(page, name) { - await page - .waitForFunction( - () => window.__godsEyeView.viewer.scene.globe.tilesLoaded, - { timeout: TIMEOUT_MS }, - ) - .catch(() => {}); + await whenTilesLoaded(page, { timeoutMs: TIMEOUT_MS }).catch(() => {}); + await whenSettled(page); await showLayerRow(page); await page.screenshot({ path: path.join(SHOTS_DIR, `gnss-${name}.png`) }); } @@ -367,18 +365,9 @@ async function main() { 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 } - : {}), + const browser = await launchBrowser({ + headless: true, args: [ - '--use-gl=angle', - '--use-angle=swiftshader', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', @@ -530,7 +519,7 @@ async function main() { await shoot(page, 'synthetic-bands'); // GNSS fills must not own map input, including over a real perimeter. - await page.evaluate(async () => { + await evaluateWithGeo(page, async (qaGeo) => { const original = window.fetch.bind(window); window.fetch = (input, init) => { const url = typeof input === 'string' ? input : input.url; @@ -562,10 +551,10 @@ async function main() { ); return original(input, init); }; - const { viewer, dataManager } = window.__godsEyeView; - viewer.camera.cancelFlight(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + const { dataManager } = window.__godsEyeView; + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: (18.25 * Math.PI) / 180, latitude: (54.25 * Math.PI) / 180, height: 2500, @@ -573,15 +562,15 @@ async function main() { orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); await dataManager.setEnabled('fire-perimeters', true); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); await page.waitForFunction( () => { // The engine's pick names the perimeter ground item's pickId. - const { viewer: v, engine } = window.__godsEyeView, + const { engine } = window.__godsEyeView, p = engine.picker.pick({ - x: v.scene.canvas.clientWidth / 2, - y: v.scene.canvas.clientHeight / 2, + x: window.__godsEyeView.engine.host.canvas.clientWidth / 2, + y: window.__godsEyeView.engine.host.canvas.clientHeight / 2, }); return String(p?.id?.id ?? p?.id ?? '').startsWith( 'fire-perimeter:gnss-pick-qa:', @@ -590,8 +579,8 @@ async function main() { { timeout: TIMEOUT_MS, polling: 250 }, ); const pickProbe = await page.evaluate(() => { - const { viewer: v, engine } = window.__godsEyeView, - c = v.scene.canvas; + const { engine } = window.__godsEyeView, + c = window.__godsEyeView.engine.host.canvas; const centre = { x: c.clientWidth / 2, y: c.clientHeight / 2 }; const empty = { x: centre.x + 300, y: centre.y }; const picked = engine.picker.pick(empty), @@ -600,7 +589,7 @@ async function main() { 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, + height: window.__godsEyeView.engine.camera.pose().cartographic.height, }; }); record('PICK: a perimeter remains selectable under GNSS', true); @@ -637,13 +626,13 @@ async function main() { await page.mouse.click(pickProbe.empty.x, pickProbe.empty.y, { clickCount: 2, }); - await sleep(1000); + await whenSettled(page); record( 'PICK: empty double-click does not zoom to a GNSS cell', await page.evaluate( (height) => Math.abs( - window.__godsEyeView.viewer.camera.positionCartographic.height - + window.__godsEyeView.engine.camera.pose().cartographic.height - height, ) < 200, pickProbe.height, @@ -659,17 +648,16 @@ async function main() { const beforeMove = await page.evaluate( () => window.__gnssQaRequests.length, ); - await page.evaluate(() => { - const { viewer } = window.__godsEyeView; - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo) => { + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ 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(); + window.__godsEyeView.engine.render.request(); }); await page.waitForFunction( () => { @@ -684,7 +672,7 @@ async function main() { }, { timeout: 15000 }, ); - await sleep(3500); + await whenSettled(page); const moved = await page.evaluate( (id) => ({ requests: window.__gnssQaRequests.length, @@ -708,17 +696,16 @@ async function main() { JSON.stringify(moved), ); await shoot(page, 'camera-refresh'); - await page.evaluate(() => { - const { viewer } = window.__godsEyeView; - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo) => { + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ 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(); + window.__godsEyeView.engine.render.request(); }); await page.waitForFunction( () => { @@ -732,19 +719,23 @@ async function main() { }, { 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); + const world = await evaluateWithGeo( + page, + async (qaGeo, id) => { + const { dataManager } = window.__godsEyeView; + const bounds = window.__godsEyeView.engine.camera.viewRectangle( + qaGeo.ellipsoid.WGS84, + ); + return { + enabled: dataManager.isEnabled(id), + height: window.__godsEyeView.engine.camera.pose().cartographic.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 && @@ -755,17 +746,16 @@ async function main() { JSON.stringify(world), ); await shoot(page, 'globe-regional'); - await page.evaluate(() => { - const { viewer } = window.__godsEyeView; - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo) => { + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ 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(); + window.__godsEyeView.engine.render.request(); }); await page.waitForFunction( () => { @@ -780,7 +770,7 @@ async function main() { }, { timeout: 15000 }, ); - await sleep(3500); + await whenSettled(page); // ── (iii) FAILURE ───────────────────────────────────────────────────── console.log('\n(iii) FAILURE — /api/gnss-integrity stubbed to 502...'); @@ -990,7 +980,7 @@ async function main() { console.error('\x1b[31mHarness error:\x1b[0m', err); exitCode = 3; } finally { - await browser.close(); + await closeBrowser(browser); } const pass = results.filter((r) => r.ok).length; diff --git a/scripts/qa-infra-lod.mjs b/scripts/qa-infra-lod.mjs index df6107190..808069e28 100644 --- a/scripts/qa-infra-lod.mjs +++ b/scripts/qa-infra-lod.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +// Cesium-only probes: native camera rotation preserves the Cesium orbit performance measurement. +import { evaluateWithGeo } from './qa/geometry.mjs'; /** * qa-infra-lod — browser checks for the local-infrastructure LOD declutter. * @@ -24,7 +26,8 @@ * rAF throttling disabled so the frame clock is honest. Exits non-zero on any * FAIL. Commits nothing. */ -import puppeteer from 'puppeteer'; +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -59,18 +62,10 @@ function report(name, detail) { console.log(` [MEAS] ${name} — ${JSON.stringify(detail)}`); } -const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', - executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined, +const browser = await launchBrowser({ + headless: true, protocolTimeout: 300_000, - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--window-size=1440,900', - '--disable-backgrounding-occluded-windows', - '--disable-renderer-backgrounding', - '--disable-background-timer-throttling', - ], + args: ['--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-background-timer-throttling'], }); try { @@ -81,8 +76,8 @@ try { const testUrl = new URL(APP_URL); testUrl.searchParams.set('welcome', '0'); await page.goto(testUrl.href, { waitUntil: 'domcontentloaded' }); - await page.waitForFunction(() => window.__godsEyeView?.styleManager - && document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 90_000 }); + await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 90_000 }); + await whenSettled(page); // The startup view restore and any boot flight land after the loading // screen; park only once they are over, or they move the camera away from // the full-earth view before the first LOD walk. @@ -101,20 +96,18 @@ try { !document.querySelector('#first-run-launcher:not([hidden])') ))); report('renderer', await page.evaluate(() => { - const gl = window.__godsEyeView.viewer.scene.context._gl; + const gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2'); const debug = gl.getExtension('WEBGL_debug_renderer_info'); return debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER); })); // Park at a full-earth view and disable every layer so infra is measured // in isolation. - await page.evaluate(async () => { + await evaluateWithGeo(page, async (qaGeo) => { const gev = window.__godsEyeView; - const v = gev.viewer; - v.camera.cancelFlight(); - const ell = v.scene.globe.ellipsoid; - v.camera.setView({ - destination: ell.cartographicToCartesian({ + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: 0, latitude: 15 * Math.PI / 180, height: 24_000_000, }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, @@ -125,7 +118,7 @@ try { } } }); - await new Promise((r) => setTimeout(r, 4_000)); + await whenSettled(page); // Enable the infrastructure layers (skipped in --control so the orbit cost // measures the empty scene for attribution). @@ -149,7 +142,7 @@ try { return (s.count || 0) > 0 || s.error; }); if (done) break; - gev.viewer.scene.requestRender?.(); + window.__godsEyeView.engine.render.request?.(); await new Promise((r) => setTimeout(r, 200)); } const out = {}; @@ -161,8 +154,8 @@ try { check(`${id} loaded its bundled dataset`, (loaded[id]?.count || 0) > 0, loaded[id]); } // Nudge a render pass so the first post-enable LOD walk runs. - await page.evaluate(() => window.__godsEyeView?.viewer?.scene?.requestRender?.()); - await new Promise((r) => setTimeout(r, 2_000)); + await page.evaluate(() => window.__godsEyeView.engine.render.request?.()); + await whenSettled(page); } // ── GATE a: full-earth budget engaged ───────────────────────────────── @@ -204,21 +197,19 @@ try { } // ── GATE b: zoom in widens the budget ─────────────────────────────── - await page.evaluate(() => { - const v = window.__godsEyeView.viewer; - const ell = v.scene.globe.ellipsoid; - v.camera.cancelFlight(); - v.camera.setView({ - destination: ell.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo) => { + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: -97.74 * Math.PI / 180, latitude: 30.27 * Math.PI / 180, height: 55_000, }), orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 }, }); // setView does not fire moveEnd; raise the LOD recompute trigger. - v.camera.moveEnd.raiseEvent?.(); - v.scene.requestRender?.(); + window.__godsEyeView.engine.camera.moveEnd.raiseEvent?.(); + window.__godsEyeView.engine.render.request?.(); }); - await new Promise((r) => setTimeout(r, 3_000)); + await whenSettled(page); const regionalLod = await readLod('getLodDiagnostics @ city'); for (const id of INFRA_LAYER_IDS) { const g = globalLod[id]; @@ -240,7 +231,7 @@ try { .slice().sort()); const before = snap(); for (let i = 0; i < 5; i++) { - gev.viewer.scene.requestRender?.(); + window.__godsEyeView.engine.render.request?.(); await new Promise((r) => setTimeout(r, 120)); } return { before, after: snap() }; @@ -251,29 +242,26 @@ try { { beforeCounts: churn.before.map((ids) => ids.length), afterCounts: churn.after.map((ids) => ids.length) }); // Back to full-earth for the frame-cost measurement. - await page.evaluate(() => { - const v = window.__godsEyeView.viewer; - const ell = v.scene.globe.ellipsoid; - v.camera.setView({ - destination: ell.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo) => { + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: 0, latitude: 15 * Math.PI / 180, height: 24_000_000, }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); - v.camera.moveEnd.raiseEvent?.(); - v.scene.requestRender?.(); + window.__godsEyeView.engine.camera.moveEnd.raiseEvent?.(); + window.__godsEyeView.engine.render.request?.(); }); - await new Promise((r) => setTimeout(r, 3_000)); + await whenSettled(page); } // Count actual rendered frames: scene.render() can return without drawing. const frameCost = await page.evaluate(() => new Promise((resolve) => { const v = window.__godsEyeView.viewer; - const scene = v.scene; const durations = []; let frames = 0; let previous; - const remove = scene.postRender.addEventListener(() => { + const remove = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { const now = performance.now(); if (previous !== undefined) durations.push(now - previous); previous = now; @@ -282,7 +270,7 @@ try { const t0 = performance.now(); const tick = () => { v.camera.rotateRight(0.0004); - scene.requestRender(); + window.__godsEyeView.engine.render.request(); if (performance.now() - t0 < 10_000) requestAnimationFrame(tick); else { remove(); @@ -308,24 +296,23 @@ try { : 'rendered-frame intervals, infra ON, 10s orbit', frameCost); // ── parked-idle honesty: the LOD walk must not force continuous render ─ - await new Promise((r) => setTimeout(r, 4_000)); + await whenSettled(page); const idle = await page.evaluate(async () => { // The debug handle's own governor read (no /src import: runs on a build). const { getRenderGovernorDiagnostics } = window.__godsEyeView; return new Promise((resolve) => { - const scene = window.__godsEyeView.viewer.scene; const startedAt = Date.now(); - const tilesLoadedBefore = scene.globe.tilesLoaded; + const tilesLoadedBefore = window.__godsEyeView.engine.surface.tilesLoaded(); let renders = 0; let framesWithPendingTiles = 0; - const remove = scene.postRender.addEventListener(() => { + const remove = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { renders += 1; - if (!scene.globe.tilesLoaded) framesWithPendingTiles += 1; + if (!window.__godsEyeView.engine.surface.tilesLoaded()) framesWithPendingTiles += 1; }); setTimeout(() => { remove(); const governor = getRenderGovernorDiagnostics(); - resolve({ renders, tilesLoadedBefore, tilesLoadedAfter: scene.globe.tilesLoaded, + resolve({ renders, tilesLoadedBefore, tilesLoadedAfter: window.__godsEyeView.engine.surface.tilesLoaded(), framesWithPendingTiles, mode: governor.mode, holds: governor.holds, requests: governor.recentRequests.filter(request => request.at >= startedAt) }); }, 5_000); @@ -348,7 +335,7 @@ try { console.log(` [SHOT] skipped — ${e?.message || e}`); } } finally { - await browser.close(); + await closeBrowser(browser); } const passed = results.filter((r) => r.pass).length; diff --git a/scripts/qa-installation-navigation.mjs b/scripts/qa-installation-navigation.mjs index 5a29ff95b..5c5d5a156 100644 --- a/scripts/qa-installation-navigation.mjs +++ b/scripts/qa-installation-navigation.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node /** Mapped installations: isolated camera navigation, cached revisits and Contacts release. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; -import puppeteer from 'puppeteer'; import { moveCamera } from './qa-journey-recorder.mjs'; const args = process.argv.slice(2); @@ -13,10 +14,9 @@ if (args.includes('--help')) { } const baseline = args.includes('--baseline'); const result = { views: [], errors: [], forbidden: [], failures: [] }; -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, - args: ['--use-angle=metal', '--disable-background-timer-throttling'], + args: ['--disable-background-timer-throttling'], protocolTimeout: 120000, }); try { @@ -58,12 +58,10 @@ try { args.find((a) => /^https?:/.test(a)), { waitUntil: 'domcontentloaded' }, ); - await page.waitForFunction( - () => - window.__godsEyeView?.dataManager && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 120000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.dataManager, { + timeout: 120000, + }); + await whenSettled(page); await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, ); @@ -72,13 +70,12 @@ try { const g = window.__godsEyeView; for (const [id] of g.dataManager.layers) await g.dataManager.setEnabled(id, false); - g.viewer.scene.screenSpaceCameraController.enableCollisionDetection = false; window.installationEvents = { changed: 0, end: 0 }; window.installationEntityRefs = new Map(); - g.viewer.camera.changed.addEventListener( + window.__godsEyeView.engine.camera.changed.addEventListener( () => window.installationEvents.changed++, ); - g.viewer.camera.moveEnd.addEventListener( + window.__godsEyeView.engine.camera.moveEnd.addEventListener( () => window.installationEvents.end++, ); }); @@ -90,7 +87,7 @@ try { const fits = await page.evaluate(async () => { const { installationTileZoom } = await import('/src/layers/installations/source.js'); - const r = window.__godsEyeView.viewer.camera.computeViewRectangle(); + const r = window.__godsEyeView.engine.camera.viewRectangle(); const d = 180 / Math.PI; return ( r && @@ -248,7 +245,7 @@ try { }); await page.type('#location-search', '33.33, -117.40'); await page.keyboard.press('Enter'); - await sleep(4500); + await whenSettled(page); } else { await moveCamera( page, @@ -274,7 +271,7 @@ try { }, { timeout: 15000 }, ); - await sleep(1000); + await whenSettled(page); await page.evaluate(async () => { const g = window.__godsEyeView; const id = window.__qaInstallationSites()[0].id; @@ -298,10 +295,7 @@ try { }, { timeout: 15000 }, ); - await page.waitForFunction( - () => !window.__godsEyeView.viewer.camera._currentFlight, - { timeout: 10000 }, - ); + await whenSettled(page); await page.waitForFunction( () => window.__godsEyeView.dataManager.layers @@ -361,11 +355,7 @@ try { { timeout: 30000 }, ); } - await page.waitForFunction( - () => !window.__godsEyeView.viewer.camera._currentFlight, - { timeout: 10000 }, - ); - await sleep(500); + await whenSettled(page); previous = await page.evaluate( () => window.__godsEyeView.dataManager.layers @@ -411,7 +401,7 @@ try { `qa-shots/installations-navigation-${baseline ? 'before' : 'after'}.json`, JSON.stringify(result, null, 2), ); - await browser.close(); + await closeBrowser(browser); } if ( !baseline && diff --git a/scripts/qa-installation-polish.mjs b/scripts/qa-installation-polish.mjs index 0fbf308bf..22d841fc1 100644 --- a/scripts/qa-installation-polish.mjs +++ b/scripts/qa-installation-polish.mjs @@ -1,14 +1,15 @@ +// Cesium-only probes: native ground-primitive inspection verifies the Cesium outline paint. +import { whenUiSettled } from './qa/ready.mjs'; /** Shared browser assertions for installation label persistence and selection. */ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import { moveCamera, createScreencast } from './qa-journey-recorder.mjs'; -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export async function checkInstallationPan(page, dir) { await page.evaluate(() => window.__godsEyeView.dataManager.setEnabled('local-datacenters', true), ); - await sleep(3000); + await whenUiSettled(page); await page.evaluate(async () => { const { getPaintRect } = ( await import('/src/overlays/worldOverlay.js') @@ -25,7 +26,7 @@ export async function checkInstallationPan(page, dir) { if (record.layerId === 'local-datacenters' && record.entity?.id != null) centerIds.push(record.entity.id); window.__polishPanFrames = []; - window.__stopPolishPan = g.viewer.scene.postRender.addEventListener(() => { + window.__stopPolishPan = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { const sites = [], centers = []; installations.visitNamedMarkers((id) => { @@ -153,7 +154,7 @@ export async function checkInstallationSelection( }, { timeout: 60000 }, ); - await sleep(1500); + await whenUiSettled(page); const picked = await page.evaluate(async (site) => { const C = await import('/node_modules/cesium/Build/Cesium/index.js'); const g = window.__godsEyeView, @@ -166,10 +167,7 @@ export async function checkInstallationSelection( layer.visitNamedMarkers((id, p) => { if (id === record.id) point = p.position; }); - const screen = C.SceneTransforms.worldToWindowCoordinates( - g.viewer.scene, - point, - ); + const screen = window.__godsEyeView.engine.projector.worldToWindow(point); window.__polishSelectedId = record.id; window.__polishTitlePaints = []; const canvas = document.getElementById('world-overlay-canvas'), @@ -224,7 +222,7 @@ export async function checkInstallationSelection( }; }; window.__stopPolishSelection = - g.viewer.scene.postRender.addEventListener(() => { + window.__godsEyeView.engine.frames.postRender.addEventListener(() => { window.__polishSelectionFrames.push({ paints: window.__polishTitlePaints.splice(0).length, own: Boolean(getPaintRect('military-installations', record.id)), @@ -274,7 +272,7 @@ export async function checkInstallationSelection( ); throw error; }); - await sleep(500); + await whenUiSettled(page); const selected = await page.evaluate(() => window.__polishFillProbe()); await page.screenshot({ path: `${dir}/after-selection-${site.key}.png` }); // Close, oblique angles expose surface clipping and floating fills. @@ -283,13 +281,12 @@ export async function checkInstallationSelection( async (site, heading) => { const C = await import('/node_modules/cesium/Build/Cesium/index.js'); - const g = window.__godsEyeView; const target = C.Cartesian3.fromDegrees( site.lon, site.lat, site.key === 'mabry' ? 180 : 10, ); - g.viewer.camera.lookAt( + window.__godsEyeView.engine.camera.lookAt( target, new C.HeadingPitchRange( C.Math.toRadians(heading), @@ -297,12 +294,12 @@ export async function checkInstallationSelection( 800, ), ); - g.viewer.camera.lookAtTransform(C.Matrix4.IDENTITY); + window.__godsEyeView.engine.camera.lookAtTransform(C.Matrix4.IDENTITY); }, site, heading, ); - await sleep(1700); + await whenUiSettled(page); assert.ok( (await page.evaluate(() => window.__polishFillProbe())).selected, 'orbit must preserve installation selection', @@ -312,7 +309,7 @@ export async function checkInstallationSelection( }); } await moveCamera(page, { ...site, pitch: -80 }, 1); - await sleep(900); + await whenUiSettled(page); const empty = await page.evaluate(() => { const g = window.__godsEyeView; for (let y = 250; y < 650; y += 75) @@ -329,7 +326,7 @@ export async function checkInstallationSelection( window.__polishFillProbe().selected === null && window.__polishFillProbe().items === 0, ); - await sleep(600); + await whenUiSettled(page); const deselected = await page.evaluate(() => { window.__stopPolishSelection(); window.__restorePolishPaint(); diff --git a/scripts/qa-l9-matrix.mjs b/scripts/qa-l9-matrix.mjs index 90f54d06d..08db2cd08 100644 --- a/scripts/qa-l9-matrix.mjs +++ b/scripts/qa-l9-matrix.mjs @@ -1,3 +1,4 @@ +// Cesium-only probes: native credits, imagery, primitive ownership and local camera coordinates are Cesium-specific claims. /** * qa-l9-matrix.mjs — the L9 release-candidate QA matrix, in one command. * @@ -36,7 +37,7 @@ * * Exit codes: 0 = no FAILs · 1 = at least one FAIL · 2 = target unreachable. */ -import puppeteer from 'puppeteer'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import { spawn } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -1207,7 +1208,7 @@ function selected(c) { } // ── browser group runner ────────────────────────────────────────────────── -const BOOT_JS = () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager; +const BOOT_JS = () => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager; async function runBrowserGroup(record) { const ids = BROWSER_CHECKS.map(([id]) => id).filter((id) => selected(CHECKS.find((c) => c.id === id))); @@ -1215,20 +1216,10 @@ async function runBrowserGroup(record) { const emit = (id, res, ms) => { if (ids.includes(id)) record(CHECKS.find((c) => c.id === id), res, ms); }; const only = (id) => ids.includes(id); - const exe = await puppeteer.executablePath().catch(() => null); - const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', - ...(exe ? { executablePath: exe } : {}), - // Heavy layers (CCTV fleet admission, traffic road graphs) can block the - // page's main thread past puppeteer's 180 s default and turn a healthy - // layer into a bogus "probe threw" FAIL. + const browser = await launchBrowser({ + headless: true, protocolTimeout: 420000, - args: ['--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader', - '--disable-background-timer-throttling', '--disable-renderer-backgrounding', - // C17 drives the keyless voice path, which asks for a microphone before - // it mints a token. A fake device + auto-granted permission keeps the - // failure under test the MISSING KEY, not a missing microphone. - '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'], + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'], }); env.browserVersion = await browser.version(); const page = await browser.newPage(); @@ -1309,15 +1300,15 @@ async function runBrowserGroup(record) { // holds non-blank pixels. const painted = await new Promise((resolve) => { let done = false; - const stop = g.viewer.scene.postRender.addEventListener(() => { + const stop = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { if (done) return; done = true; setTimeout(() => { try { stop(); } catch { /* removed */ } resolve(true); }, 0); }); - g.viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); setTimeout(() => { if (!done) { done = true; try { stop(); } catch { /* removed */ } resolve(false); } }, 20000); }); - const canvas = g.viewer.scene.canvas; + const canvas = window.__godsEyeView.engine.host.canvas; return { painted, w: canvas?.width ?? 0, h: canvas?.height ?? 0, frames: g.viewer.scene.frameState?.frameNumber ?? null }; }, null, 45000); paint = paintR.ok ? paintR.value : { probeError: paintR.reason }; @@ -1339,12 +1330,12 @@ async function runBrowserGroup(record) { for (const [id] of BROWSER_CHECKS.slice(1)) { emit(id, skip('app did not boot — see C1', 'ENV'), 0); } - await browser.close(); + await closeBrowser(browser); return; } // Cancel the intro flyTo: it clobbers any setView issued mid-flight. - await evalBounded(() => { try { window.__godsEyeView.viewer.camera.cancelFlight(); } catch { /* none */ } }); + await evalBounded(() => { try { window.__godsEyeView.engine.camera.cancelFlight(); } catch { /* none */ } }); await new Promise((r) => setTimeout(r, 3000)); // Enable a layer, then poll its stats from THIS side. Polling in Node (many @@ -1608,7 +1599,7 @@ async function runBrowserGroup(record) { // is also the only altitude at which "live flow" means anything. await evalBounded(async () => { const g = window.__godsEyeView; - g.viewer.camera.cancelFlight(); + window.__godsEyeView.engine.camera.cancelFlight(); g.styleManager.applyCameraState({ lat: 30.2672, lon: -97.7431, alt: 2500, heading: 0, pitch: -40 }, 1.2); await new Promise((r) => setTimeout(r, 3500)); }); @@ -1653,7 +1644,7 @@ async function runBrowserGroup(record) { // Establish the camera this check needs instead of inheriting one. await evalBounded(async () => { const g = window.__godsEyeView; - g.viewer.camera.cancelFlight(); + window.__godsEyeView.engine.camera.cancelFlight(); g.styleManager.applyCameraState({ lat: 20, lon: 0, alt: 14000000, heading: 0, pitch: -90 }, 1.5); await new Promise((r) => setTimeout(r, 4000)); }, null, 30000); @@ -1725,7 +1716,7 @@ async function runBrowserGroup(record) { // down: nadir at 25 km spans well under a degree. await evalBounded(async (b) => { const g = window.__godsEyeView; - g.viewer.camera.cancelFlight(); + window.__godsEyeView.engine.camera.cancelFlight(); g.styleManager.applyCameraState({ lat: b.lat, lon: b.lon, alt: 25000, heading: 0, pitch: -90 }, 1.2); await new Promise((r) => setTimeout(r, 4000)); }, box, 30000); @@ -1791,11 +1782,11 @@ async function runBrowserGroup(record) { const credR = await mustEval(async () => { const viewer = window.__godsEyeView.viewer; const dm = window.__godsEyeView.dataManager; - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); const html = (viewer.creditDisplay._staticCredits || []).map((c) => c.html); viewer.creditDisplay.showLightbox(); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); const listCount = document.querySelector('.cesium-credit-lightbox > ul')?.children?.length || 0; viewer.creditDisplay.hideLightbox(); @@ -1830,12 +1821,11 @@ async function runBrowserGroup(record) { await step('C13', async () => { const stR = await mustEval(async () => { - const viewer = window.__godsEyeView.viewer; window.__godsEyeView.styleManager.setCleanView(true); // Cesium paints the credit line during a render — force frames before // measuring, or a healthy credit reads as 0x0. for (let i = 0; i < 4; i += 1) { - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); // eslint-disable-next-line no-await-in-loop await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); } @@ -1954,10 +1944,10 @@ async function runBrowserGroup(record) { // control at all. const setupR = await mustEval(async () => { const g = window.__godsEyeView; - g.viewer.camera.cancelFlight(); + window.__godsEyeView.engine.camera.cancelFlight(); g.styleManager.applyCameraState({ lat: 30.2672, lon: -97.7431, alt: 3000, heading: 0, pitch: -35 }, 1.5); await new Promise((r) => setTimeout(r, 3000)); - return { altKm: g.viewer.camera.positionCartographic.height / 1000 }; + return { altKm: window.__godsEyeView.engine.camera.pose().cartographic.height / 1000 }; }, null, 60000); if (!setupR.ok) return crash(`could not put the camera at city altitude to test the reset: ${setupR.reason}`); const before = setupR.value?.altKm; @@ -1978,7 +1968,7 @@ async function runBrowserGroup(record) { // eslint-disable-next-line no-await-in-loop await new Promise((r) => setTimeout(r, 1000)); // eslint-disable-next-line no-await-in-loop - altKm = (await evalBounded(() => window.__godsEyeView.viewer.camera.positionCartographic.height / 1000, null, 20000)) ?? altKm; + altKm = (await evalBounded(() => window.__godsEyeView.engine.camera.pose().cartographic.height / 1000, null, 20000)) ?? altKm; if (altKm > 5000) break; } return altKm > 5000 @@ -2028,7 +2018,7 @@ async function runBrowserGroup(record) { errorDetail: document.getElementById('gev-voice-error-detail')?.textContent?.trim() || null, dataStatus: document.getElementById('gev-voice-control')?.dataset?.status || null, recentError: diag?.recentErrors?.[0] ? { source: diag.recentErrors[0].source, message: diag.recentErrors[0].message } : null, - appAlive: !!window.__godsEyeView?.viewer, + appAlive: !!window.__godsEyeView?.engine, }; }, null, 60000); if (!surfacedR.ok) return crash(`could not drive the keyless voice path: ${surfacedR.reason}`); @@ -2045,7 +2035,7 @@ async function runBrowserGroup(record) { return pass(`keyless voice degrades honestly: status="${surfaced.status}", detail="${surfaced.detail}", reason "${surfaced.recentError?.message}"; globe still alive`); }); - await browser.close(); + await closeBrowser(browser); } // ── preflight ───────────────────────────────────────────────────────────── diff --git a/scripts/qa-label-readability.mjs b/scripts/qa-label-readability.mjs index 23a8c0807..b5c8e4f76 100644 --- a/scripts/qa-label-readability.mjs +++ b/scripts/qa-label-readability.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; /** * qa-label-readability.mjs — before/after evidence capture for ambient label * legibility over bright ground. @@ -30,9 +31,9 @@ * not only an eyeball. */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; const argv = process.argv.slice(2); const getOpt = (name, fallback) => { @@ -54,7 +55,6 @@ const SCENE_FILTER = getOpt('--scenes', '') .filter(Boolean); const SHOT_DIR = path.resolve(getOpt('--out', 'qa-shots/labels')); const SETTLE_MAX_MS = 40_000; -const SETTLE_MIN_MS = 4_000; /** * Scenes are (camera pose × visual style). `field` describes the synthetic @@ -162,19 +162,7 @@ const SCENES = [ }, ]; -const CHROME_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - // Pin puppeteer's Chrome-for-Testing: system Chrome auto-updates underneath - // the harnesses and its software-GL behaviour shifts across majors. - await puppeteer.executablePath().catch(() => null), -].filter(Boolean); -function findChrome() { - for (const candidate of CHROME_CANDIDATES) { - try { if (fs.existsSync(candidate)) return candidate; } catch { /* fall through */ } - } - return null; -} /** Decode a base64 PNG inside the page and return luminance statistics. */ async function measureShot(page, base64) { @@ -207,21 +195,13 @@ async function measureShot(page, base64) { }, base64); } -/** Wait until consecutive frames stop changing, so tiles are fully resolved. */ +/** Resolve map tiles before measuring the label background. */ async function settle(page) { - const started = Date.now(); - let previous = null; - while (Date.now() - started < SETTLE_MAX_MS) { - await new Promise((resolve) => setTimeout(resolve, 1500)); - const state = await page.evaluate(() => { - const { engine } = window.__godsEyeView; - return { tilesLoaded: engine.surface.tilesLoaded(), pending: 0 }; - }); - const settled = state.tilesLoaded && state.pending === 0; - if (settled && previous && Date.now() - started > SETTLE_MIN_MS) return true; - previous = settled; - } - return false; + await whenSettled(page); + return whenTilesLoaded(page, { timeoutMs: SETTLE_MAX_MS }).then( + () => true, + () => false, + ); } async function main() { @@ -239,17 +219,9 @@ async function main() { } fs.mkdirSync(SHOT_DIR, { recursive: true }); - const executablePath = findChrome(); - const browser = await puppeteer.launch({ - headless: 'new', - ...(executablePath ? { executablePath } : {}), - args: [ - '--no-sandbox', '--disable-setuid-sandbox', - '--use-gl=angle', `--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`, - '--disable-dev-shm-usage', '--disable-web-security', - '--disable-background-timer-throttling', '--disable-renderer-backgrounding', - '--window-size=1280,800', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-web-security', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1280,800'], }); const results = []; @@ -263,9 +235,10 @@ async function main() { () => window.__godsEyeView?.layers && window.__godsEyeView?.styleManager, { timeout: 60_000, polling: 100 }, ); + await whenSettled(page); // flyToAustin arrives ~500 ms after init; let it start and land before any // scene sets its own pose, or the arrival overwrites the first camera. - await new Promise((resolve) => setTimeout(resolve, 2500)); + await whenSettled(page); const selected = SCENE_FILTER.length ? SCENES.filter((scene) => SCENE_FILTER.some((prefix) => scene.id.startsWith(prefix))) @@ -421,7 +394,7 @@ async function main() { ); } } finally { - await browser.close(); + await closeBrowser(browser); } const summary = path.join(SHOT_DIR, `${TAG}-summary.json`); diff --git a/scripts/qa-labels.mjs b/scripts/qa-labels.mjs index 332d16a80..53f0debcd 100644 --- a/scripts/qa-labels.mjs +++ b/scripts/qa-labels.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { whenSettled } from './qa/ready.mjs'; /** * Deterministic QA for the detection overlay's bounded two-lane label pipeline. * @@ -16,9 +17,9 @@ * makes a small orbit. */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; const argv = process.argv.slice(2); const getFlag = (name) => argv.includes(name); @@ -58,30 +59,7 @@ const NORMAL_COUNTS = Object.freeze({ satellites: 830, }); -const CHROME_EXECUTABLE_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - // Prefer puppeteer's version-pinned Chrome-for-Testing over the system - // Chrome: /Applications auto-updates underneath the harnesses, and its - // software-GL behavior shifts across majors (system Chrome 150 blew the - // tile-gated drain budget under SwiftShader on 2026-07-30 — six - // false-negative qa-cctv-v2 runs against a healthy build). A deterministic - // pinned browser beats the newest one for regression harnesses. - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -function findChromeExecutable() { - for (const candidate of CHROME_EXECUTABLE_CANDIDATES) { - try { - if (fs.existsSync(candidate)) return candidate; - } catch { - // Let Puppeteer use its bundled browser. - } - } - return null; -} const results = []; let skippedChecks = 0; @@ -218,21 +196,9 @@ async function main() { } fs.mkdirSync(SHOT_DIR, { recursive: true }); - const executablePath = findChromeExecutable(); - const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', - ...(executablePath ? { executablePath } : {}), - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--use-gl=angle', - `--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`, - '--disable-dev-shm-usage', - '--disable-web-security', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--window-size=1280,800', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-web-security', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1280,800'], }); const consoleErrors = []; @@ -278,11 +244,12 @@ async function main() { () => window.__godsEyeView?.layers && window.__godsEyeView?.styleManager, { timeout: 60000, polling: 100 }, ); + await whenSettled(page); // flyToAustin schedules its 600 m arrival 500 ms after initialization. // Let that callback start, then cancel it before establishing the global // measurement camera so startup motion cannot invalidate the sample. - await new Promise((resolve) => setTimeout(resolve, 600)); + await whenSettled(page); await page.evaluate(() => window.__godsEyeView.engine.camera.cancelFlight()); const stateChecks = await page.evaluate((storageKey) => { @@ -647,7 +614,7 @@ async function main() { console.log(` Screenshot: ${path.join(SHOT_DIR, 'normal-global-5200.png')}`); console.log(` Synthetic solves: ${solveSamples.length}; normal frames: ${normalFieldFrames.length} (${normalFrames.length} non-solve)`); } finally { - await browser.close(); + await closeBrowser(browser); if (skippedField) summarize(); } diff --git a/scripts/qa-layer-panel.mjs b/scripts/qa-layer-panel.mjs index 6f69f60bc..368716717 100644 --- a/scripts/qa-layer-panel.mjs +++ b/scripts/qa-layer-panel.mjs @@ -1,13 +1,12 @@ #!/usr/bin/env node +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; /** Browser proof of Layers panel replacement and subscription ownership. */ -import puppeteer from 'puppeteer'; -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: [ '--no-sandbox', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), ], }); const page = await browser.newPage(); @@ -26,6 +25,7 @@ try { await page.waitForFunction(() => window.__godsEyeView?.dataManager, { timeout: 60000, }); + await whenSettled(page); const results = await page.evaluate(async () => { const manager = window.__godsEyeView.dataManager; const entry = document.querySelector( @@ -139,7 +139,7 @@ try { for (const [name, passed] of results) check(name, passed); check('no uncaught browser errors', errors.length === 0); } finally { - await browser.close(); + await closeBrowser(browser); } console.log(`RESULT: ${failures} failures`); process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-layer-token-twochar.mjs b/scripts/qa-layer-token-twochar.mjs index 4a9c53ac1..0ca883e72 100644 --- a/scripts/qa-layer-token-twochar.mjs +++ b/scripts/qa-layer-token-twochar.mjs @@ -7,9 +7,9 @@ * * Run against a local Vite server: node scripts/qa-layer-token-twochar.mjs */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; -import puppeteer from 'puppeteer'; import { LAYER_STATE_TOKEN_RESERVATIONS, nextLayerStateToken, @@ -119,14 +119,12 @@ function injectCatalog(source) { return result; } -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: [ '--no-sandbox', '--disable-dev-shm-usage', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), ], }); @@ -333,5 +331,5 @@ try { console.log('RESULT: 10 passed, 0 failed, 0 skipped'); } finally { await Promise.all(contexts.map((context) => context.close().catch(() => {}))); - await browser.close(); + await closeBrowser(browser); } From 53b07ba91fb5b1009217c07daaca450f0376f2a9 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 16:43:58 +0400 Subject: [PATCH 4/6] Move QA scripts to the shared harness, batch 3 of 4 Each script launches and closes Chrome through scripts/qa/browser.mjs, waits on the application through scripts/qa/ready.mjs instead of the loading screen and fixed pauses, and reads the contract instead of Cesium where the check is not about Cesium itself. --- scripts/qa-layer-visibility.mjs | 9 +- scripts/qa-location-controls.mjs | 55 ++-- scripts/qa-map-source-controls.mjs | 60 ++--- scripts/qa-map-source-tray.mjs | 61 ++--- scripts/qa-military-names.mjs | 47 ++-- scripts/qa-nepal-media-playback.mjs | 21 +- scripts/qa-openfreemap-same-tile.mjs | 57 ++--- scripts/qa-overlay-baseline.mjs | 62 ++--- scripts/qa-overpass-offload.mjs | 165 ++++++------ scripts/qa-radio.mjs | 353 ++++++++++++-------------- scripts/qa-recent-imagery.mjs | 101 ++++---- scripts/qa-scene-controls.mjs | 19 +- scripts/qa-scene-dialog-file-drop.mjs | 6 +- scripts/qa-scope-hud.mjs | 22 +- scripts/qa-segmented-controls.mjs | 17 +- scripts/qa-sky-model-lighting.mjs | 7 +- scripts/qa-status-badges.mjs | 14 +- scripts/qa-street-level.mjs | 63 ++--- scripts/qa-terrain-429.mjs | 92 +++---- scripts/qa-traffic-baseline.mjs | 81 ++---- scripts/qa-traffic-jamviz-ab.mjs | 65 ++--- scripts/qa-traffic-motion.mjs | 51 ++-- scripts/qa-traffic-navigation.mjs | 33 ++- scripts/qa-traffic-oblique.mjs | 42 +-- 24 files changed, 628 insertions(+), 875 deletions(-) diff --git a/scripts/qa-layer-visibility.mjs b/scripts/qa-layer-visibility.mjs index 8a903918e..e098f9e2a 100644 --- a/scripts/qa-layer-visibility.mjs +++ b/scripts/qa-layer-visibility.mjs @@ -1,12 +1,13 @@ #!/usr/bin/env node +import { whenSettled } from './qa/ready.mjs'; /** * Real-browser proof for manager-owned periodic work and document visibility. * Uses the app's development-only layer registration seam; no provider keys. * Run: node scripts/qa-layer-visibility.mjs --url http://localhost:4173 * Hiding uses a real background tab, never a patched document.hidden getter. */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; -import puppeteer from 'puppeteer'; import { chromeExecutablePath } from './qa-chrome.mjs'; const args = process.argv.slice(2); @@ -17,8 +18,7 @@ const url = new URL( 'http://localhost:4173', ); url.searchParams.set('welcome', '0'); -const browser = await puppeteer.launch({ - executablePath: chromeExecutablePath(), +const browser = await launchBrowser({ headless: true, args: [ '--no-sandbox', @@ -39,6 +39,7 @@ try { await page.waitForFunction( () => window.__gevQaRegisterLayer && window.__godsEyeView?.dataManager, ); + await whenSettled(page); await page.evaluate(async () => { const manager = window.__godsEyeView.dataManager; window.__qaVisibilityUpdates = 0; @@ -120,5 +121,5 @@ try { ); console.log('RESULT: 7 passed'); } finally { - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-location-controls.mjs b/scripts/qa-location-controls.mjs index dc80bd279..f862355ea 100644 --- a/scripts/qa-location-controls.mjs +++ b/scripts/qa-location-controls.mjs @@ -1,14 +1,13 @@ #!/usr/bin/env node /** Location UI acceptance; controlled search results isolate UI races from geocoder availability. */ +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; -import puppeteer from 'puppeteer'; -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: [ '--no-sandbox', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), ], }); const page = await browser.newPage(); @@ -26,11 +25,10 @@ try { { waitUntil: 'domcontentloaded' }, ); await page.waitForFunction( - () => - window.__godsEyeView?.styleManager?._locationControls && - document.getElementById('loading-screen')?.classList.contains('hidden'), + () => window.__godsEyeView?.styleManager?._locationControls, { timeout: 60000 }, ); + await whenSettled(page); await page.click('#location-bar-toggle'); await page.click('[data-pin-target="location-bar"]'); const cityId = await page.$eval( @@ -114,10 +112,7 @@ try { }, { timeout: 30000 }, ); - await page.waitForFunction( - () => !window.__godsEyeView.viewer.camera._currentFlight, - { timeout: 10000 }, - ); + await whenSettled(page); check( 'native geo URI search shows the decimal coordinate readout', await page.evaluate(() => { @@ -135,7 +130,7 @@ try { check( 'geo URI navigation settles near the requested coordinate', await page.evaluate(() => { - const position = window.__godsEyeView.viewer.camera.positionCartographic; + const position = window.__godsEyeView.engine.camera.pose().cartographic; const degrees = 180 / Math.PI; return ( Math.abs(position.latitude * degrees - 39.7392) < 0.05 && @@ -252,36 +247,17 @@ try { document.getElementById('poi-row').classList.contains('expanded'), ); // Finish the camera's existing city flight before inspecting both layouts. - await page.waitForFunction( - () => !window.__godsEyeView.viewer.camera._currentFlight, - { timeout: 10000 }, - ); + await whenSettled(page); await page.evaluate( () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)), ), ); - const settled = await page - .waitForFunction( - () => { - const scene = window.__godsEyeView.viewer.scene; - for (let index = 0; index < scene.primitives.length; index++) { - const primitive = scene.primitives.get(index); - if ( - typeof primitive.tilesLoaded === 'boolean' && - !primitive.tilesLoaded - ) - return false; - } - return true; - }, - { timeout: 60000 }, - ) - .then( - () => true, - () => false, - ); + const settled = await whenTilesLoaded(page, { timeoutMs: 60000 }).then( + () => true, + () => false, + ); check('visible city tiles settle before screenshots', settled); fs.mkdirSync('qa-shots/location-controls', { recursive: true }); await page.screenshot({ path: 'qa-shots/location-controls/desktop.png' }); @@ -301,7 +277,8 @@ try { if (!pending) return false; await pending; return ( - !ui.orbitController.active && !window.__godsEyeView.viewer.trackedEntity + !ui.orbitController.active && + !window.__godsEyeView.engine.camera.following() ); }), ); @@ -329,7 +306,7 @@ try { ); check('no uncaught browser errors', errors.length === 0); } finally { - await browser.close(); + await closeBrowser(browser); } console.log(`RESULT: ${failures} failures`); process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-map-source-controls.mjs b/scripts/qa-map-source-controls.mjs index d72961ca0..4c2452838 100644 --- a/scripts/qa-map-source-controls.mjs +++ b/scripts/qa-map-source-controls.mjs @@ -1,15 +1,15 @@ #!/usr/bin/env node +import { evaluateWithGeo } from './qa/geometry.mjs'; /** Browser acceptance for Map Source control lifecycle and truthful selection. */ +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; -import puppeteer from 'puppeteer'; -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: [ '--no-sandbox', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), ], }); const page = await browser.newPage(); @@ -25,17 +25,7 @@ try { const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173'); url.searchParams.set('welcome', '0'); await page.goto(url.href, { waitUntil: 'domcontentloaded' }); - await page.waitForFunction( - () => { - const cover = document.getElementById('loading-screen'); - return ( - window.__godsEyeView?.styleManager?._mapSourceControls && - cover?.classList.contains('hidden') && - Number(getComputedStyle(cover).opacity) === 0 - ); - }, - { timeout: 60_000 }, - ); + await whenSettled(page); await page.evaluate(() => { const manager = window.__godsEyeView.styleManager; const controller = manager.mapStackController; @@ -132,19 +122,17 @@ try { }); document.querySelector('.map-stack-chip.active')?.focus(); }); - await page.evaluate(() => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.cancelFlight?.(); - viewer.scene.tweens?.removeAll?.(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo) => { + window.__godsEyeView.engine.camera.cancelFlight?.(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: (-97.7431 * Math.PI) / 180, latitude: (30.2568 * Math.PI) / 180, height: 1200, }), orientation: { heading: 0, pitch: -0.85, roll: 0 }, }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); await page.evaluate( () => @@ -152,26 +140,10 @@ try { requestAnimationFrame(() => requestAnimationFrame(resolve)), ), ); - const settled = await page - .waitForFunction( - () => { - const primitives = window.__godsEyeView.viewer.scene.primitives; - for (let i = 0; i < primitives.length; i++) { - const primitive = primitives.get(i); - if ( - typeof primitive.tilesLoaded === 'boolean' && - !primitive.tilesLoaded - ) - return false; - } - return true; - }, - { timeout: 60_000 }, - ) - .then( - () => true, - () => false, - ); + const settled = await whenTilesLoaded(page, { timeoutMs: 60_000 }).then( + () => true, + () => false, + ); check('visible tile content settles before visual captures', settled); fs.mkdirSync('qa-shots/map-source-controls', { recursive: true }); await page.screenshot({ path: 'qa-shots/map-source-controls/desktop.png' }); @@ -206,7 +178,7 @@ try { check('no uncaught browser errors', errors.length === 0); if (errors.length) console.log(JSON.stringify(errors)); } finally { - await browser.close(); + await closeBrowser(browser); } console.log(`RESULT: ${failures} failures`); process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-map-source-tray.mjs b/scripts/qa-map-source-tray.mjs index 87a756ed4..cd78c0dd8 100644 --- a/scripts/qa-map-source-tray.mjs +++ b/scripts/qa-map-source-tray.mjs @@ -1,9 +1,10 @@ #!/usr/bin/env node /** Focused browser proof for the responsive, accessible Map Source tray. */ +import { whenSettled, whenUiSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import puppeteer from 'puppeteer'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const shotsDir = process.env.QA_SHOTS_DIR || path.join(repoRoot, 'qa-shots', 'map-source-tray'); @@ -18,26 +19,12 @@ const headful = process.argv.includes('--headful'); // branch. const forceKeyless = process.argv.includes('--keyless') || process.env.QA_MAP_SOURCE_TRAY_KEYLESS === '1'; -const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH - || await puppeteer.executablePath().catch(() => null); -if (!executablePath || !fs.existsSync(executablePath)) { - throw new Error('Puppeteer Chrome for Testing is unavailable'); -} fs.mkdirSync(shotsDir, { recursive: true }); -const browser = await puppeteer.launch({ - headless: headful ? false : 'new', - executablePath, - // Metal is a macOS-only ANGLE backend: passing it on Windows or Linux makes - // WebGL initialization fail outright, so Cesium never constructs and this - // harness dies at the boot wait before a single assertion runs. - args: [ - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), - '--no-sandbox', - ], +const browser = await launchBrowser({ + headless: true, + args: [...(process.platform === 'darwin' ? ['--enable-gpu'] : []), '--no-sandbox'], }); const page = await browser.newPage(); const failures = []; @@ -154,7 +141,7 @@ const pressTabs = async (count, backwards = false) => { if (backwards) await page.keyboard.up('Shift'); } // The row scrolls smoothly when native Tab brings an edge button into view. - await new Promise((resolve) => setTimeout(resolve, 250)); + await whenUiSettled(page); }; // Phones (≤720px) present the dock's Presets inline in the More tab's sheet. // It opens from the keyboard, as a keyboard user would, so the focus it moves @@ -218,7 +205,7 @@ const tabTo = async (selector, { backwards = false, limit = 160 } = {}) => { for (let step = 1; step <= limit; step += 1) { await page.keyboard.press('Tab'); if (await page.evaluate((match) => document.activeElement?.matches(match), selector)) { - await new Promise((resolve) => setTimeout(resolve, 250)); + await whenUiSettled(page); return { reached: true, steps: step }; } } @@ -304,10 +291,7 @@ try { // tray assertion into a mission or voice action in a pristine browser. await page.goto(`${appUrl}/?welcome=0`, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 60_000 }); - await page.waitForFunction( - () => document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 60_000 }, - ); + await whenSettled(page); const presentation = await page.evaluate(() => ({ ids: [...document.querySelectorAll('.map-stack-chip')].map((chip) => chip.dataset.stackId), @@ -1103,9 +1087,9 @@ try { const clickTileThenLeave = async (stackId) => { await page.evaluate(() => window.__godsEyeView.styleManager .setPanelCollapsed('control-panel', false, { explicit: true })); - await new Promise((resolve) => setTimeout(resolve, 240)); + await whenUiSettled(page); await page.click(`[data-stack-id="${stackId}"]`); - await new Promise((resolve) => setTimeout(resolve, 120)); + await whenUiSettled(page); const afterClick = await readTrayState(); await page.mouse.move(20, 20); // leave the dock entirely → pointerleave // Past the 420 ms unpinned close delay with room for a slow frame. @@ -1121,7 +1105,7 @@ try { await setControlPanelPinned(false); await page.evaluate(() => window.__godsEyeView.styleManager .setPanelCollapsed('control-panel', true, { explicit: true })); - await new Promise((resolve) => setTimeout(resolve, 200)); + await whenUiSettled(page); await page.focus('#control-panel-toggle'); await page.keyboard.press('Enter'); // opens and hands focus to the active tile const selectedForHold = await page.evaluate(() => window.__godsEyeView.styleManager.mapStackController.getActiveId()); @@ -1136,7 +1120,7 @@ try { await page.keyboard.press('Tab'); if (activeIsLastTile) await page.keyboard.up('Shift'); await page.keyboard.press('Enter'); // activate it from the keyboard - await new Promise((resolve) => setTimeout(resolve, 200)); + await whenUiSettled(page); const keyboardAfterActivate = await page.evaluate(() => ({ focusedStack: document.activeElement?.dataset?.stackId || null, isChip: !!document.activeElement?.classList?.contains('map-stack-chip'), @@ -1148,7 +1132,7 @@ try { return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; }); await page.mouse.move(chipPoint.x, chipPoint.y); - await new Promise((resolve) => setTimeout(resolve, 120)); + await whenUiSettled(page); await page.mouse.move(20, 20); await new Promise((resolve) => setTimeout(resolve, 1000)); const keyboardHold = await readTrayState(); @@ -1179,7 +1163,7 @@ try { await page.mouse.move(pinApproach.x, pinApproach.y); await page.evaluate(() => window.__godsEyeView.styleManager .setPanelCollapsed('control-panel', false, { explicit: true })); - await new Promise((resolve) => setTimeout(resolve, 240)); + await whenUiSettled(page); check( 'a tile click does not exempt the unpinned tray from mouse-away auto-dismiss', dismissAfterTileClick.afterClick.collapsed === false @@ -1205,7 +1189,7 @@ try { await checkResponsiveStyleFocus(1000); await page.setViewport({ width: 620, height: 900, deviceScaleFactor: 1 }); - await new Promise((resolve) => setTimeout(resolve, 180)); + await whenUiSettled(page); await waitForShellSettled(); await openPhoneMoreSheet(); const at620 = await trayMetrics(); @@ -1222,7 +1206,7 @@ try { await checkResponsiveStyleFocus(620); await page.setViewport({ width: 480, height: 900, deviceScaleFactor: 1 }); - await new Promise((resolve) => setTimeout(resolve, 180)); + await whenUiSettled(page); const at480 = await trayMetrics(); const allRectsInside = at480.chips.every((chip) => ( chip.left >= 0 && chip.right <= at480.viewport.width @@ -1294,10 +1278,7 @@ try { timeout: 60_000, }); await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 60_000 }); - await page.waitForFunction( - () => document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 60_000 }, - ); + await whenSettled(page); } await page.waitForFunction( () => window.__godsEyeView.styleManager.mapStackController.getState()?.status !== 'switching', @@ -1429,7 +1410,7 @@ try { await page.keyboard.press('Enter'); await page.focus('#control-panel-toggle'); await page.keyboard.press('Enter'); - await new Promise((resolve) => setTimeout(resolve, 400)); + await whenUiSettled(page); const pinnedSibling = await locationState(); check('Location: pinned tray survives sibling opening', pinnedSibling.pinned && pinnedSibling.locationExpanded === 'true' @@ -1449,14 +1430,14 @@ try { const searchTab = '#mobile-tab-bar [data-mobile-sheet="search"]'; await page.focus(searchTab); await page.keyboard.press('Enter'); - await new Promise((resolve) => setTimeout(resolve, 250)); + await whenUiSettled(page); const field = await focusMetrics('#location-search'); check(`Search: ${width} px keyboard opens the sheet on the location field`, field.focusedId === 'location-search' && field.visible && field.insideViewport, JSON.stringify(field)); await page.screenshot({ path: path.join(shotsDir, `${width}-search-open.png`) }); await page.keyboard.press('Escape'); - await new Promise((resolve) => setTimeout(resolve, 250)); + await whenUiSettled(page); const tab = await focusMetrics(searchTab); const closedSheet = await page.evaluate(() => !document.body.dataset.mobileSheet); check(`Search: ${width} px Escape closes the sheet with a visible ring on its tab`, @@ -1747,7 +1728,7 @@ try { check('no new page or console errors', consoleErrors.length === 0, consoleErrors.join(' | ')); } finally { - await browser.close(); + await closeBrowser(browser); } if (failures.length) { diff --git a/scripts/qa-military-names.mjs b/scripts/qa-military-names.mjs index fa84bbdf7..aec7d0d87 100644 --- a/scripts/qa-military-names.mjs +++ b/scripts/qa-military-names.mjs @@ -1,8 +1,10 @@ #!/usr/bin/env node +import { evaluateWithGeo } from './qa/geometry.mjs'; /** Named military areas: continent performance, close identities and a recorded zoom. */ +import { whenSettled, whenFramesDrawn } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; -import puppeteer from 'puppeteer'; import { checkInstallationPan, checkInstallationSelection, @@ -34,14 +36,10 @@ const result = { video: null, }; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, protocolTimeout: 180000, - args: [ - '--use-angle=metal', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - ], + args: ['--disable-background-timer-throttling', '--disable-renderer-backgrounding'], }); let recorder; try { @@ -69,10 +67,10 @@ try { }); await page.waitForFunction( () => - window.__godsEyeView?.dataManager && - document.getElementById('loading-screen')?.classList.contains('hidden'), + window.__godsEyeView?.dataManager, { timeout: 120000 }, ); + await whenSettled(page); await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, ); @@ -92,11 +90,10 @@ try { for (const [id] of g.dataManager.layers) await g.dataManager.setEnabled(id, false); g.styleManager.setDetection({ enabled: false }); - g.viewer.scene.screenSpaceCameraController.enableCollisionDetection = false; }); assert.equal(result.packs.length, 0, 'names pack stays unloaded at startup'); result.renderer = await page.evaluate(() => { - const gl = window.__godsEyeView.viewer.scene.context._gl; + const gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2'); const ext = gl.getExtension('WEBGL_debug_renderer_info'); return ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) @@ -111,7 +108,7 @@ try { .module.getStats(), ); const settle = async () => { - await sleep(500); + await whenFramesDrawn(page); await page.waitForFunction( () => { const s = window.__godsEyeView.dataManager.layers @@ -121,7 +118,7 @@ try { }, { timeout: 60000 }, ); - await sleep(1200); + await whenFramesDrawn(page); }; const us = { lon: -100, lat: 39, height: 6500000, pitch: -90 }; await moveCamera(page, us, 0); @@ -213,7 +210,7 @@ try { await page.evaluate(() => window.__godsEyeView.styleManager.setCleanView(true), ); - await sleep(300); + await whenFramesDrawn(page); result.cleanUi = await page.evaluate(async () => { const host = (await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine); const root = document.getElementById('world-overlay-root'); @@ -278,7 +275,7 @@ try { const screen = new C.Cartesian2(); window.__installationFrames = []; window.__stopInstallationFrames = - g.viewer.scene.postRender.addEventListener(() => { + window.__godsEyeView.engine.frames.postRender.addEventListener(() => { const stats = layer.getStats(); const markers = [], labels = [], @@ -286,11 +283,7 @@ try { layer.visitNamedMarkers((id, point) => { const rect = getPaintRect('military-installations', id); if (rect) rects.push(rect); - const p = C.SceneTransforms.worldToWindowCoordinates( - g.viewer.scene, - point.position, - screen, - ); + const p = window.__godsEyeView.engine.projector.worldToWindow(point.position, screen); if ( point.show && p && @@ -316,7 +309,7 @@ try { ) overlaps++; } - const altitude = g.viewer.camera.positionCartographic.height; + const altitude = window.__godsEyeView.engine.camera.pose().cartographic.height; window.__installationFrames.push({ t: performance.now(), altitude, @@ -331,7 +324,7 @@ try { }); }); const clipStart = await pageNow(page); - await sleep(1200); + await whenFramesDrawn(page); for (const view of [ { lon: -116, lat: 35, height: 1500000, pitch: -85 }, { lon: -117.2, lat: 33, height: 200000, pitch: -80 }, @@ -381,11 +374,11 @@ try { for (const [key, expected, lon, lat, height] of sites) { await moveCamera(page, { lon, lat, height, pitch: -80 }, 1.5); await settle(); - const snapshot = await page.evaluate( - (lat, lon) => { + const snapshot = await evaluateWithGeo(page, + async (qaGeo, lat, lon) => { const g = window.__godsEyeView; const layer = g.dataManager.layers.get('military-installations').module; - const position = g.viewer.scene.globe.ellipsoid.cartographicToCartesian( + const position = qaGeo.cartographic.toCartesian( { longitude: (lon * Math.PI) / 180, latitude: (lat * Math.PI) / 180, @@ -466,7 +459,7 @@ try { ); assert.match(result.cardText, /Fort Hood/); assert.match(result.cardText, /TA-25/); - await sleep(1800); + await whenFramesDrawn(page); await page.$eval( '#military-awareness-panel .military-awareness-names', (el) => { @@ -481,5 +474,5 @@ try { if (recorder) result.video = await recorder.stop().catch((e) => ({ error: e.message })); await fs.writeFile(`${dir}/result.json`, JSON.stringify(result, null, 2)); - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-nepal-media-playback.mjs b/scripts/qa-nepal-media-playback.mjs index 87dc5d64f..c4d24bf56 100644 --- a/scripts/qa-nepal-media-playback.mjs +++ b/scripts/qa-nepal-media-playback.mjs @@ -1,18 +1,19 @@ #!/usr/bin/env node // Composed-app media policy/lifecycle checks. Pinokio UA is a policy fixture, // not native-host acceptance; the YouTube SDK clock below is deterministic. +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; const output = path.resolve( process.env.QA_OUTPUT_DIR || 'qa-shots/nepal-media', ); fs.mkdirSync(output, { recursive: true }); -const browser = await puppeteer.launch({ - headless: process.env.QA_HEADFUL !== '1', - args: process.env.QA_HEADFUL === '1' ? ['--use-angle=metal'] : [], +const browser = await launchBrowser({ + headless: true, + args: process.env.QA_HEADFUL === '1' ? [] : [], }); let assertions = 0; const check = (name, value) => { @@ -84,12 +85,10 @@ try { `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, { waitUntil: 'domcontentloaded' }, ); - await page.waitForFunction( - () => - window.__godsEyeView?.sceneDirector && - document.querySelector('#loading-screen')?.classList.contains('hidden'), - { timeout: 60000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.sceneDirector, { + timeout: 60000, + }); + await whenSettled(page); const result = await page.evaluate(async (pinokio) => { const d = window.__godsEyeView.sceneDirector; const scene = d._project.scenes.find((s) => /Nepal/.test(s.title)); @@ -200,5 +199,5 @@ try { } console.log(`PASS: ${assertions} assertions`); } finally { - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-openfreemap-same-tile.mjs b/scripts/qa-openfreemap-same-tile.mjs index 1ce44cc95..617408a11 100644 --- a/scripts/qa-openfreemap-same-tile.mjs +++ b/scripts/qa-openfreemap-same-tile.mjs @@ -17,10 +17,11 @@ * projection must fit one frame; Traffic does no outline projection and the * warm ask reuses it. Traffic activation Long Tasks are reported separately. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; -import puppeteer from 'puppeteer'; const args = process.argv.slice(2); const valueAfter = (flag, fallback) => { @@ -155,19 +156,13 @@ function workEvidence(entries, label) { await fs.mkdir(path.dirname(OUTPUT), { recursive: true }); -const browser = await puppeteer.launch({ - headless: args.includes('--headful') ? false : 'new', +const browser = await launchBrowser({ + headless: true, protocolTimeout: 240_000, - ...(process.env.PUPPETEER_EXECUTABLE_PATH - ? { executablePath: process.env.PUPPETEER_EXECUTABLE_PATH } - : {}), args: [ '--no-sandbox', '--disable-setuid-sandbox', - '--use-gl=angle', - ...(args.includes('--headful') - ? [] - : ['--use-angle=swiftshader', '--enable-unsafe-swiftshader']), + ...(args.includes('--headful') ? [] : ['--enable-unsafe-swiftshader']), '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', @@ -251,15 +246,15 @@ async function settleOutline(page, label) { } if (!annotation || annotation.pendingOutline) throw new Error(`${label}: outline did not settle`); - const { scene } = window.__godsEyeView.viewer; await new Promise((resolve) => { const timer = setTimeout(resolve, 1500); - const remove = scene.postRender.addEventListener(() => { - clearTimeout(timer); - remove(); - resolve(); - }); - scene.requestRender(); + const remove = + window.__godsEyeView.engine.frames.postRender.addEventListener(() => { + clearTimeout(timer); + remove(); + resolve(); + }); + window.__godsEyeView.engine.render.request(); }); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)), @@ -414,13 +409,13 @@ try { }); await page.waitForFunction( () => - window.__godsEyeView?.viewer && + window.__godsEyeView?.engine && window.__godsEyeView?.dataManager && window.__gevVoiceCommands?.runner && - window.__gevAnnotations && - document.getElementById('loading-screen')?.classList.contains('hidden'), + window.__gevAnnotations, { timeout: 120_000, polling: 250 }, ); + await whenSettled(page); await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, ); @@ -428,10 +423,10 @@ try { window.__godsEyeView.styleManager.setDetection({ enabled: false }), ); await page.evaluate(async (target) => { - const { viewer, dataManager } = window.__godsEyeView; + const { dataManager } = window.__godsEyeView; await dataManager.setEnabled('traffic', false); - viewer.camera.cancelFlight(); - viewer.camera.setView({ + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ destination: window.__CESIUM__.Cartesian3.fromDegrees( target.longitude, target.latitude, @@ -443,11 +438,11 @@ try { roll: 0, }, }); - viewer.camera.moveEnd.raiseEvent(); + window.__godsEyeView.engine.camera.moveEnd.raiseEvent(); window.__gevAnnotations.clear(); performance.clearMeasures(); }, TARGET); - await sleep(750); + await whenSettled(page); if (run === 0) { phase = 'overlap'; @@ -469,7 +464,7 @@ try { await sleep(100); await heldTargetRequest.continue(); const overlap = await settleOutline(page, 'QA Capitol overlap'); - await sleep(750); + await whenSettled(page); const trafficStats = await page.evaluate(() => window.__godsEyeView.dataManager.layers .get('traffic') @@ -538,7 +533,7 @@ try { }, { timeout: 180_000, polling: 250 }, ); - await sleep(1500); + await whenSettled(page); const trafficMeasures = relevantMeasures( await page.evaluate(() => performance.getEntriesByType('measure').map((entry) => ({ @@ -582,7 +577,7 @@ try { phase = 'outline-cold'; const cold = await driveOutline(page, `QA Capitol cold ${run}`); - await sleep(750); + await whenSettled(page); cold.measures = relevantMeasures(cold.measures); cold.work = workEvidence(cold.measures, 'cold'); cold.network = network.filter( @@ -596,7 +591,7 @@ try { await page.evaluate(() => window.__gevAnnotations.clear()); phase = 'outline-warm'; const warm = await driveOutline(page, `QA Capitol warm ${run}`); - await sleep(750); + await whenSettled(page); warm.measures = relevantMeasures(warm.measures); warm.work = workEvidence(warm.measures, 'warm'); warm.network = network.filter( @@ -674,7 +669,7 @@ try { assert.equal(warmDecodes.length, 0, 'decoded-cache hit performs no decode'); report.renderer ||= await page.evaluate(() => { - const gl = window.__godsEyeView.viewer.scene.context._gl; + const gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2'); const debug = gl.getExtension('WEBGL_debug_renderer_info'); return debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) @@ -849,5 +844,5 @@ try { process.exitCode = 1; } finally { await fs.writeFile(OUTPUT, `${JSON.stringify(report, null, 2)}\n`); - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-overlay-baseline.mjs b/scripts/qa-overlay-baseline.mjs index 719bfb09c..f26839306 100644 --- a/scripts/qa-overlay-baseline.mjs +++ b/scripts/qa-overlay-baseline.mjs @@ -1,4 +1,7 @@ #!/usr/bin/env node +import { chromeExecutablePath } from './qa-chrome.mjs'; +// Cesium-only probes: native entity, DataSource and primitive counts independently audit Cesium ownership. +import { whenSettled, whenFramesDrawn } from './qa/ready.mjs'; /** * Phase 0 world-overlay baseline harness. * @@ -25,13 +28,13 @@ * and primitives (none of them a layer's own any more). */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import puppeteer from 'puppeteer'; import { fromDegrees } from '../src/geo/cartesian3.js'; -const CHROME_EXECUTABLE = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const CHROME_EXECUTABLE = chromeExecutablePath(); const DEFAULT_URL = 'http://localhost:4176'; const VIEWPORT = Object.freeze({ width: 1440, height: 900 }); const SAMPLE_MS = 5_000; @@ -366,20 +369,19 @@ async function waitForApp(page) { () => Boolean(window.__godsEyeView?.engine && window.__godsEyeView?.dataManager), { timeout: 60_000 }, ); + await whenSettled(page); await page.evaluate(() => { - const { engine, viewer } = window.__godsEyeView; + const { engine } = window.__godsEyeView; try { engine.camera.cancelFlight(); } catch { /* no flight */ } - try { viewer?.scene.tweens?.removeAll?.(); } catch { /* no tween collection */ } }); - await sleep(1_250); + await whenFramesDrawn(page); } async function setCamera(page, camera) { if (!camera) return; await page.evaluate(([destination, heading, pitch]) => { - const { engine, viewer } = window.__godsEyeView; + const { engine } = window.__godsEyeView; engine.camera.cancelFlight(); - viewer?.scene.tweens?.removeAll?.(); engine.camera.setView({ destination, orientation: { heading, pitch, roll: 0 }, @@ -587,7 +589,7 @@ async function prepareCctv(page, heightM) { }, heightM); if (!camera) return 'CCTV catalog did not expose a geolocated camera'; await setCamera(page, camera); - await sleep(7_000); + await whenFramesDrawn(page); return null; } @@ -600,14 +602,14 @@ async function prepareFirms(page) { }); if (!result.fire) return `FIRMS unavailable (${result.stats.error || 'no detections'})`; await setCamera(page, [result.fire.longitude, result.fire.latitude, 60_000, 0, -1.45]); - await sleep(4_000); + await whenFramesDrawn(page); return null; } async function prepareVessels(page) { const state = await readLayerState(page, 'ais-live-vessels'); if (!(state?.stats?.count > 0)) return `AIS unavailable (${state?.stats?.error || 'no live vessels'})`; - await sleep(2_500); + await whenFramesDrawn(page); return null; } @@ -621,7 +623,7 @@ async function prepareDetection(page, densityPct) { densityPct: density, }), densityPct); if (!control?.ok) return `Detection control failed (${control?.error || 'unknown error'})`; - await sleep(2_000); + await whenFramesDrawn(page); return null; } @@ -650,7 +652,7 @@ async function prepareTrackedFlight(page, cockpit) { } catch { return 'Civil tracking did not create a tracked entity'; } - await sleep(2_000); + await whenFramesDrawn(page); if (!cockpit) return null; const entryAvailable = await page.evaluate(() => { const button = document.getElementById('cockpit-entry'); @@ -664,7 +666,7 @@ async function prepareTrackedFlight(page, cockpit) { } catch { return 'Cockpit entry control did not enter cockpit mode'; } - await sleep(2_000); + await whenFramesDrawn(page); return null; } @@ -683,7 +685,7 @@ async function prepareMission(page) { } catch { return 'Mission selection did not reach the layer probe'; } - await sleep(2_000); + await whenFramesDrawn(page); return null; } @@ -703,7 +705,7 @@ async function prepareScene(page, scene) { if (!skipReason && scene.detectionDensity) skipReason = await prepareDetection(page, scene.detectionDensity); if (!skipReason && scene.trackedFlight) skipReason = await prepareTrackedFlight(page, scene.cockpit); if (!skipReason && scene.missionSelected) skipReason = await prepareMission(page); - await sleep(DEFAULT_SETTLE_MS); + await whenFramesDrawn(page); return { layerActivations, skipReason }; } @@ -868,7 +870,7 @@ async function runScene(browser, scene, shotsDir) { const motion = await samplePhase(page, true); const afterMotionShot = await captureShot(page, shotsDir, scene.id, 'after-motion'); if (afterMotionShot) result.screenshots.push(afterMotionShot); - await sleep(BETWEEN_PHASE_SETTLE_MS); + await whenFramesDrawn(page); const rest = await samplePhase(page, false); result.samples = { motion, rest }; result.entityInventoryAfter = await readEntityInventory(page); @@ -904,9 +906,6 @@ function unavailableRenderer(renderer) { } async function main() { - if (!fs.existsSync(CHROME_EXECUTABLE)) { - throw new Error(`Required Chrome executable not found: ${CHROME_EXECUTABLE}`); - } if (!Number.isFinite(DPR) || DPR <= 0) throw new Error(`Invalid --dpr value: ${DPR}`); const scenes = selectedScenes(); if (DIST_DIR && (!fs.existsSync(DIST_DIR) || !fs.statSync(DIST_DIR).isDirectory())) { @@ -917,18 +916,9 @@ async function main() { if (!response.ok) throw new Error(`Dev server unavailable at ${APP_URL}: ${response.status || response.statusText}`); } - const browser = await puppeteer.launch({ - executablePath: CHROME_EXECUTABLE, - headless: HEADFUL ? false : 'new', - args: [ - ...(process.env.QA_ANGLE ? ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE}`] : HARDWARE_GPU ? [] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']), - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - `--window-size=${VIEWPORT.width},${VIEWPORT.height}`, - ], + const browser = await launchBrowser({ + headless: true, + args: [...(process.env.QA_ANGLE ? [] : HARDWARE_GPU ? [] : ['--enable-unsafe-swiftshader']), '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', `--window-size=${VIEWPORT.width},${VIEWPORT.height}`], }); const run = { generatedAt: new Date().toISOString(), context: null, scenes: [] }; @@ -974,13 +964,9 @@ async function main() { webglRenderer: browserContext.webglRenderer, }, viewport: { ...VIEWPORT, dpr: browserContext.dpr }, - gpuMode: process.env.QA_ANGLE || (HARDWARE_GPU ? 'hardware' : 'swiftshader'), + gpuMode: process.env.QA_ANGLE || 'metal', chromeExecutable: CHROME_EXECUTABLE, - chromeArgs: process.env.QA_ANGLE - ? ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE}`, '--no-sandbox'] - : HARDWARE_GPU - ? ['--no-sandbox'] - : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader', '--no-sandbox'], + chromeArgs: browser.process()?.spawnargs.slice(1), sampleMs: SAMPLE_MS, captureMode: DIST_DIR ? `static-dist:${path.resolve(DIST_DIR)}` : 'dev-server', }; @@ -992,7 +978,7 @@ async function main() { printScene(result); } } finally { - await browser.close(); + await closeBrowser(browser); } run.summary = { diff --git a/scripts/qa-overpass-offload.mjs b/scripts/qa-overpass-offload.mjs index b7181c0f5..8b9b3447b 100644 --- a/scripts/qa-overpass-offload.mjs +++ b/scripts/qa-overpass-offload.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +// Cesium-only probes: native primitives and globe instrumentation isolate Cesium paint and offload cost. +import { evaluateWithGeo } from './qa/geometry.mjs'; /** * qa-overpass-offload.mjs — keyless/keyed source replacement acceptance gate. * @@ -25,10 +27,11 @@ * Screenshots and a JSON result go to qa-shots/ (gitignored). Exits nonzero on * a failed assertion. Works against keyed or keyless dev servers. */ +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; -import puppeteer from 'puppeteer'; import { tilesForBounds } from '../src/data/tomtomTiles.js'; import { trafficDetailBounds } from '../src/layers/traffic/source.js'; @@ -55,19 +58,13 @@ const profile = args.includes('--profile'); const coverage = args.includes('--coverage'); const shots = path.resolve('qa-shots'); await fs.mkdir(shots, { recursive: true }); -const browser = await puppeteer.launch({ - headless: !args.includes('--headful'), +const browser = await launchBrowser({ + headless: true, protocolTimeout: 300_000, - ...(process.env.PUPPETEER_EXECUTABLE_PATH - ? { executablePath: process.env.PUPPETEER_EXECUTABLE_PATH } - : {}), args: [ '--no-sandbox', '--disable-setuid-sandbox', - '--use-gl=angle', - ...(args.includes('--software-gl') - ? ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'] - : []), + ...(args.includes('--software-gl') ? ['--enable-unsafe-swiftshader'] : []), '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', @@ -346,14 +343,10 @@ try { timeout: 60_000, }); await page.waitForFunction( - () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + () => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager, { timeout: 60_000, polling: 500 }, ); - await page.waitForFunction( - () => - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 60_000, polling: 250 }, - ); + await whenSettled(page); // Startup restoration owns its camera flight until the loading cover is gone. await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, @@ -362,7 +355,7 @@ try { window.__godsEyeView.styleManager.setDetection({ enabled: false }), ); result.renderer = await page.evaluate(() => { - const gl = window.__godsEyeView.viewer.scene.context._gl; + const gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2'); const debug = gl.getExtension('WEBGL_debug_renderer_info'); return debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) @@ -374,13 +367,13 @@ try { { timeout: 10_000 }, ); async function fly(lat, lon, height = 2000, heading = 0, pitch = -75) { - await page.evaluate( - async (view) => { - const { viewer } = window.__godsEyeView; - viewer.camera.cancelFlight(); + await evaluateWithGeo( + page, + async (qaGeo, view) => { + window.__godsEyeView.engine.camera.cancelFlight(); await new Promise((resolve) => - viewer.camera.flyTo({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + window.__godsEyeView.engine.camera.flyTo({ + destination: qaGeo.cartographic.toCartesian({ latitude: (view.lat * Math.PI) / 180, longitude: (view.lon * Math.PI) / 180, height: view.height, @@ -400,22 +393,7 @@ try { ); } async function settleTiles() { - await page.waitForFunction( - () => { - const { scene } = window.__godsEyeView.viewer; - if (scene.globe.show) return scene.globe.tilesLoaded; - let found = false; - for (let i = 0; i < scene.primitives.length; i++) { - const primitive = scene.primitives.get(i); - if (!primitive.show || typeof primitive.tilesLoaded !== 'boolean') - continue; - found = true; - if (!primitive.tilesLoaded) return false; - } - return found; - }, - { timeout: 90_000, polling: 500 }, - ); + await whenTilesLoaded(page, { timeoutMs: 90_000 }); } async function shot(name) { await settleTiles(); @@ -440,30 +418,35 @@ try { const milliseconds = await page.evaluate( (allowEmpty) => new Promise((resolve, reject) => { - const { viewer, dataManager } = window.__godsEyeView; + const { dataManager } = window.__godsEyeView; const start = performance.now(); const cleanup = () => { remove(); clearTimeout(timer); }; - const remove = viewer.scene.postRender.addEventListener(() => { - const stats = dataManager.layers.get('traffic').module.getStats(); - if (stats.count <= 0) { - if ( - allowEmpty && - !stats.loading && - stats.lastUpdate && - performance.now() - start > 500 - ) { + const remove = + window.__godsEyeView.engine.frames.postRender.addEventListener( + () => { + const stats = dataManager.layers + .get('traffic') + .module.getStats(); + if (stats.count <= 0) { + if ( + allowEmpty && + !stats.loading && + stats.lastUpdate && + performance.now() - start > 500 + ) { + cleanup(); + resolve(null); + } + return; + } + const elapsed = performance.now() - start; cleanup(); - resolve(null); - } - return; - } - const elapsed = performance.now() - start; - cleanup(); - resolve(elapsed); - }); + resolve(elapsed); + }, + ); const timer = setTimeout(() => { cleanup(); reject(new Error('No traffic dots within 180 seconds')); @@ -551,23 +534,24 @@ try { .map((p) => C.Cartesian3.clone(p.position)), ); for (const position of positions) { - const screen = C.SceneTransforms.worldToWindowCoordinates( - scene, - position, - ); + const screen = + window.__godsEyeView.engine.projector.worldToWindow(position); if ( !screen || screen.x < 0 || - screen.x >= scene.canvas.clientWidth || + screen.x >= window.__godsEyeView.engine.host.canvas.clientWidth || screen.y < 0 || - screen.y >= scene.canvas.clientHeight + screen.y >= window.__godsEyeView.engine.host.canvas.clientHeight ) continue; inView++; const carto = C.Cartographic.fromCartesian(position); const height = scene.globe.show - ? scene.globe.getHeight(carto) - : scene.sampleHeight(carto, collections); + ? window.__godsEyeView.engine.surface.terrainHeight(carto) + : window.__godsEyeView.engine.surface.sampleHeight( + carto, + collections, + ); if (Number.isFinite(height) && Math.abs(height) <= 9000) { sampled++; const delta = carto.height - height; @@ -645,13 +629,13 @@ try { ); view.layers = await waitForSources(); view.actualCamera = await page.evaluate(() => { - const camera = window.__godsEyeView.viewer.camera; - const position = camera.positionCartographic; + const position = window.__godsEyeView.engine.camera.pose().cartographic; return { lat: (position.latitude * 180) / Math.PI, lon: (position.longitude * 180) / Math.PI, height: position.height, - pitch: (camera.pitch * 180) / Math.PI, + pitch: + (window.__godsEyeView.engine.camera.pose().pitch * 180) / Math.PI, }; }); assert.ok( @@ -736,14 +720,16 @@ try { surface.stats.mode === 'live' ? mode : 'osm', ); const actualCamera = await page.evaluate(() => { - const camera = window.__godsEyeView.viewer.camera; - const p = camera.positionCartographic; + const p = window.__godsEyeView.engine.camera.pose().cartographic; return { lat: (p.latitude * 180) / Math.PI, lon: (p.longitude * 180) / Math.PI, height: p.height, - pitch: (camera.pitch * 180) / Math.PI, - heading: (camera.heading * 180) / Math.PI, + pitch: + (window.__godsEyeView.engine.camera.pose().pitch * 180) / Math.PI, + heading: + (window.__godsEyeView.engine.camera.pose().heading * 180) / + Math.PI, }; }); for (const [key, expected, tolerance] of [ @@ -868,9 +854,7 @@ try { } } else if (pan) { const sessionBudget = 32; // <= 0.54% of the default 6,000/day upstream budget. - await page.evaluate(() => { - window.__godsEyeView.viewer.scene.screenSpaceCameraController.enableCollisionDetection = false; - }); + await page.evaluate(() => {}); beginView('pan-start'); await fly(30.2672, -97.7431, 450, 0, -35); await enableTrafficTimed('pan-start'); @@ -992,9 +976,7 @@ try { // A continent jump can temporarily use the previous city's terrain height // for collision avoidance (São Paulo -> Austin raises a 450 m target). // Pin the prescribed camera fixtures so the revisit covers identical tiles. - await page.evaluate(() => { - window.__godsEyeView.viewer.scene.screenSpaceCameraController.enableCollisionDetection = false; - }); + await page.evaluate(() => {}); for (const city of [ ['austin', 30.2672, -97.7431], ['london', 51.5074, -0.1278], @@ -1043,7 +1025,7 @@ try { { timeout: 120_000, polling: 500 }, ); const austin = await page.evaluate(() => { - const { viewer, dataManager } = window.__godsEyeView; + const { dataManager } = window.__godsEyeView; const traffic = dataManager.layers.get('traffic').module.getStats(); const alpr = dataManager.layers.get('alpr-cameras').module.getStats(); // The camera badges: the layer's sprite mover sets (normal and @@ -1059,7 +1041,8 @@ try { alpr, cameraEntities, sourceLabel: traffic.loadingLabel, - cameraHeight: viewer.camera.positionCartographic.height, + cameraHeight: + window.__godsEyeView.engine.camera.pose().cartographic.height, }; }); result.traffic = austin.traffic; @@ -1219,14 +1202,10 @@ try { waitUntil: 'domcontentloaded', timeout: 60_000, }); - await page.waitForFunction( - () => - window.__godsEyeView?.dataManager && - document - .getElementById('loading-screen') - ?.classList.contains('hidden'), - { timeout: 90_000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.dataManager, { + timeout: 90_000, + }); + await whenSettled(page); await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, ); @@ -1282,9 +1261,9 @@ try { { timeout: 120_000, polling: 500 }, before, ); - result.military = await page.evaluate(() => { - const { viewer, dataManager } = window.__godsEyeView; - const center = viewer.scene.globe.ellipsoid.cartographicToCartesian({ + result.military = await evaluateWithGeo(page, async (qaGeo) => { + const { dataManager } = window.__godsEyeView; + const center = qaGeo.cartographic.toCartesian({ latitude: (30.314 * Math.PI) / 180, longitude: (-97.763 * Math.PI) / 180, height: 0, @@ -1367,7 +1346,7 @@ try { priorFort, ); result.fortCavazos = await page.evaluate(() => { - const { viewer, dataManager } = window.__godsEyeView; + const { dataManager } = window.__godsEyeView; // Drawn site discs (context handles, see Camp Mabry) plus the named // markers the stats count. let markers = 0, @@ -1533,5 +1512,5 @@ try { path.join(shots, 'overpass-offload-result.json'), JSON.stringify(result, null, 2), ); - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 407847195..ea755aa94 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +// Cesium-only probes: native flight interception and entity sentinels verify Cesium camera ownership. +import { evaluateWithGeo } from './qa/geometry.mjs'; /** * Deterministic browser proof for the Radio companion layer. * @@ -11,10 +13,11 @@ * * Run: node scripts/qa-radio.mjs --url http://localhost:4193 */ +import { whenSettled, whenFramesDrawn } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import puppeteer from 'puppeteer'; import { stations } from './fixtures/layers/radio.mjs'; import { isExpectedPreviewSetupError } from './qa-browserEvidence.mjs'; @@ -36,15 +39,6 @@ const RADIO_URL = new URL(APP_URL); RADIO_URL.searchParams.set('welcome', '0'); const HEADFUL = args.includes('--headful'); -const chromeCandidates = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -const chrome = chromeCandidates.find((candidate) => { - try { return fs.existsSync(candidate); } catch { return false; } -}); const results = []; @@ -86,14 +80,13 @@ async function restoreRadioPanels(page, prior) { // not on wall-clock sleeps, particularly with software rendering. async function settleRadioFrames(page) { await page.evaluate(() => new Promise((resolve, reject) => { - const scene = window.__godsEyeView.viewer.scene; let remaining = 4; const timeout = setTimeout(() => { remove(); reject(new Error('Radio rendered-frame deadline exceeded')); }, 10_000); - const remove = scene.postRender.addEventListener(() => { + const remove = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { if (--remaining === 0) { remove(); clearTimeout(timeout); resolve(); } - else requestAnimationFrame(() => scene.requestRender()); + else requestAnimationFrame(() => window.__godsEyeView.engine.render.request()); }); - scene.requestRender(); + window.__godsEyeView.engine.render.request(); })); } @@ -105,17 +98,9 @@ async function main() { process.exit(2); } fs.mkdirSync(SHOTS_DIR, { recursive: true }); - const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', - ...(chrome ? { executablePath: chrome } : {}), - args: [ - '--no-sandbox', '--disable-setuid-sandbox', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), - '--disable-dev-shm-usage', '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', '--window-size=1440,900', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1440,900'], }); try { @@ -370,17 +355,15 @@ async function main() { await page.goto(RADIO_URL.href, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await page.waitForFunction(() => window.__godsEyeView?.dataManager, { timeout: 60_000 }); + await whenSettled(page); await page.waitForFunction(() => window.__godsEyeView?.styleManager?._dataManager?.layers?.has('radio'), { timeout: 60_000 }); - await page.waitForFunction( - () => document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 60_000 }, - ); + await whenSettled(page); await page.waitForFunction(() => ( typeof window.__gevQaRegisterLayer === 'function' && typeof window.__gevQaUnregisterLayer === 'function' )); await page.evaluate(() => { - window.__godsEyeView.viewer.camera.cancelFlight(); + window.__godsEyeView.engine.camera.cancelFlight(); window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', true); window.__godsEyeView.styleManager.setPanelCollapsed('cctv-panel', true); window.__godsEyeView.styleManager.setPanelCollapsed('radio-panel', true); @@ -470,7 +453,7 @@ async function main() { const selectedBefore = module.getUIState().selected?.id || null; const playsBefore = window.__qaRadioPlayCalls.length; const camera = gev.viewer.camera; - const originalFlyTo = camera.flyTo; + const originalFlyTo = window.__godsEyeView.engine.camera.flyTo; const flyToCalls = []; camera.flyTo = (options) => flyToCalls.push(options); const beganTuning = module.beginTuning(); @@ -643,7 +626,7 @@ async function main() { const scroller = document.querySelector('#global-context-panel .global-context-panel-inner'); const radio = document.getElementById('radio-panel'); scroller.scrollTop = Math.max(0, radio.offsetTop - scroller.clientHeight + 70); - const camera = gev.viewer.camera.positionWC; + const camera = window.__godsEyeView.engine.camera.pose().position; return { scrollTop: scroller.scrollTop, pageY: window.scrollY, @@ -716,7 +699,7 @@ async function main() { const viewport = scroller.getBoundingClientRect(); const directory = document.querySelector('#radio-panel .radio-directory-row').getBoundingClientRect(); const play = document.getElementById('radio-play-btn').getBoundingClientRect(); - const camera = gev.viewer.camera.positionWC; + const camera = window.__godsEyeView.engine.camera.pose().position; return { scrollTop: scroller.scrollTop, pageY: window.scrollY, @@ -762,23 +745,21 @@ async function main() { { name: 'global', height: 24_200_000, limit: 16 }, { name: 'near', height: 500_000, limit: 32 }, ]) { - await page.evaluate(({ lon, height }) => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.cancelFlight(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo, { lon, height }) => { + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: lon * Math.PI / 180, latitude: 6 * Math.PI / 180, height, }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }, { lon: spec.lon, height: view.height }); - await sleep(700); + await whenFramesDrawn(page); await settleRadioFrames(page); const sample = await page.evaluate(async () => { - const viewer = window.__godsEyeView.viewer; const radio = window.__godsEyeView.dataManager.layers.get('radio').module; const drawn = window.__qaRadioDrawn; const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; @@ -806,11 +787,11 @@ async function main() { }); }; const timeout = setTimeout(finish, 500); - removePostRender = viewer.scene.postRender.addEventListener(() => { + removePostRender = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { clearTimeout(timeout); finish(); }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); }); singletonViews.push({ ...spec, ...view, ...sample }); @@ -1362,17 +1343,15 @@ async function main() { && allPalette.music?.[1] > allPalette.music?.[2], JSON.stringify(allPalette), ); - const clusterBadge = await page.evaluate(async () => { - const viewer = window.__godsEyeView.viewer; + const clusterBadge = await evaluateWithGeo(page, async (qaGeo) => { const drawn = window.__qaRadioDrawn; const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; - const ellipsoid = viewer.scene.globe.ellipsoid; // Start from another view, so the clusters at the view below are a new // cohort the host must solve (the old check forced one by switching // Cesium's clustering off and on). - viewer.camera.cancelFlight(); - viewer.camera.setView({ - destination: ellipsoid.cartographicToCartesian({ + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: 68 * Math.PI / 180, latitude: 7 * Math.PI / 180, height: 24_200_000, @@ -1382,15 +1361,15 @@ async function main() { await new Promise((resolve) => setTimeout(resolve, 500)); await drawn.frames(3); const baselineSolveRevision = getWorldOverlayDiagnostics().solveRevision; - viewer.camera.setView({ - destination: ellipsoid.cartographicToCartesian({ + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: -98 * Math.PI / 180, latitude: 38 * Math.PI / 180, height: 18_000_000, }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); const radio = window.__godsEyeView.dataManager.layers.get('radio').module; // The layer clusters on screen itself now (render/display.js, // ../screenClusters.js) into the radio-clusters point set; the cluster @@ -1405,7 +1384,7 @@ async function main() { removePostRender?.(); done({ rect: null, host: getWorldOverlayDiagnostics() }); }, 150); - removePostRender = viewer.scene.postRender.addEventListener(() => { + removePostRender = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { clearTimeout(timeout); removePostRender(); const host = getWorldOverlayDiagnostics(); @@ -1421,7 +1400,7 @@ async function main() { host, }); }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); } const position = typeof painted?.position === 'function' ? painted.position() : painted?.position; @@ -1451,30 +1430,27 @@ async function main() { && clusterBadge?.rect?.h > 0, JSON.stringify(clusterBadge), ); - await page.evaluate(async () => { - const viewer = window.__godsEyeView.viewer; - const ellipsoid = viewer.scene.globe.ellipsoid; - viewer.camera.cancelFlight(); - viewer.camera.setView({ - destination: ellipsoid.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo) => { + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: 68 * Math.PI / 180, latitude: 7 * Math.PI / 180, height: 24_200_000, }), orientation: { heading: 15 * Math.PI / 180, pitch: -Math.PI / 2, roll: 0 }, }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); await new Promise((resolve) => setTimeout(resolve, 500)); }); await settleRadioFrames(page); - const highGlobalClusterLabels = await page.evaluate(async () => { - const viewer = window.__godsEyeView.viewer; + const highGlobalClusterLabels = await evaluateWithGeo(page, async (qaGeo) => { const radio = window.__godsEyeView.dataManager.layers.get('radio').module; - const ellipsoid = viewer.scene.globe.ellipsoid; + const ellipsoid = qaGeo.ellipsoid.WGS84; const { getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js'); const source = radio.getOverlayDiagnostics(); - const cameraPosition = viewer.camera.positionWC; + const cameraPosition = window.__godsEyeView.engine.camera.pose().position; const cameraRadius = Math.hypot(cameraPosition.x, cameraPosition.y, cameraPosition.z); const horizonDistance = Math.sqrt(Math.max( 0, @@ -1486,8 +1462,8 @@ async function main() { const drawn = window.__qaRadioDrawn; const { getPaintRect: getOverlayPaintRect } = window.__gevWorldOverlay; let clusterPoint = null; - const width = viewer.canvas.clientWidth; - const height = viewer.canvas.clientHeight; + const width = window.__godsEyeView.engine.host.canvas.clientWidth; + const height = window.__godsEyeView.engine.host.canvas.clientHeight; const anchors = source.clusterIds .map((entryId) => getOverlayPaintRect('radio', entryId)?.entry?.position) .map((position) => drawn.canvasPoint(typeof position === 'function' ? position() : position)) @@ -1503,7 +1479,7 @@ async function main() { return { source, host: getWorldOverlayDiagnostics(), - altitude: viewer.camera.positionCartographic.height, + altitude: window.__godsEyeView.engine.camera.pose().cartographic.height, horizonDistance, horizonDistanceAlpha: distanceFade(horizonDistance, { maxDistance: 50_000_000 }), clusterPoint, @@ -1521,10 +1497,8 @@ async function main() { JSON.stringify(highGlobalClusterLabels), ); await page.screenshot({ path: path.join(SHOTS_DIR, 'high-global-all-labels.png') }); - const clusterContinuity = await page.evaluate(async () => { - const viewer = window.__godsEyeView.viewer; + const clusterContinuity = await evaluateWithGeo(page, async (qaGeo) => { const radio = window.__godsEyeView.dataManager.layers.get('radio').module; - const ellipsoid = viewer.scene.globe.ellipsoid; const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; const paintedClusters = () => new Promise((resolve) => { let removePostRender = null; @@ -1532,7 +1506,7 @@ async function main() { removePostRender?.(); resolve({ clusters: [], fadingCount: -1, host: null, source: null }); }, 500); - removePostRender = viewer.scene.postRender.addEventListener(() => { + removePostRender = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { clearTimeout(timeout); removePostRender(); const source = radio.getOverlayDiagnostics(); @@ -1553,20 +1527,20 @@ async function main() { source, }); }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); const longitudes = [-25, -24, -23, -22, -23, -24, -25]; const samples = []; for (const longitude of longitudes) { - viewer.camera.setView({ - destination: ellipsoid.cartographicToCartesian({ + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: longitude * Math.PI / 180, latitude: 38 * Math.PI / 180, height: 10_000_000, }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); for (const delay of [50, 100, 200, 350]) { await new Promise((resolve) => setTimeout(resolve, delay)); samples.push({ longitude, delay, ...(await paintedClusters()) }); @@ -1621,11 +1595,10 @@ async function main() { JSON.stringify(clusterContinuity), ); const directoryRefreshContinuity = await page.evaluate(async () => { - const viewer = window.__godsEyeView.viewer; const radio = window.__godsEyeView.dataManager.layers.get('radio').module; const before = radio.getOverlayDiagnostics().clusterMemberships; await radio.update(); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); await new Promise((resolve) => setTimeout(resolve, 900)); const after = radio.getOverlayDiagnostics().clusterMemberships; const beforeByMembership = new Map(before.map((entry) => [entry.membershipId, entry.entryId])); @@ -2451,7 +2424,7 @@ async function main() { await page.evaluate(() => { const camera = window.__godsEyeView.viewer.camera; window.__qaFirstPlayFlyToCalls = []; - window.__qaFirstPlayOriginalFlyTo = camera.flyTo; + window.__qaFirstPlayOriginalFlyTo = window.__godsEyeView.engine.camera.flyTo; camera.flyTo = (options) => window.__qaFirstPlayFlyToCalls.push(options); }); const firstPlayPanels = await revealRadioControls(page, 'radio-play-btn'); @@ -2496,11 +2469,10 @@ async function main() { check('first Play never moves the camera', firstPlay.flyToCalls === 0, `${firstPlay.flyToCalls} flyTo calls`); await page.select('#radio-filter', 'news'); - await page.evaluate(() => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.cancelFlight(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo) => { + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: -98 * Math.PI / 180, latitude: 38 * Math.PI / 180, height: 18_000_000, @@ -2508,7 +2480,7 @@ async function main() { orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); }); - await sleep(700); + await whenFramesDrawn(page); await settleRadioFrames(page); const newsClusterTarget = await page.evaluate(() => { // A drawn cluster: a painted cluster entry whose anchor picks a @@ -2555,7 +2527,7 @@ async function main() { ); await page.select('#radio-filter', 'weather'); - await sleep(700); + await whenFramesDrawn(page); const weatherCluster = await page.evaluate(() => { const radio = window.__godsEyeView.dataManager.layers.get('radio').module; return { texts: radio.getOverlayDiagnostics().clusterTexts }; @@ -2607,19 +2579,18 @@ async function main() { ); const idleHorizonScans = await page.evaluate(async () => { const radio = window.__godsEyeView.dataManager.layers.get('radio').module; - const camera = window.__godsEyeView.viewer.camera; - camera.cancelFlight(); + window.__godsEyeView.engine.camera.cancelFlight(); await new Promise((resolve) => setTimeout(resolve, 350)); const positionBefore = { - x: camera.positionWC.x, - y: camera.positionWC.y, - z: camera.positionWC.z, + x: window.__godsEyeView.engine.camera.pose().position.x, + y: window.__godsEyeView.engine.camera.pose().position.y, + z: window.__godsEyeView.engine.camera.pose().position.z, }; const before = radio.getStats().horizonScans; await new Promise((resolve) => setTimeout(resolve, 850)); - const dx = camera.positionWC.x - positionBefore.x; - const dy = camera.positionWC.y - positionBefore.y; - const dz = camera.positionWC.z - positionBefore.z; + const dx = window.__godsEyeView.engine.camera.pose().position.x - positionBefore.x; + const dy = window.__godsEyeView.engine.camera.pose().position.y - positionBefore.y; + const dz = window.__godsEyeView.engine.camera.pose().position.z - positionBefore.z; return { before, after: radio.getStats().horizonScans, @@ -2729,21 +2700,19 @@ async function main() { && slider.getBoundingClientRect().width > 0; }); - const horizon = await page.evaluate(async () => { - const viewer = window.__godsEyeView.viewer; - const ellipsoid = viewer.scene.globe.ellipsoid; + const horizon = await evaluateWithGeo(page, async (qaGeo) => { const drawn = window.__qaRadioDrawn; // What is drawn, through the engine's picks at every fixture station's // canvas point: a station behind the globe projects onto the canvas // too (the point sets draw without a depth test), so a pick of one // there means it was not culled. const setView = (lon, lat = 0) => { - viewer.camera.cancelFlight(); - viewer.camera.setView({ - destination: ellipsoid.cartographicToCartesian({ longitude: lon * Math.PI / 180, latitude: lat * Math.PI / 180, height: 2_000_000 }), + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: lon * Math.PI / 180, latitude: lat * Math.PI / 180, height: 2_000_000 }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }; setView(-54); await new Promise((resolve) => setTimeout(resolve, 500)); @@ -2894,7 +2863,7 @@ async function main() { buttons: type === 'pointercancel' || type === 'pointerup' ? 0 : 1, })); const camera = gev.viewer.camera; - const originalFlyTo = camera.flyTo; + const originalFlyTo = window.__godsEyeView.engine.camera.flyTo; const flyToCalls = []; camera.flyTo = (options) => flyToCalls.push(options); const before = { @@ -3208,7 +3177,7 @@ async function main() { await page.mouse.down(); await page.mouse.move(tunerCommitTarget.targetX, tunerCommitTarget.y, { steps: 12 }); await page.mouse.up(); - await sleep(650); + await whenFramesDrawn(page); const tunerCommit = await page.evaluate((target) => { const gev = window.__godsEyeView; const slider = document.getElementById('radio-tuner-slider'); @@ -3317,7 +3286,7 @@ async function main() { && sample.ratio >= microDragSamples[index - 1].ratio)), JSON.stringify(microDragSamples), ); - const viewportTuner = await page.evaluate(async (selectedId) => { + const viewportTuner = await evaluateWithGeo(page, async (qaGeo, selectedId) => { const gev = window.__godsEyeView; const style = gev.styleManager; const before = { @@ -3326,11 +3295,11 @@ async function main() { selectedIndex: style._radioControls._radioTunerStations.findIndex((station) => station.id === selectedId), needle: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')), }; - const current = gev.viewer.camera.positionCartographic; + const current = window.__godsEyeView.engine.camera.pose().cartographic; const oppositeLongitude = ((current.longitude + 2 * Math.PI) % (2 * Math.PI)) - Math.PI; - gev.viewer.canvas.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 1 })); - gev.viewer.camera.setView({ - destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({ + window.__godsEyeView.engine.host.canvas.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 1 })); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: oppositeLongitude, latitude: -current.latitude, height: 2_000_000, @@ -3373,7 +3342,7 @@ async function main() { }; }); await page.$eval('#radio-next-btn', (button) => button.click()); - await sleep(1500); + await whenFramesDrawn(page); const nextNeedleAfter = await page.evaluate(() => { const style = window.__godsEyeView.styleManager; const selectedId = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id; @@ -3568,10 +3537,9 @@ async function main() { && fullPoolNavigation.afterNextWrap === fullPoolNavigation.expectedNextWrap, JSON.stringify(fullPoolNavigation), ); - await page.evaluate(() => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo) => { + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: Math.PI, latitude: -2 * Math.PI / 180, height: 2_000_000, @@ -3579,7 +3547,7 @@ async function main() { orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); }); - await sleep(500); + await whenFramesDrawn(page); // Next left the Radio stopped; a release plays only from an active stream. await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' })); await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing'); @@ -3630,8 +3598,7 @@ async function main() { ); await page.select('#radio-filter', 'news'); - const markerTarget = await page.evaluate(async () => { - const viewer = window.__godsEyeView.viewer; + const markerTarget = await evaluateWithGeo(page, async (qaGeo) => { const radio = window.__godsEyeView.dataManager.layers.get('radio').module; const drawn = window.__qaRadioDrawn; // Radio clusters on screen in its own display now (no Cesium @@ -3645,26 +3612,26 @@ async function main() { origin: 'programmatic', }); } - viewer.camera.cancelFlight(); + window.__godsEyeView.engine.camera.cancelFlight(); const markerView = { - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + destination: qaGeo.cartographic.toCartesian({ longitude: -174 * Math.PI / 180, latitude: 6 * Math.PI / 180, height: 500_000, }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }; - viewer.camera.setView(markerView); + window.__godsEyeView.engine.camera.setView(markerView); await new Promise((resolve) => { let removePostRender = null; - removePostRender = viewer.scene.postRender.addEventListener(() => { + removePostRender = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { removePostRender?.(); resolve(); }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); radio.setFilter('news'); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); await new Promise((resolve) => setTimeout(resolve, 500)); const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; return new Promise((resolve) => { @@ -3677,13 +3644,13 @@ async function main() { resolve(result); }; const probe = () => { - const camera = viewer.camera.positionCartographic; + const camera = window.__godsEyeView.engine.camera.pose().cartographic; const longitude = camera.longitude * 180 / Math.PI; const latitude = camera.latitude * 180 / Math.PI; if (Math.abs(longitude + 174) > 0.5 || Math.abs(latitude - 6) > 0.5) { - viewer.camera.cancelFlight(); - viewer.camera.setView(markerView); - viewer.scene.requestRender(); + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView(markerView); + window.__godsEyeView.engine.render.request(); return; } const diagnostics = radio.getOverlayDiagnostics(); @@ -3726,16 +3693,16 @@ async function main() { overlayEntryCount: diagnostics.entryCount, worldOverlay: getWorldOverlayDiagnostics(), camera: { - longitude: viewer.camera.positionCartographic.longitude * 180 / Math.PI, - latitude: viewer.camera.positionCartographic.latitude * 180 / Math.PI, - height: viewer.camera.positionCartographic.height, + longitude: window.__godsEyeView.engine.camera.pose().cartographic.longitude * 180 / Math.PI, + latitude: window.__godsEyeView.engine.camera.pose().cartographic.latitude * 180 / Math.PI, + height: window.__godsEyeView.engine.camera.pose().cartographic.height, }, }; - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }; timeout = setTimeout(() => finish(lastObservation || { found: false, reason: 'no post-render probe' }), 8000); - removePostRender = viewer.scene.postRender.addEventListener(probe); - viewer.scene.requestRender(); + removePostRender = window.__godsEyeView.engine.frames.postRender.addEventListener(probe); + window.__godsEyeView.engine.render.request(); }); }); check('Radio exposes a visible marker for click-tolerance QA', markerTarget?.found === true, JSON.stringify(markerTarget)); @@ -3761,14 +3728,13 @@ async function main() { check('an offset click within 8px reliably selects the intended Radio dot', offsetSelected === markerTarget.id, offsetSelected); } - await page.evaluate(() => { + await evaluateWithGeo(page, async (qaGeo) => { const gev = window.__godsEyeView; - const camera = gev.viewer.camera; const radio = gev.dataManager.layers.get('radio').module; - camera.cancelFlight(); - gev.viewer.canvas.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 1 })); - camera.setView({ - destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({ + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.host.canvas.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 1 })); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: Math.PI, latitude: -2 * Math.PI / 180, height: 2_000_000, @@ -3792,12 +3758,12 @@ async function main() { window.__qaRadioFailedNearest = false; window.__qaRadioDelayNextFailure = true; window.__qaRadioFlyToCalls = []; - window.__qaRadioOriginalFlyTo = camera.flyTo; - window.__qaRadioCameraHeight = camera.positionCartographic.height; + window.__qaRadioOriginalFlyTo = window.__godsEyeView.engine.camera.flyTo; + window.__qaRadioCameraHeight = window.__godsEyeView.engine.camera.pose().cartographic.height; camera.flyTo = (options) => window.__qaRadioFlyToCalls.push(options); gev.dataManager.layers.get('flights').enabled = true; }); - await sleep(500); + await whenFramesDrawn(page); await page.evaluate(() => { const manager = window.__godsEyeView.styleManager; manager.setPanelCollapsed('global-context-panel', false); @@ -3805,7 +3771,7 @@ async function main() { }); await page.waitForSelector('#radio-next-btn', { visible: true }); await page.evaluate(() => { - window.__qaRadioCameraHeight = window.__godsEyeView.viewer.camera.positionCartographic.height; + window.__qaRadioCameraHeight = window.__godsEyeView.engine.camera.pose().cartographic.height; }); const radioOnlyNextDispatched = await page.evaluate(() => { const button = document.getElementById('radio-next-btn'); @@ -3815,10 +3781,9 @@ async function main() { }); if (!radioOnlyNextDispatched) throw new Error('Radio next button unavailable'); await page.waitForFunction(() => typeof window.__qaRejectRadioPlay === 'function'); - await page.evaluate(() => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo) => { + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: Math.PI, latitude: -2 * Math.PI / 180, height: 2_400_000, @@ -3828,12 +3793,11 @@ async function main() { window.__qaRejectRadioPlay(); }); await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing'); - const playing = await page.evaluate(async () => { + const playing = await evaluateWithGeo(page, async (qaGeo) => { const radio = window.__godsEyeView.dataManager.layers.get('radio').module; - const viewer = window.__godsEyeView.viewer; const { getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; const flightTargets = window.__qaRadioFlyToCalls.map(({ destination }) => { - const position = viewer.scene.globe.ellipsoid.cartesianToCartographic(destination); + const position = qaGeo.cartographic.fromCartesian(destination); return { lat: position.latitude * 180 / Math.PI, lon: position.longitude * 180 / Math.PI, @@ -3887,7 +3851,7 @@ async function main() { const run = async (ownerPrefix, buttonId) => { const owner = viewer.entities.add({ id: `qa:${ownerPrefix}:radio-camera-owner`, - position: viewer.camera.positionWC.clone(), + position: window.__godsEyeView.engine.camera.pose().position, }); owner.gevTrackedId = `${ownerPrefix}:qa-radio-camera-owner`; viewer.trackedEntity = owner; @@ -3903,7 +3867,7 @@ async function main() { afterStationId: radio.getUIState().selected?.id || null, flyToDelta: window.__qaRadioFlyToCalls.length - beforeFlyToCalls, trackedIdentityPreserved: viewer.trackedEntity === owner, - trackedId: viewer.trackedEntity?.gevTrackedId || null, + trackedId: window.__godsEyeView.engine.camera.following()?.label?.id || null, }; viewer.trackedEntity = undefined; viewer.entities.remove(owner); @@ -3937,7 +3901,7 @@ async function main() { const viewer = window.__godsEyeView.viewer; const owner = viewer.entities.add({ id: 'qa:flights:delayed-radio-fallback-owner', - position: viewer.camera.positionWC.clone(), + position: window.__godsEyeView.engine.camera.pose().position, }); owner.gevTrackedId = 'flights:qa-delayed-radio-fallback-owner'; viewer.trackedEntity = owner; @@ -3952,7 +3916,7 @@ async function main() { const result = { flyToDelta: window.__qaRadioFlyToCalls.length - beforeFlyToCalls, trackedIdentityPreserved: viewer.trackedEntity === owner, - trackedId: viewer.trackedEntity?.gevTrackedId || null, + trackedId: window.__godsEyeView.engine.camera.following()?.label?.id || null, selectedId: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id || null, }; viewer.trackedEntity = undefined; @@ -3968,10 +3932,9 @@ async function main() { && Boolean(delayedFallbackOwnership.selectedId), JSON.stringify(delayedFallbackOwnership), ); - await page.evaluate(() => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo) => { + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: Math.PI, latitude: -2 * Math.PI / 180, height: window.__qaRadioCameraHeight, @@ -3992,21 +3955,20 @@ async function main() { && playing.host.entriesBySource?.radio === playing.overlay.entryCount, JSON.stringify({ overlay: playing.overlay, host: playing.host.entriesBySource }), ); - const closeSelectedText = await page.evaluate(async () => { - const viewer = window.__godsEyeView.viewer; + const closeSelectedText = await evaluateWithGeo(page, async (qaGeo) => { const radio = window.__godsEyeView.dataManager.layers.get('radio').module; const selected = radio.getUIState().selected; window.__qaRadioVisualView = { - position: viewer.camera.positionWC.clone(), - direction: viewer.camera.directionWC.clone(), - up: viewer.camera.upWC.clone(), + position: window.__godsEyeView.engine.camera.pose().position, + direction: window.__godsEyeView.engine.camera.pose().direction, + up: window.__godsEyeView.engine.camera.pose().up, }; - const destination = viewer.scene.globe.ellipsoid.cartographicToCartesian({ + const destination = qaGeo.cartographic.toCartesian({ longitude: selected.lon * Math.PI / 180, latitude: selected.lat * Math.PI / 180, height: 5_000, }); - viewer.camera.setView({ + window.__godsEyeView.engine.camera.setView({ destination, orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); @@ -4033,7 +3995,7 @@ async function main() { removePostRender?.(); resolve({ rect: null, host: getWorldOverlayDiagnostics() }); }, 150); - removePostRender = viewer.scene.postRender.addEventListener(() => { + removePostRender = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { clearTimeout(timeout); removePostRender(); const rect = getOverlayPaintRect('radio', expectedEntryId); @@ -4042,7 +4004,7 @@ async function main() { host: getWorldOverlayDiagnostics(), }); }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); shown = selectedShown(); } @@ -4051,7 +4013,7 @@ async function main() { rect: painted?.rect || null, host: painted?.host || getWorldOverlayDiagnostics(), selectedEntityShow: shown, - cameraHeight: viewer.camera.positionCartographic.height, + cameraHeight: window.__godsEyeView.engine.camera.pose().cartographic.height, }; }); check( @@ -4062,13 +4024,11 @@ async function main() { JSON.stringify(closeSelectedText), ); await page.screenshot({ path: path.join(SHOTS_DIR, 'selected-label-desktop.png') }); - const highGlobalSelectedText = await page.evaluate(async () => { - const viewer = window.__godsEyeView.viewer; + const highGlobalSelectedText = await evaluateWithGeo(page, async (qaGeo) => { const radio = window.__godsEyeView.dataManager.layers.get('radio').module; const selected = radio.getUIState().selected; - const ellipsoid = viewer.scene.globe.ellipsoid; - viewer.camera.setView({ - destination: ellipsoid.cartographicToCartesian({ + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: (selected.lon + 10) * Math.PI / 180, latitude: selected.lat * Math.PI / 180, height: 5_000_000, @@ -4086,7 +4046,7 @@ async function main() { removePostRender?.(); resolve(null); }, 150); - removePostRender = viewer.scene.postRender.addEventListener(() => { + removePostRender = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { clearTimeout(timeout); removePostRender(); const painted = getOverlayPaintRect('radio', expectedEntryId); @@ -4097,7 +4057,7 @@ async function main() { h: painted.h, } : null); }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); } // The selected point and its bracket are instances of the @@ -4129,14 +4089,14 @@ async function main() { entityAnchor: entityAnchor ? { x: entityAnchor.x, y: entityAnchor.y } : null, entityDistance: entityPosition ? Math.hypot( - viewer.camera.positionWC.x - entityPosition.x, - viewer.camera.positionWC.y - entityPosition.y, - viewer.camera.positionWC.z - entityPosition.z, + window.__godsEyeView.engine.camera.pose().position.x - entityPosition.x, + window.__godsEyeView.engine.camera.pose().position.y - entityPosition.y, + window.__godsEyeView.engine.camera.pose().position.z - entityPosition.z, ) : null, overlay: radio.getOverlayDiagnostics(), host: getWorldOverlayDiagnostics(), - cameraHeight: viewer.camera.positionCartographic.height, + cameraHeight: window.__godsEyeView.engine.camera.pose().cartographic.height, }; }); check( @@ -4164,7 +4124,7 @@ async function main() { const viewer = window.__godsEyeView.viewer; window.__godsEyeView.styleManager.toggleCleanView(true); viewer.resize(); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); }); await page.screenshot({ path: path.join(SHOTS_DIR, 'selected-label-mobile.png') }); @@ -4173,19 +4133,18 @@ async function main() { const viewer = window.__godsEyeView.viewer; window.__godsEyeView.styleManager.toggleCleanView(false); viewer.resize(); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); await page.evaluate(() => { - const viewer = window.__godsEyeView.viewer; const view = window.__qaRadioVisualView; - viewer.camera.setView({ + window.__godsEyeView.engine.camera.setView({ destination: view.position, orientation: { direction: view.direction, up: view.up }, }); delete window.__qaRadioVisualView; - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); - await new Promise((resolve) => setTimeout(resolve, 300)); + await whenFramesDrawn(page); await page.evaluate(() => { window.__qaRadioFlyToCalls = []; @@ -4202,10 +4161,10 @@ async function main() { radioOnlyNextClicked, `clicked=${radioOnlyNextClicked}`, ); - const radioOnlyFocusCalls = await page.evaluate(() => { + const radioOnlyFocusCalls = await evaluateWithGeo(page, async (qaGeo) => { const calls = window.__qaRadioFlyToCalls.length; const heights = window.__qaRadioFlyToCalls.map((options) => ( - window.__godsEyeView.viewer.scene.globe.ellipsoid.cartesianToCartographic(options.destination).height + qaGeo.cartographic.fromCartesian(options.destination).height )); window.__godsEyeView.viewer.camera.flyTo = window.__qaRadioOriginalFlyTo; delete window.__qaRadioOriginalFlyTo; @@ -4218,19 +4177,19 @@ async function main() { JSON.stringify(radioOnlyFocusCalls), ); - const stagedGlobeNavigation = await page.evaluate(async () => { + const stagedGlobeNavigation = await evaluateWithGeo(page, async (qaGeo) => { const gev = window.__godsEyeView; const viewer = gev.viewer; const camera = viewer.camera; const radio = gev.dataManager.layers.get('radio').module; const stationIds = radio.getTunerStations(3).map((station) => station.id); if (stationIds.length < 3) throw new Error('Radio recenter QA requires three visible stations'); - const originalFlyTo = camera.flyTo; + const originalFlyTo = window.__godsEyeView.engine.camera.flyTo; const calls = []; camera.flyTo = (options) => calls.push(options); const setCamera = (height, pitchDegrees, headingDegrees = 38) => { - camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: -133.9483 * Math.PI / 180, latitude: 23.4741 * Math.PI / 180, height, @@ -4241,18 +4200,18 @@ async function main() { roll: 0, }, }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }; const completeFlight = (index) => { const flight = calls[index]; - camera.setView({ + window.__godsEyeView.engine.camera.setView({ destination: flight.destination, orientation: flight.orientation, }); flight.complete?.(); }; const describe = (flight) => { - const cartographic = viewer.scene.globe.ellipsoid.cartesianToCartographic(flight.destination); + const cartographic = qaGeo.cartographic.fromCartesian(flight.destination); return { lat: cartographic.latitude * 180 / Math.PI, lon: cartographic.longitude * 180 / Math.PI, @@ -4279,7 +4238,7 @@ async function main() { radio.cycleStation(1, { rotate: true, autoplay: false, stationIds }); const owner = viewer.entities.add({ id: 'qa:flights:radio-recenter-stage-owner', - position: viewer.camera.positionWC.clone(), + position: window.__godsEyeView.engine.camera.pose().position, }); owner.gevTrackedId = 'flights:qa-radio-recenter-stage-owner'; viewer.trackedEntity = owner; @@ -4509,7 +4468,7 @@ async function main() { ); await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('radio-panel', false)); - await sleep(350); + await whenFramesDrawn(page); await page.screenshot({ path: path.join(SHOTS_DIR, 'desktop.png') }); const desktop = await page.$eval('#radio-panel', (element) => { const rect = element.getBoundingClientRect(); @@ -4531,7 +4490,7 @@ async function main() { await page.click('#radio-play-btn'); await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing'); await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 1 }); - await sleep(500); + await whenFramesDrawn(page); await page.$eval('#radio-panel', (element) => element.scrollIntoView({ block: 'nearest' })); await page.screenshot({ path: path.join(SHOTS_DIR, 'mobile.png') }); const mobile = await page.$eval('#radio-panel', (element) => { @@ -4599,7 +4558,7 @@ async function main() { failedResponses.slice(0, 3).join(' | '), ); } finally { - await browser.close(); + await closeBrowser(browser); } const failures = results.filter((result) => !result.ok).length; diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index 89d60d96f..3f3f3115c 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -1,4 +1,7 @@ #!/usr/bin/env node +// Cesium-only probes: native imagery stacks and split position verify the Cesium imagery adapter. +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenUiSettled, whenSettled } from './qa/ready.mjs'; /** * Rendered acceptance for DATA ▸ Recent Imagery against the real NASA * endpoints (CMR catalog, GIBS tiles, Worldview snapshots): the rail panel, @@ -17,9 +20,9 @@ * 4 s ZOOM IN wait and 0.8 s height-stability tail (defaults: 2 s / 1.2 s). * Every wait is bounded. Without NASA access the run skips with code 0. */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; import { chromeExecutablePath } from './qa-chrome.mjs'; const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; @@ -51,21 +54,14 @@ if (!(await networkAvailable())) { process.exit(0); } -const browser = await puppeteer.launch({ - headless: !HEADFUL, - ...(HEADFUL - ? { - executablePath: chromeExecutablePath(), - } - : {}), +const browser = await launchBrowser({ + headless: true, args: [ '--no-sandbox', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', ...(HEADFUL ? ['--window-size=1280,860'] : []), - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), ], }); const page = await browser.newPage(); @@ -200,7 +196,7 @@ const state = () => : null, splitLeft: split ? split.getBoundingClientRect().left : null, splitPosition: gev.viewer.scene.splitPosition, - canvasWidth: gev.viewer.scene.canvas.clientWidth, + canvasWidth: window.__godsEyeView.engine.host.canvas.clientWidth, activeStack: qa.activeStack(), gibsOnGlobe: hosts.globe, gibsOnTileset: hosts.tileset, @@ -212,25 +208,27 @@ const hostsDetail = (s) => /** Put the camera straight down over a point at a height, no flight. */ const setView = (view) => - page.evaluate((target) => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.cancelFlight?.(); - viewer.scene.tweens?.removeAll?.(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ - longitude: (target.lon * Math.PI) / 180, - latitude: (target.lat * Math.PI) / 180, - height: target.height, - }), - orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, - }); - viewer.scene.requestRender(); - }, view); + evaluateWithGeo( + page, + async (qaGeo, target) => { + window.__godsEyeView.engine.camera.cancelFlight?.(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ + longitude: (target.lon * Math.PI) / 180, + latitude: (target.lat * Math.PI) / 180, + height: target.height, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + window.__godsEyeView.engine.render.request(); + }, + view, + ); /** Ground width of the camera's view rectangle, in km (0 without one). */ const viewWidthKm = () => page.evaluate(() => { - const rect = window.__godsEyeView.viewer.camera.computeViewRectangle?.(); + const rect = window.__godsEyeView.engine.camera.viewRectangle?.(); if (!rect) return 0; const lat = (rect.south + rect.north) / 2; return Math.abs(rect.east - rect.west) * 6371 * Math.cos(lat); @@ -279,8 +277,9 @@ async function probe() { const tilesSettled = () => until(() => { const gev = window.__godsEyeView; - if (gev.viewer.scene.globe.show) return gev.viewer.scene.globe.tilesLoaded; - return !gev.tileset || gev.tileset.tilesLoaded; + if (gev.viewer.scene.globe.show) + return window.__godsEyeView.engine.surface.tilesLoaded(); + return !gev.tileset || window.__godsEyeView.engine.surface.tilesLoaded(); }); const backOnPhotoreal = () => until(() => window.__riQa.activeStack() === 'photoreal'); @@ -334,7 +333,7 @@ const measure = () => let layoutBaseline = null; /** Every control's rect matches the baseline within 1 px. */ async function checkLayout(label) { - await wait(150); + await whenUiSettled(page); const now = await measure(); if (!layoutBaseline) { layoutBaseline = now; @@ -370,12 +369,13 @@ try { () => window.__gevRecentImagery && window.__godsEyeView && - document.getElementById('loading-screen')?.classList.contains('hidden'), + window.__godsEyeView.engine, null, 90_000, ); check('the app boots with the Recent Imagery handle', booted); if (!booted) throw new Error('boot timeout'); + await whenSettled(page); await page.evaluate(installPageHelpers); await setView(AUSTIN); @@ -586,10 +586,8 @@ try { changedColour = colour; break; } - await page.evaluate(() => - window.__godsEyeView.viewer.scene.requestRender(), - ); - await wait(1000); + await page.evaluate(() => window.__godsEyeView.engine.render.request()); + await whenUiSettled(page); } check( 'the rendered frame inside the box changes once the preview drapes', @@ -681,7 +679,7 @@ try { ); await checkLayout('pinned'); await page.keyboard.press(targetIndex > 0 ? 'ArrowLeft' : 'ArrowRight'); - await wait(700); + await whenUiSettled(page); current = await state(); check( 'with the image pinned, focus moves without changing the map', @@ -981,7 +979,7 @@ try { let refusalWidth = 0; for (const height of [400_000, 900_000, 1_800_000]) { await setView({ ...AUSTIN, height }); - await wait(300); + await whenUiSettled(page); refusalWidth = await viewWidthKm(); if (refusalWidth > 1_100) break; } @@ -1041,10 +1039,9 @@ try { await panelShot('05-refused-panel'); // ZOOM IN: straight down to the height whose view is 400 km wide. const fitHeight = await page.evaluate(() => { - const viewer = window.__godsEyeView.viewer; - const canvas = viewer.scene.canvas; + const canvas = window.__godsEyeView.engine.host.canvas; const halfWidth = - Math.tan(viewer.camera.frustum.fovy / 2) * + Math.tan(window.__godsEyeView.engine.camera.pose().fovy / 2) * (canvas.clientWidth / canvas.clientHeight); return Math.min(400, Math.max(5, 400 / (2 * halfWidth))) * 1000; }); @@ -1052,14 +1049,14 @@ try { const zoomed = await until( (target) => Math.abs( - window.__godsEyeView.viewer.camera.positionCartographic.height - target, + window.__godsEyeView.engine.camera.pose().cartographic.height - target, ) <= target * 0.01, fitHeight, ZOOM_TIMEOUT_MS, ); const zoomedHeight = await page.evaluate( - () => window.__godsEyeView.viewer.camera.positionCartographic.height, + () => window.__godsEyeView.engine.camera.pose().cartographic.height, ); check( `ZOOM IN reaches target within ${ZOOM_TIMEOUT_MS / 1_000} s`, @@ -1235,7 +1232,7 @@ try { }, id); const imageryPanel = 'recent-imagery-panel'; const collapsedNow = (await togglePanel(imageryPanel)) === false; - await wait(400); + await whenUiSettled(page); const headerOnly = await page.evaluate(() => ({ panel: document.getElementById('recent-imagery-panel')?.offsetHeight, body: document.getElementById('recent-imagery-panel-body')?.offsetHeight, @@ -1249,7 +1246,7 @@ try { JSON.stringify(headerOnly), ); const expandedAgain = await togglePanel(imageryPanel); - await wait(400); + await whenUiSettled(page); const bodyBack = await page.evaluate( () => document.getElementById('recent-imagery-panel-body')?.offsetHeight, ); @@ -1270,7 +1267,7 @@ try { cctv: document.getElementById('cctv-panel')?.offsetHeight, })), ); - await wait(100); + await whenUiSettled(page); } const tail = (key) => new Set(samples.slice(-HEIGHT_TAIL_SAMPLES).map((s) => s[key])); @@ -1315,7 +1312,7 @@ try { for (const height of [900, 700]) { const at = `(${height} px viewport)`; await page.setViewport({ width: 1280, height }); - await wait(600); + await whenUiSettled(page); check( `the CCTV panel expands from its launcher ${at}`, (await togglePanel('cctv-panel')) === true, @@ -1372,7 +1369,7 @@ try { ?.classList.contains('collapsed'), ); if (imageryExpanded) await togglePanel(imageryPanel); - await wait(400); + await whenUiSettled(page); const onScreen = await page.evaluate(() => Object.fromEntries( [ @@ -1389,7 +1386,7 @@ try { JSON.stringify(onScreen), ); await togglePanel(imageryPanel); - await wait(400); + await whenUiSettled(page); } // ── scroll preservation (700 px viewport, body scrollable) ─────────────── @@ -1397,7 +1394,7 @@ try { const header = document.querySelector('#ri-details .rail-card-header'); if (header?.getAttribute('aria-expanded') !== 'true') header?.click(); }); - await wait(300); + await whenUiSettled(page); const bodyScroll = () => page.evaluate(() => { const body = document.getElementById('recent-imagery-panel-body'); @@ -1424,7 +1421,7 @@ try { layer.setToolActive(true); layer.setToolActive(false); }); - await wait(300); + await whenUiSettled(page); const afterNotify = await bodyScroll(); check( 'the body scroll position survives alpha and state notifications', @@ -1434,7 +1431,7 @@ try { await focusStrip(); await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowRight'); - await wait(600); + await whenUiSettled(page); const afterArrows = await bodyScroll(); check( 'the body scroll position survives arrow navigation', @@ -1488,7 +1485,7 @@ try { error?.stack || error?.message || String(error), ); } finally { - await browser.close(); + await closeBrowser(browser); } console.log(`shots: ${shots.join(', ')}`); console.log(`RESULT: ${checks} checks, ${failures} failures`); diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs index bda4a407c..5ab995f7a 100644 --- a/scripts/qa-scene-controls.mjs +++ b/scripts/qa-scene-controls.mjs @@ -1,18 +1,17 @@ #!/usr/bin/env node /** Exercise installed Scene controls and real project/playback operations. */ +import { whenSettled, whenUiSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; const shots = path.resolve('qa-shots/scene-controls'); fs.mkdirSync(shots, { recursive: true }); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: [ '--no-sandbox', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), ], }); const page = await browser.newPage(); @@ -34,11 +33,10 @@ try { { waitUntil: 'domcontentloaded' }, ); await page.waitForFunction( - () => - window.__godsEyeView?.sceneDirector?._controls && - document.getElementById('loading-screen')?.classList.contains('hidden'), + () => window.__godsEyeView?.sceneDirector?._controls, { timeout: 60000 }, ); + await whenSettled(page); await page.evaluate(() => { window.__qaSceneState = []; window.__godsEyeView.sceneDirector.subscribe((notification) => @@ -294,6 +292,7 @@ try { ); await page.click('#scene-start-btn'); await page.waitForFunction(() => window.__godsEyeView.sceneDirector.running); + await whenSettled(page); await page.$eval('#scene-stop-btn', (button) => button.click()); await page.waitForFunction(() => !window.__godsEyeView.sceneDirector.running); check( @@ -364,7 +363,7 @@ try { await page.setViewport({ width: 390, height: 844 }); // Phones present Scenes in the Layers tab's sheet. await page.click('#mobile-tab-bar [data-mobile-sheet="layers"]'); - await new Promise((resolve) => setTimeout(resolve, 400)); + await whenUiSettled(page); await page.screenshot({ path: path.join(shots, 'narrow.png') }); check( 'Scene controls stay inside the narrow viewport', @@ -483,6 +482,6 @@ try { errors.length === 0, ); } finally { - await browser.close(); + await closeBrowser(browser); } if (failures) process.exitCode = 1; diff --git a/scripts/qa-scene-dialog-file-drop.mjs b/scripts/qa-scene-dialog-file-drop.mjs index d6806ff72..ec6af59f5 100644 --- a/scripts/qa-scene-dialog-file-drop.mjs +++ b/scripts/qa-scene-dialog-file-drop.mjs @@ -14,13 +14,13 @@ * Needs the repository's puppeteer (devDependencies). Exit status 0 when the * drop selects the file, 1 otherwise. */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import http from 'node:http'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import vue from '@vitejs/plugin-vue'; -import puppeteer from 'puppeteer'; import { createServer } from 'vite'; import { VUE_PLUGIN_OPTIONS } from '../build/vueOptions.js'; @@ -77,7 +77,7 @@ async function main() { }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const url = `http://127.0.0.1:${server.address().port}/`; - const browser = await puppeteer.launch({ headless: true }); + const browser = await launchBrowser({ headless: true }); try { const page = await browser.newPage(); await page.setViewport({ width: 1000, height: 800 }); @@ -100,7 +100,7 @@ async function main() { } console.log('OK: a file dropped on the visible control is selected'); } finally { - await browser.close(); + await closeBrowser(browser); server.close(); await vite.close(); await rm(dir, { recursive: true, force: true }); diff --git a/scripts/qa-scope-hud.mjs b/scripts/qa-scope-hud.mjs index 1607b2631..183dad60d 100644 --- a/scripts/qa-scope-hud.mjs +++ b/scripts/qa-scope-hud.mjs @@ -7,17 +7,16 @@ * feather 0/11/100/200, layout independence, * share/scene restore, and visible text backing including rotated metadata. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: [ '--no-sandbox', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), ], }); const page = await browser.newPage(); @@ -214,16 +213,7 @@ async function openApplication(url) { () => window.__godsEyeView?.styleManager?._applicationShortcuts, { timeout: 60_000 }, ); - await page.waitForFunction( - () => { - const cover = document.getElementById('loading-screen'); - return ( - cover?.classList.contains('hidden') && - Number(getComputedStyle(cover).opacity) === 0 - ); - }, - { timeout: 60_000 }, - ); + await whenSettled(page); } try { @@ -668,7 +658,7 @@ try { } check('runtime has no uncaught browser errors', errors.length === 0, errors); } finally { - await browser.close(); + await closeBrowser(browser); } console.log(`RESULT: ${failures} failures`); process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-segmented-controls.mjs b/scripts/qa-segmented-controls.mjs index d60b574fd..0398c942c 100644 --- a/scripts/qa-segmented-controls.mjs +++ b/scripts/qa-segmented-controls.mjs @@ -1,13 +1,12 @@ #!/usr/bin/env node /** Run against a dev server or built preview with QA_BASE_URL (default :4179). */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; -import puppeteer from 'puppeteer'; -const browser = await puppeteer.launch({ - executablePath: - process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()), +const browser = await launchBrowser({ headless: true, - args: ['--use-gl=angle', '--use-angle=swiftshader'], + args: [], }); const page = await browser.newPage(); const errors = []; @@ -30,11 +29,7 @@ try { () => window.__godsEyeView?.styleManager?._applicationShortcuts, { timeout: 60000 }, ); - await page.waitForFunction( - () => - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 60000 }, - ); + await whenSettled(page); // DISPLAY holds most segmented controls; Draw's shape group is in UTILITIES. await page.evaluate(() => { for (const panel of ['pp-toggles', 'utilities-panel']) @@ -347,7 +342,7 @@ try { await page.screenshot({ path: `${shots}/failure.png` }).catch(() => {}); throw error; } finally { - await browser.close(); + await closeBrowser(browser); } console.log(`RESULT: ${failures.length} failures`); process.exitCode = failures.length ? 1 : 0; diff --git a/scripts/qa-sky-model-lighting.mjs b/scripts/qa-sky-model-lighting.mjs index 69f727297..24dfdc61f 100644 --- a/scripts/qa-sky-model-lighting.mjs +++ b/scripts/qa-sky-model-lighting.mjs @@ -1,17 +1,18 @@ #!/usr/bin/env node +// Cesium-only probes: standalone native scenes validate Cesium model lighting. /** * Lit airplane regression: StarrySky must match a black environment baseline. * A white environment is a positive control: it must visibly brighten the * same model. Each case uses a fresh scene and dynamic environment map. * Usage: node scripts/qa-sky-model-lighting.mjs [--url http://localhost:4333/] */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; -import puppeteer from 'puppeteer'; const i = process.argv.indexOf('--url'); const base = i > 0 ? process.argv[i + 1] : 'http://localhost:4333/'; const url = new URL('/__qa_sky_model__', base); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: ['--no-sandbox', '--ignore-gpu-blocklist'], }); @@ -145,5 +146,5 @@ try { 'PASS: lit airplane matches black sky; white control is brighter', ); } finally { - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-status-badges.mjs b/scripts/qa-status-badges.mjs index 1d3b7347b..5746e15c9 100644 --- a/scripts/qa-status-badges.mjs +++ b/scripts/qa-status-badges.mjs @@ -1,11 +1,12 @@ #!/usr/bin/env node /** Badge acceptance on dev/preview. Camera state is a disposable controller fixture. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; -import puppeteer from 'puppeteer'; -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, - args: ['--use-gl=angle', '--use-angle=swiftshader'], + args: [], }); const page = await browser.newPage(); page.setDefaultNavigationTimeout(60000); @@ -31,11 +32,10 @@ try { { waitUntil: 'domcontentloaded' }, ); await page.waitForFunction( - () => - window.__godsEyeView?.styleManager?._cctvControls && - document.getElementById('loading-screen')?.classList.contains('hidden'), + () => window.__godsEyeView?.styleManager?._cctvControls, { timeout: 60000 }, ); + await whenSettled(page); await page.evaluate(async () => { const { dataManager, styleManager: manager } = window.__godsEyeView; await dataManager.setEnabled('wind', true); @@ -272,7 +272,7 @@ try { } check('no uncaught browser errors', errors.length === 0, errors); } finally { - await browser.close(); + await closeBrowser(browser); } console.log(`${checks} checks; ${failures} failures`); process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index a1b48fb92..91c366ddd 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -1,4 +1,7 @@ #!/usr/bin/env node +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import { blockPaidQaMaps } from './qa-map-policy.mjs'; /** * Browser QA for the Street Level layer against a running server: @@ -223,6 +226,7 @@ async function boot(page, url) { await page.waitForFunction(() => Boolean(window.__godsEyeView?.dataManager), { timeout: 150_000, }); + await whenSettled(page); await page.evaluate(() => document.querySelector('.first-run-explore')?.click(), ); @@ -338,8 +342,9 @@ async function assertKeyRequired(page) { const findPick = (page, prefix, { box = null, step = 3 } = {}) => page.evaluate( (want, area, stride) => { - const { scene } = window.__godsEyeView.viewer; - const rect = scene.canvas.getBoundingClientRect(); + const { engine } = window.__godsEyeView; + const rect = + window.__godsEyeView.engine.host.canvas.getBoundingClientRect(); const { x, y, w, h } = area || { x: 0, y: Math.round(rect.height / 2), @@ -348,7 +353,7 @@ const findPick = (page, prefix, { box = null, step = 3 } = {}) => }; for (let py = y; py < y + h; py += stride) for (let px = x; px < x + w; px += stride) { - const id = scene.pick({ x: px, y: py })?.id; + const id = engine.picker.pick({ x: px, y: py })?.id; if (typeof id === 'string' && id.startsWith(want)) return { x: rect.left + px, @@ -378,30 +383,33 @@ async function findPickUntil( for (;;) { const hit = await findPick(page, prefix, { box, step }); if (hit || Date.now() >= deadline) return hit; - await new Promise((resolve) => setTimeout(resolve, 500)); + await whenSettled(page); } } /** Park the camera at `at`; a late startup flight can move it. */ async function park(page, at = PARK) { for (let attempt = 0; attempt < 5; attempt++) { - const stayed = await page.evaluate(async (at) => { - const v = window.__godsEyeView.viewer; - v.camera.cancelFlight?.(); - const C = v.camera.positionCartographic.constructor; - v.camera.setView({ - destination: v.scene.globe.ellipsoid.cartographicToCartesian( - C.fromDegrees(at.lon, at.lat, at.height), - ), - orientation: { heading: 0, pitch: at.pitch, roll: 0 }, - }); - await new Promise((resolve) => setTimeout(resolve, 1500)); - const c = v.camera.positionCartographic; - return ( - Math.abs((c.longitude * 180) / Math.PI - at.lon) < 0.001 && - Math.abs((c.latitude * 180) / Math.PI - at.lat) < 0.001 - ); - }, at); + const stayed = await evaluateWithGeo( + page, + async (qaGeo, at) => { + window.__godsEyeView.engine.camera.cancelFlight?.(); + const C = qaGeo.cartographic; + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian( + C.fromDegrees(at.lon, at.lat, at.height), + ), + orientation: { heading: 0, pitch: at.pitch, roll: 0 }, + }); + await new Promise((resolve) => setTimeout(resolve, 1500)); + const c = window.__godsEyeView.engine.camera.pose().cartographic; + return ( + Math.abs((c.longitude * 180) / Math.PI - at.lon) < 0.001 && + Math.abs((c.latitude * 180) / Math.PI - at.lat) < 0.001 + ); + }, + at, + ); if (stayed) return; } throw new Error(`the camera would not stay parked at ${JSON.stringify(at)}`); @@ -414,14 +422,11 @@ async function main() { const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173'; const fixtures = args.includes('--fixtures'); const strict = args.includes('--strict'); - const browser = await puppeteer.launch({ + const browser = await launchBrowser({ headless: true, - executablePath: - process.env.PUPPETEER_EXECUTABLE_PATH || - (await puppeteer.executablePath()), defaultViewport: VIEWPORT, protocolTimeout: 300_000, - args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'], + args: ['--no-sandbox'], }); let count = 0; const skips = []; @@ -494,7 +499,7 @@ async function main() { box === 'column' ? await page.evaluate(() => { const { height, width } = - window.__godsEyeView.viewer.scene.canvas.getBoundingClientRect(); + window.__godsEyeView.engine.host.canvas.getBoundingClientRect(); return { x: Math.round(width * 0.3), y: 0, w: 1, h: height }; }) : null; @@ -560,7 +565,7 @@ async function main() { 'keyless server: the MAPILLARY chip explains its key instead of switching', async () => { await press(page, '#sl-provider-chips [data-chip-id="mapillary"]'); - await new Promise((resolve) => setTimeout(resolve, 500)); + await whenSettled(page); assert.ok(litAlone(await getUI(page), 'kartaview')); }, ); @@ -831,7 +836,7 @@ async function main() { if (dir) console.error(`failure evidence saved in ${dir}/`); throw error; } finally { - await browser.close(); + await closeBrowser(browser); } } diff --git a/scripts/qa-terrain-429.mjs b/scripts/qa-terrain-429.mjs index 8164e34b5..b3e27f0a0 100644 --- a/scripts/qa-terrain-429.mjs +++ b/scripts/qa-terrain-429.mjs @@ -1,4 +1,7 @@ #!/usr/bin/env node +// Cesium-only probes: native terrain-tree counters verify Cesium retry behavior. +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled, until } from './qa/ready.mjs'; /** * Keyless terrain throttling probe. * @@ -21,8 +24,8 @@ * Exits 1 when a throttled tile never recovered or a terrain tile is left in * Cesium's FAILED state. */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; -import puppeteer from 'puppeteer'; const appUrl = process.env.QA_BASE_URL || 'http://localhost:4173'; const argv = process.argv.slice(2); @@ -46,20 +49,10 @@ const headful = argv.includes('--headful'); const TERRAIN_HOST = 'terrain.reearth.land'; const TILE_PATTERN = /\/cesium-mesh\/ellipsoid\/\d+\/\d+\/\d+\.terrain/; -const executablePath = - process.env.PUPPETEER_EXECUTABLE_PATH || - (await puppeteer.executablePath().catch(() => null)); -if (!executablePath || !fs.existsSync(executablePath)) { - throw new Error('Puppeteer Chrome for Testing is unavailable'); -} - -const browser = await puppeteer.launch({ - headless: headful ? false : 'new', - executablePath, +const browser = await launchBrowser({ + headless: true, args: [ - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), '--no-sandbox', ], }); @@ -149,31 +142,17 @@ page.on('console', (message) => { }); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const waitFor = async (predicate, timeoutMs, label) => { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await predicate()) return; - await sleep(250); - } - throw new Error(`timed out waiting for ${label}`); -}; const setCamera = (lon, lat, height) => - page.evaluate( - ({ lon, lat, height }) => { - const viewer = window.__godsEyeView.viewer; + evaluateWithGeo( + page, + async (qaGeo, { lon, lat, height }) => { // The app's own startup flight would otherwise keep steering the camera // through the burst and turn the zoom-out into a zoom-in. - viewer.camera.cancelFlight(); - const cartographic = - viewer.camera.positionCartographic.constructor.fromDegrees( - lon, - lat, - height, - ); - viewer.camera.setView({ - destination: - viewer.scene.globe.ellipsoid.cartographicToCartesian(cartographic), + window.__godsEyeView.engine.camera.cancelFlight(); + const cartographic = qaGeo.cartographic.fromDegrees(lon, lat, height); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian(cartographic), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); window.__godsEyeView.requestRender?.(); @@ -183,7 +162,7 @@ const setCamera = (lon, lat, height) => const cameraHeight = () => page.evaluate( - () => window.__godsEyeView.viewer.camera.positionCartographic.height, + () => window.__godsEyeView.engine.camera.pose().cartographic.height, ); const globeState = () => @@ -203,7 +182,7 @@ const globeState = () => return { failed, loaded, - tilesLoaded: viewer.scene.globe.tilesLoaded, + tilesLoaded: window.__godsEyeView.engine.surface.tilesLoaded(), provider: viewer.terrainProvider?.constructor?.name, }; }); @@ -212,8 +191,9 @@ const burst = async () => { if (mode === 'zoomOut') { for (let i = 0; i < notches; i += 1) { await page.evaluate(() => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.zoomOut(viewer.camera.positionCartographic.height * 0.6); + window.__godsEyeView.engine.camera.zoomOut( + window.__godsEyeView.engine.camera.pose().cartographic.height * 0.6, + ); window.__godsEyeView.requestRender?.(); }); await sleep(40); @@ -230,14 +210,22 @@ const burst = async () => { try { await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }); - await page.waitForFunction(() => Boolean(window.__godsEyeView?.viewer), { + await page.waitForFunction(() => Boolean(window.__godsEyeView?.engine), { timeout: 60000, }); - await waitFor(() => layerJsonSeen, 60000, 'the Re:Earth layer.json fetch'); - await waitFor( + await whenSettled(page); + await until(() => layerJsonSeen, { + timeoutMs: 60000, + intervalMs: 250, + label: 'the Re:Earth layer.json fetch', + }); + await until( async () => (await globeState()).provider === 'CesiumTerrainProvider', - 30000, - 'the keyless terrain provider', + { + timeoutMs: 30000, + intervalMs: 250, + label: 'the keyless terrain provider', + }, ); const summaries = []; @@ -246,16 +234,16 @@ try { // overwrite the first setView on the very next frame. for (let attempt = 0; attempt < 5; attempt += 1) { await setCamera(-97.7431, 30.2672, 40000); - await sleep(1500); + await whenSettled(page); const height = await cameraHeight(); if (Math.abs(height - 40000) < 1000) break; } - await waitFor( - async () => (await globeState()).tilesLoaded, - 30000, - 'the 40 km view to settle', - ).catch(() => {}); - await sleep(2000); + await until(async () => (await globeState()).tilesLoaded, { + timeoutMs: 30000, + intervalMs: 250, + label: 'the 40 km view to settle', + }).catch(() => {}); + await whenSettled(page); const settledHeight = await cameraHeight(); if (Math.abs(settledHeight - 40000) > 1000) { throw new Error( @@ -358,5 +346,5 @@ try { ); process.exitCode = failures.length ? 1 : 0; } finally { - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-traffic-baseline.mjs b/scripts/qa-traffic-baseline.mjs index 7059bf438..3f8311bae 100644 --- a/scripts/qa-traffic-baseline.mjs +++ b/scripts/qa-traffic-baseline.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled, whenFramesDrawn } from './qa/ready.mjs'; /** * qa-traffic-baseline.mjs — Traffic Phase 0 causal-chain capture. * @@ -35,7 +37,7 @@ * the latency plan. */ -import puppeteer from 'puppeteer'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; const argv = process.argv.slice(2); @@ -53,20 +55,7 @@ const HEADFUL = argv.includes('--headful'); const TIMEOUT_MS = Math.max(10_000, getNumberOpt('--timeout-ms', 120_000)); const DEGRADED_DELAY_MS = Math.max(0, getNumberOpt('--degraded-delay-ms', 2500)); -const CHROME_EXECUTABLE_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -function findChromeExecutable() { - for (const candidate of CHROME_EXECUTABLE_CANDIDATES) { - try { if (fs.existsSync(candidate)) return candidate; } catch { /* ignore */ } - } - return null; -} const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -95,16 +84,15 @@ function debugUrl(rawUrl) { /** Position the camera and explicitly raise the boundaries the layer observes. */ async function moveCamera(page, view) { - return page.evaluate((v) => { + return evaluateWithGeo(page, async (qaGeo, v) => { const gev = window.__godsEyeView; const module = gev.dataManager.layers.get('traffic').module; const beforeLastUpdate = module.getStats().lastUpdate; const boundaryTime = performance.now(); - const ellipsoid = gev.viewer.scene.globe.ellipsoid; const radians = Math.PI / 180; - try { gev.viewer.camera.cancelFlight(); } catch { /* no active flight */ } - gev.viewer.camera.setView({ - destination: ellipsoid.cartographicToCartesian({ + try { window.__godsEyeView.engine.camera.cancelFlight(); } catch { /* no active flight */ } + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: v.lon * radians, latitude: v.lat * radians, height: v.height, @@ -119,8 +107,7 @@ async function moveCamera(page, view) { // explicitly makes the capture boundary deterministic across GPU speeds. // Pairing still flows through camera.changed's schedule-time anchor; the // synthetic moveEnd is emitted only as the same diagnostic used naturally. - gev.viewer.camera.changed.raiseEvent(); - gev.viewer.camera.moveEnd.raiseEvent(); + window.__godsEyeView.engine.camera.moveEnd.raiseEvent(); return { beforeLastUpdate, boundaryTime }; }, view); } @@ -144,7 +131,7 @@ async function waitForTrafficCapture(page, beforeLastUpdate, boundaryTime) { boundaryTime, ); // Let a final full-pass postRender land after `_fetching` flips false. - await sleep(150); + await whenFramesDrawn(page); } /** Return serializable Traffic User Timing measures created after a boundary. */ @@ -272,7 +259,7 @@ function rowsForCapture(capture) { /** Read the active WebGL renderer for the real-GPU/SwiftShader warning. */ function readRenderer(page) { return page.evaluate(() => { - const gl = window.__godsEyeView?.viewer?.scene?.context?._gl; + const gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2'); if (!gl) return 'unknown'; const extension = gl.getExtension('WEBGL_debug_renderer_info'); return extension @@ -285,7 +272,7 @@ async function main() { const url = debugUrl(APP_URL); console.log('\nTraffic Phase 0 — causal-chain baseline capture'); console.log(` App URL : ${url}`); - console.log(` Browser mode : ${HEADFUL ? 'headful (real GPU expected)' : 'headless SwiftShader (relative-only)'}`); + console.log(` Browser mode : headless (QA_ANGLE=${process.env.QA_ANGLE || 'metal'})`); console.log(` Degraded delay : ${DEGRADED_DELAY_MS ? `${DEGRADED_DELAY_MS} ms/request` : 'skipped'}`); try { @@ -296,19 +283,9 @@ async function main() { process.exit(2); } - const executablePath = findChromeExecutable(); - const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', - ...(executablePath ? { executablePath } : {}), - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--window-size=1440,900', - ...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']), - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1440,900', ...(HEADFUL ? [] : [])], }); let activeProxyDelayMs = 0; @@ -343,19 +320,19 @@ async function main() { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await page.waitForFunction( - () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + () => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager, { timeout: 60_000 }, ); + await whenSettled(page); // Disable all persisted overlays, park above traffic's activation ceiling, // then enable traffic without accidentally starting an unmeasured load. - await page.evaluate(async (highView) => { + await evaluateWithGeo(page, async (qaGeo, highView) => { const gev = window.__godsEyeView; await gev.dataManager.restoreEnabledLayerIds([]); - const ellipsoid = gev.viewer.scene.globe.ellipsoid; const radians = Math.PI / 180; - gev.viewer.camera.setView({ - destination: ellipsoid.cartographicToCartesian({ + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: highView.lon * radians, latitude: highView.lat * radians, height: 9000, @@ -363,10 +340,9 @@ async function main() { orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); await gev.dataManager.setEnabled('traffic', true); - gev.viewer.camera.changed.raiseEvent(); - gev.viewer.camera.moveEnd.raiseEvent(); + window.__godsEyeView.engine.camera.moveEnd.raiseEvent(); }, VIEWS.austin); - await sleep(500); + await whenFramesDrawn(page); const renderer = await readRenderer(page); const softwareRenderer = /swiftshader|software/i.test(renderer); @@ -386,22 +362,19 @@ async function main() { // Clear the last-bounds gate without clearing the module's road cache, then // revisit the byte-identical camera pose for a true client-cache hit. - await page.evaluate((view) => { - const gev = window.__godsEyeView; - const ellipsoid = gev.viewer.scene.globe.ellipsoid; + await evaluateWithGeo(page, async (qaGeo, view) => { const radians = Math.PI / 180; - gev.viewer.camera.setView({ - destination: ellipsoid.cartographicToCartesian({ + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: view.lon * radians, latitude: view.lat * radians, height: 9000, }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); - gev.viewer.camera.changed.raiseEvent(); - gev.viewer.camera.moveEnd.raiseEvent(); + window.__godsEyeView.engine.camera.moveEnd.raiseEvent(); }, VIEWS.austin); - await sleep(500); + await whenFramesDrawn(page); captures.push(await captureState(page, 'exact-viewport revisit', VIEWS.austin, (msValue) => { activeProxyDelayMs = msValue; @@ -467,7 +440,7 @@ async function main() { for (const error of consoleErrors.slice(0, 5)) console.log(` ${error}`); } } finally { - await browser.close(); + await closeBrowser(browser); } } diff --git a/scripts/qa-traffic-jamviz-ab.mjs b/scripts/qa-traffic-jamviz-ab.mjs index b236ab0b1..c93c42b36 100644 --- a/scripts/qa-traffic-jamviz-ab.mjs +++ b/scripts/qa-traffic-jamviz-ab.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled, whenTilesLoaded, whenFramesDrawn } from './qa/ready.mjs'; /** * qa-traffic-jamviz-ab.mjs — A/B screenshot capture for the jam-viz * congestion prototypes (feat/traffic-jam-viz). @@ -18,7 +20,7 @@ * Exits non-zero on harness failure (missing live mode, zero dots, etc.). */ -import puppeteer from 'puppeteer'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -53,50 +55,18 @@ const VIEWS = [ ]; const MODES = ['none', 'density', 'heatline', 'both']; -const CHROME_EXECUTABLE_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - // Prefer puppeteer's version-pinned Chrome-for-Testing over the system - // Chrome: /Applications auto-updates underneath the harnesses, and its - // software-GL behavior shifts across majors (system Chrome 150 blew the - // tile-gated drain budget under SwiftShader on 2026-07-30 — six - // false-negative qa-cctv-v2 runs against a healthy build). A deterministic - // pinned browser beats the newest one for regression harnesses. - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -const findChromeExecutable = () => - CHROME_EXECUTABLE_CANDIDATES.find((c) => { try { return fs.existsSync(c); } catch { return false; } }) || null; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** Same pattern as qa-cctv-v2: wait for the 3D tileset to settle for crisp shots. */ -function waitForTilesLoaded(page, timeoutMs) { - return page.waitForFunction( - () => { - const prims = window.__godsEyeView.viewer.scene.primitives; - for (let i = 0; i < prims.length; i++) { - const p = prims.get(i); - if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') { - return p.tilesLoaded === true; - } - } - return true; - }, - { timeout: timeoutMs } - ).then(() => true).catch(() => false); -} /** Teleport to a view (cancelling any camera flight first). */ function setView(page, v) { - return page.evaluate((view) => { - const gev = window.__godsEyeView; - try { gev.viewer.camera.cancelFlight(); } catch { /* no flight */ } - const ell = gev.viewer.scene.globe.ellipsoid; + return evaluateWithGeo(page, async (qaGeo, view) => { + try { window.__godsEyeView.engine.camera.cancelFlight(); } catch { /* no flight */ } const d2r = Math.PI / 180; - gev.viewer.camera.setView({ - destination: ell.cartographicToCartesian({ + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: view.lon * d2r, latitude: view.lat * d2r, height: view.height, }), orientation: { @@ -155,15 +125,9 @@ async function main() { } fs.mkdirSync(OUT_DIR, { recursive: true }); - const browser = await puppeteer.launch({ - headless: 'new', - ...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}), - args: [ - '--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader', - '--disable-dev-shm-usage', '--disable-web-security', - '--disable-background-timer-throttling', '--disable-renderer-backgrounding', - '--window-size=1600,900', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-web-security', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1600,900'], }); const manifest = []; @@ -174,11 +138,12 @@ async function main() { console.log('Loading app...'); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForFunction( - () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + () => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager, { timeout: 60000 }, ); + await whenSettled(page); await page.evaluate(() => window.__godsEyeView.dataManager.setEnabled('traffic', true)); - await sleep(1500); + await whenFramesDrawn(page); let firstView = true; for (const view of VIEWS) { @@ -203,7 +168,7 @@ async function main() { ); await setView(page, view); const stats = await waitForFreshRender(page, since); - await waitForTilesLoaded(page, firstView ? 30000 : 15000); + await whenTilesLoaded(page, { timeoutMs: firstView ? 30000 : 15000 }).then(() => true).catch(() => false); await sleep(1400); // flow-recolor race + heat-line pulse mid-bright const final = await page.evaluate(() => { @@ -249,7 +214,7 @@ async function main() { exitCode = 3; } finally { fs.writeFileSync(path.join(OUT_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2)); - await browser.close(); + await closeBrowser(browser); } console.log(`\nShots + manifest.json → ${OUT_DIR}`); process.exit(exitCode); diff --git a/scripts/qa-traffic-motion.mjs b/scripts/qa-traffic-motion.mjs index 6973d0d99..cbbb14811 100644 --- a/scripts/qa-traffic-motion.mjs +++ b/scripts/qa-traffic-motion.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +// Cesium-only probes: native primitive inspection independently measures the Cesium road paint. +import { evaluateWithGeo } from './qa/geometry.mjs'; /** * Traffic motion acceptance: real-GPU Puppeteer, stable identities, stationary * camera holds and timestamped CDP JPEGs encoded with ffmpeg. No tile mocks. @@ -9,10 +11,11 @@ * --journey follows a neighborhood-to-city route with layer toggles, then * checks Capitol/greenbelt/parking/corridor views against a 1.5 s limit. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; -import puppeteer from 'puppeteer'; const args = process.argv.slice(2); if (args.includes('--help')) { console.log( @@ -37,11 +40,10 @@ fs.mkdirSync(root, { recursive: true }); const revision = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8', }).trim(); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, protocolTimeout: 300000, args: [ - '--use-angle=metal', '--window-size=1280,800', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', @@ -84,12 +86,10 @@ try { u.searchParams.set('trafficRoads', mode); u.searchParams.set('trafficDebug', '1'); await page.goto(u.href, { waitUntil: 'domcontentloaded', timeout: 60000 }); - await page.waitForFunction( - () => - window.__godsEyeView?.dataManager && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 120000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.dataManager, { + timeout: 120000, + }); + await whenSettled(page); await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, ); @@ -99,7 +99,6 @@ try { g.styleManager.setDetection({ enabled: false }); for (const [id] of g.dataManager.layers) g.dataManager.setEnabled(id, false); - g.viewer.scene.screenSpaceCameraController.enableCollisionDetection = false; }); const identity = await page.evaluate(() => ({ title: document.title, @@ -159,10 +158,10 @@ try { clock.style.cssText = 'position:fixed;bottom:4px;left:38%;padding:3px 8px;background:#000c;color:#fff;font:12px monospace;z-index:999999;pointer-events:none'; document.body.append(clock); - viewer.camera.moveEnd.addEventListener(() => + window.__godsEyeView.engine.camera.moveEnd.addEventListener(() => window.__motion.moveEnds.push(performance.now()), ); - scene.postRender.addEventListener(() => { + window.__godsEyeView.engine.frames.postRender.addEventListener(() => { if (!window.__motion.active) return; discoverCollections(); const now = performance.now(), @@ -174,7 +173,9 @@ try { : ` | camera stopped +${((now - window.__motion.holdStart) / 1000).toFixed(1)}s`); const visit = (id, roadId, point) => { if (!point.show) return; - const s = scene.cartesianToCanvasCoordinates(point.position); + const s = window.__godsEyeView.engine.projector.worldToWindow( + point.position, + ); if (s && s.x >= 0 && s.y >= 0 && s.x <= 1280 && s.y <= 800) points.set(id, { x: s.x, y: s.y, roadId }); }; @@ -268,21 +269,19 @@ try { }); }, baseline); const fly = (lat, lon, height, pitch, duration, heading = 20) => - page.evaluate( - async (v) => { - const { viewer } = window.__godsEyeView; + evaluateWithGeo( + page, + async (qaGeo, v) => { window.__motion.label = 'camera moving'; window.__motion.holdStart = null; - viewer.camera.cancelFlight(); + window.__godsEyeView.engine.camera.cancelFlight(); await new Promise((resolve) => - viewer.camera.flyTo({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian( - { - latitude: (v.lat * Math.PI) / 180, - longitude: (v.lon * Math.PI) / 180, - height: v.height, - }, - ), + window.__godsEyeView.engine.camera.flyTo({ + destination: qaGeo.cartographic.toCartesian({ + latitude: (v.lat * Math.PI) / 180, + longitude: (v.lon * Math.PI) / 180, + height: v.height, + }), orientation: { heading: (v.heading * Math.PI) / 180, pitch: (v.pitch * Math.PI) / 180, @@ -534,7 +533,7 @@ try { await page.close(); } } finally { - await browser.close(); + await closeBrowser(browser); fs.writeFileSync( path.join(root, 'results.json'), JSON.stringify( diff --git a/scripts/qa-traffic-navigation.mjs b/scripts/qa-traffic-navigation.mjs index b45daeb2c..d02c85d6f 100644 --- a/scripts/qa-traffic-navigation.mjs +++ b/scripts/qa-traffic-navigation.mjs @@ -1,20 +1,18 @@ #!/usr/bin/env node +import { whenSettled, until } from './qa/ready.mjs'; // Deterministic city-navigation exercise; road/flow responses are fixtures. +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; -import puppeteer from 'puppeteer'; import { trafficFixtureResponse } from './traffic-fixtures.mjs'; const args = process.argv.slice(2); const url = args[args.indexOf('--url') + 1] || 'http://localhost:4173'; -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, protocolTimeout: 120000, args: [ '--no-sandbox', '--disable-dev-shm-usage', - '--use-gl=angle', - '--use-angle=swiftshader', '--disable-background-timer-throttling', ], }); @@ -44,15 +42,15 @@ try { await page.waitForFunction(() => window.__godsEyeView?.dataManager, { timeout: 60000, }); - await sleep(5000); + await whenSettled(page); await page.keyboard.press('Escape'); await page.evaluate(async (withCctv) => { - const { viewer, dataManager } = window.__godsEyeView; + const { dataManager } = window.__godsEyeView; const C = await import('/node_modules/.vite/deps/cesium.js'); for (const id of ['satellites', 'flights', 'military']) await dataManager.setEnabled(id, false); - viewer.camera.cancelFlight(); - viewer.camera.setView({ + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ destination: C.Cartesian3.fromDegrees(-97.744, 30.267, 3200), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); @@ -71,27 +69,28 @@ try { console.log( 'Traffic timeout state', await page.evaluate(() => { - const { viewer, dataManager } = window.__godsEyeView; + const { dataManager } = window.__godsEyeView; return { stats: dataManager.layers.get('traffic').module.getStats(), - camera: viewer.camera.positionCartographic, + camera: window.__godsEyeView.engine.camera.pose().cartographic, }; }), ); throw error; } - await sleep(2500); + await whenSettled(page); await page.evaluate(async () => { - const { viewer } = window.__godsEyeView; const C = await import('/node_modules/.vite/deps/cesium.js'); - viewer.camera.flyTo({ + window.__godsEyeView.engine.camera.flyTo({ destination: C.Cartesian3.fromDegrees(-0.1276, 51.5072, 3200), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, duration: 4, }); }); - const deadline = Date.now() + 45000; - while (londonRequests < 2 && Date.now() < deadline) await sleep(500); + await until(() => londonRequests >= 2, { + timeoutMs: 45000, + label: 'London retry', + }); assert.ok( londonRequests >= 2, 'London must recover its failed request without a toggle', @@ -121,5 +120,5 @@ try { await page.close(); } } finally { - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-traffic-oblique.mjs b/scripts/qa-traffic-oblique.mjs index cdc2b9265..6c2b60072 100644 --- a/scripts/qa-traffic-oblique.mjs +++ b/scripts/qa-traffic-oblique.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node /** Reticle coverage on oblique arrivals: source requests, toggle oracle and zoom revisits. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; -import puppeteer from 'puppeteer'; import { moveCamera, createScreencast, @@ -20,12 +21,10 @@ const modes = modeArg ? [modeArg] : ['tomtom', 'osm', 'hybrid']; const out = `qa-shots/oblique-${baseline ? 'before' : 'after'}`; fs.mkdirSync(out, { recursive: true }); const result = { views: [], errors: [], forbidden: [], failures: [] }; -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, protocolTimeout: 180000, args: [ - '--use-angle=metal', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', ], @@ -59,12 +58,10 @@ try { const url = new URL(args.find((a) => /^https?:/.test(a))); url.searchParams.set('trafficRoads', mode); await page.goto(url.href, { waitUntil: 'domcontentloaded' }); - await page.waitForFunction( - () => - window.__godsEyeView?.dataManager && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 120000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.dataManager, { + timeout: 120000, + }); + await whenSettled(page); await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, ); @@ -74,18 +71,19 @@ try { for (const [id] of g.dataManager.layers) await g.dataManager.setEnabled(id, false); g.styleManager.setDetection({ enabled: false }); - g.viewer.scene.screenSpaceCameraController.enableCollisionDetection = false; }); const snap = () => page.evaluate(() => { - const { viewer, dataManager } = window.__godsEyeView; + const { dataManager } = window.__godsEyeView; let upper = 0, lower = 0; dataManager.layers .get('traffic') .module.visitMotionDots((id, key, p) => { if (!p.show) return; - const s = viewer.scene.cartesianToCanvasCoordinates(p.position); + const s = window.__godsEyeView.engine.projector.worldToWindow( + p.position, + ); if (s && s.x >= 240 && s.x <= 1040 && s.y >= 0 && s.y <= 800) { if (s.y < 400) upper++; else lower++; @@ -98,7 +96,7 @@ try { }; }); const settle = async () => { - await sleep(700); + await whenSettled(page); await page .waitForFunction( () => { @@ -110,7 +108,7 @@ try { { timeout: 15000 }, ) .catch(() => {}); - await sleep(1200); + await whenSettled(page); return snap(); }; for (const [name, lat, lon, height] of [ @@ -186,20 +184,22 @@ try { mode === 'hybrid' ? await createScreencast(page, dir, { fps: 24 }) : null; await installFrameProbe(page); await page.evaluate(() => { - const { viewer, dataManager } = window.__godsEyeView; + const { dataManager } = window.__godsEyeView; window.__obliqueFrames = []; window.__obliqueMoving = true; - viewer.scene.postRender.addEventListener(() => { + window.__godsEyeView.engine.frames.postRender.addEventListener(() => { const s = dataManager.layers.get('traffic').module.getStats(); let upper = 0, lower = 0, near = 0; - const camera = viewer.camera.positionWC; + const camera = window.__godsEyeView.engine.camera.pose().position; dataManager.layers .get('traffic') .module.visitMotionDots((id, key, p) => { if (!p.show) return; - const q = viewer.scene.cartesianToCanvasCoordinates(p.position); + const q = window.__godsEyeView.engine.projector.worldToWindow( + p.position, + ); if (q && q.x >= 240 && q.x <= 1040 && q.y >= 0 && q.y <= 800) { if (q.y < 400) upper++; else lower++; @@ -251,7 +251,7 @@ try { window.__obliqueMoving = false; }); const panned = await settle(); - await sleep(1800); + await whenSettled(page); const frames = await page.evaluate(() => window.__obliqueFrames); await page.evaluate(() => window.__godsEyeView.dataManager.setEnabled('traffic', false), @@ -304,7 +304,7 @@ try { } } finally { fs.writeFileSync(`${out}/result.json`, JSON.stringify(result, null, 2)); - await browser.close(); + await closeBrowser(browser); } if ( !baseline && From 6c416f3fe6df4b30496d74f5b29500e5f1207e61 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 16:43:58 +0400 Subject: [PATCH 5/6] Move QA scripts to the shared harness, batch 4 of 4 As in the previous batches. Scripts launching Chrome themselves go from 99 to 8 (all outside this conversion), fixed pauses from 86 to 69 and Cesium-only reads from 75 to 46; the QA harness baseline records them. --- scripts/qa-harness-baseline.json | 10 +- scripts/qa-traffic-preset-ab.mjs | 70 +--- scripts/qa-traffic.mjs | 68 +-- scripts/qa-transit-browser.mjs | 47 ++- scripts/qa-transit-controls.mjs | 20 +- scripts/qa-transit-heading.mjs | 60 +-- scripts/qa-transit-recovery.mjs | 101 ++--- scripts/qa-transit-scenes.mjs | 645 +++++++++++++++-------------- scripts/qa-transit.mjs | 316 +++++++------- scripts/qa-ui-disposal.mjs | 16 +- scripts/qa-vessel-cards.mjs | 77 +--- scripts/qa-vessel-datum.mjs | 51 +-- scripts/qa-view-target-prewarm.mjs | 71 ++-- scripts/qa-visual-effects.mjs | 54 +-- scripts/qa-visual-input.mjs | 22 +- scripts/qa-voice-auth-focus.mjs | 10 +- scripts/qa-voice-auth.mjs | 23 +- scripts/qa-voice-routing.mjs | 126 ++---- scripts/qa-voice-wav.mjs | 27 +- scripts/qa-weather-journey.mjs | 85 ++-- scripts/qa-weather-perf.mjs | 82 ++-- scripts/qa-weather-swap.mjs | 38 +- scripts/qa-weather-teardown.mjs | 55 +-- scripts/qa-zoom-out-guard.mjs | 42 +- src/tooling/transitQa.test.mjs | 61 ++- 25 files changed, 1006 insertions(+), 1171 deletions(-) diff --git a/scripts/qa-harness-baseline.json b/scripts/qa-harness-baseline.json index 04b000f91..2425b662b 100644 --- a/scripts/qa-harness-baseline.json +++ b/scripts/qa-harness-baseline.json @@ -1,9 +1,9 @@ { "metrics": { - "ownLaunch": 99, - "fixedSleeps": 86, - "loadingScreenReadiness": 1, - "cesiumOnlyReads": 75, - "ownWaitHelpers": 19 + "ownLaunch": 8, + "fixedSleeps": 69, + "loadingScreenReadiness": 0, + "cesiumOnlyReads": 46, + "ownWaitHelpers": 16 } } diff --git a/scripts/qa-traffic-preset-ab.mjs b/scripts/qa-traffic-preset-ab.mjs index d1b4950fd..ce7126fc9 100644 --- a/scripts/qa-traffic-preset-ab.mjs +++ b/scripts/qa-traffic-preset-ab.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled, whenTilesLoaded, whenUiSettled } from './qa/ready.mjs'; /** * qa-traffic-preset-ab.mjs — A/B screenshot capture for preset-aware * traffic dot styling (owner field finding 2026-07-23: NVG/FLIR/CRT @@ -23,7 +25,7 @@ * param not adopted by the layer). */ -import puppeteer from 'puppeteer'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -69,52 +71,20 @@ const STYLES = [ { id: 'retro', name: 'retro', label: 'CRT', profile: 'crt' }, ]; -const CHROME_EXECUTABLE_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - // Prefer puppeteer's version-pinned Chrome-for-Testing over the system - // Chrome: /Applications auto-updates underneath the harnesses, and its - // software-GL behavior shifts across majors (system Chrome 150 blew the - // tile-gated drain budget under SwiftShader on 2026-07-30 — six - // false-negative qa-cctv-v2 runs against a healthy build). A deterministic - // pinned browser beats the newest one for regression harnesses. - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -const findChromeExecutable = () => - CHROME_EXECUTABLE_CANDIDATES.find((c) => { try { return fs.existsSync(c); } catch { return false; } }) || null; -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** Same pattern as qa-traffic-jamviz-ab: wait for the 3D tileset to settle. */ -function waitForTilesLoaded(page, timeoutMs) { - return page.waitForFunction( - () => { - const prims = window.__godsEyeView.viewer.scene.primitives; - for (let i = 0; i < prims.length; i++) { - const p = prims.get(i); - if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') { - return p.tilesLoaded === true; - } - } - return true; - }, - { timeout: timeoutMs } - ).then(() => true).catch(() => false); -} /** Enable traffic + teleport, then poll the layer until settled. */ function settleTraffic(page, view, { minCount = 100, timeoutS = 45 } = {}) { - return page.evaluate(async (v, minC, tS) => { + return evaluateWithGeo(page, async (qaGeo, v, minC, tS) => { const gev = window.__godsEyeView; await gev.dataManager.setEnabled('traffic', true); const mod = gev.dataManager.layers.get('traffic').module; - try { gev.viewer.camera.cancelFlight(); } catch { /* no flight */ } - const ell = gev.viewer.scene.globe.ellipsoid; + try { window.__godsEyeView.engine.camera.cancelFlight(); } catch { /* no flight */ } const d2r = Math.PI / 180; - gev.viewer.camera.setView({ - destination: ell.cartographicToCartesian({ + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: v.lon * d2r, latitude: v.lat * d2r, height: v.height, }), orientation: { @@ -161,15 +131,9 @@ async function main() { } fs.mkdirSync(OUT_DIR, { recursive: true }); - const browser = await puppeteer.launch({ - headless: 'new', - ...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}), - args: [ - '--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader', - '--disable-dev-shm-usage', '--disable-web-security', - '--disable-background-timer-throttling', '--disable-renderer-backgrounding', - '--window-size=1600,900', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-web-security', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1600,900'], }); const manifest = []; @@ -180,10 +144,10 @@ async function main() { console.log('Loading app...'); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForFunction( - () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + () => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager, { timeout: 60000 }, ); - await sleep(1500); + await whenSettled(page); let firstView = true; for (const view of VIEWS) { @@ -201,7 +165,7 @@ async function main() { exitCode = 1; continue; } - await waitForTilesLoaded(page, firstView ? 30000 : 15000); + await whenTilesLoaded(page, { timeoutMs: firstView ? 30000 : 15000 }).then(() => true).catch(() => false); firstView = false; for (const style of STYLES) { @@ -213,7 +177,7 @@ async function main() { sm.setStyle(s.name, { applyPreset: true }); if (sm.stages.thermal) sm.stages.thermal.uniforms.palette = s.ironbow ? 1.0 : 0.0; }, style); - await sleep(1800); // 500 ms intensity lerp + settle + await whenUiSettled(page); // 500 ms intensity lerp + settle // Round 2 legs: dots alone (DETECT off) vs dots + bucket-colored // brackets (DENSE) — presetDots stays at its default 'on'. @@ -221,7 +185,7 @@ async function main() { await page.evaluate((d) => { window.__godsEyeView.styleManager._setDetectionMode(d === 'on' ? 'DENSE' : 'OFF'); }, detect); - await sleep(1200); // label-arbiter solve + a few rendered frames + await whenUiSettled(page); // label-arbiter solve + a few rendered frames const final = await page.evaluate(() => { const mod = window.__godsEyeView.dataManager.layers.get('traffic').module; @@ -272,14 +236,14 @@ async function main() { sm._setDetectionMode('OFF'); if (sm.stages.thermal) sm.stages.thermal.uniforms.palette = 0.0; }); - await sleep(1000); + await whenUiSettled(page); } } catch (e) { console.error('Harness error:', e); exitCode = 3; } finally { fs.writeFileSync(path.join(OUT_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2)); - await browser.close(); + await closeBrowser(browser); } console.log(`\nShots + manifest.json → ${OUT_DIR}`); process.exit(exitCode); diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index 9770999df..e2520c14b 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled, whenFramesDrawn } from './qa/ready.mjs'; /** * qa-traffic.mjs — headless proof for the TomTom live-flow traffic layer. * @@ -30,7 +32,7 @@ * Exits non-zero on any FAIL. Does not commit anything. */ -import puppeteer from 'puppeteer'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import { captureQaScreenshot } from './qa-chrome.mjs'; import fs from 'node:fs'; import path from 'node:path'; @@ -52,26 +54,7 @@ const HEADFUL = argv.includes('--headful'); const FIXTURES = argv.includes('--fixtures'); const { installTrafficRoutes } = await import('./traffic-fixtures.mjs'); -const CHROME_EXECUTABLE_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - // Prefer puppeteer's version-pinned Chrome-for-Testing over the system - // Chrome: /Applications auto-updates underneath the harnesses, and its - // software-GL behavior shifts across majors (system Chrome 150 blew the - // tile-gated drain budget under SwiftShader on 2026-07-30 — six - // false-negative qa-cctv-v2 runs against a healthy build). A deterministic - // pinned browser beats the newest one for regression harnesses. - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -function findChromeExecutable() { - for (const candidate of CHROME_EXECUTABLE_CANDIDATES) { - try { if (fs.existsSync(candidate)) return candidate; } catch { /* ignore */ } - } - return null; -} const results = []; function record(name, ok, detail) { @@ -79,7 +62,6 @@ function record(name, ok, detail) { const tag = ok === null ? '\x1b[33mINCONCLUSIVE\x1b[0m' : ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); } -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** Enable traffic + teleport, then poll the layer until settled. */ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) { @@ -87,19 +69,18 @@ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) { // changes do not activate a headful window the way a real UI click does. await page.bringToFront(); await page.waitForFunction(() => document.visibilityState === 'visible'); - return page.evaluate(async (v, minC, tS) => { + return evaluateWithGeo(page, async (qaGeo, v, minC, tS) => { const gev = window.__godsEyeView; const dm = gev.dataManager; await dm.setEnabled('traffic', true); const mod = dm.layers.get('traffic').module; const beforeUpdate = mod.getStats().lastUpdate; - const ell = gev.viewer.scene.globe.ellipsoid; const d2r = Math.PI / 180; // The app's intro flyTo animation clobbers a setView issued mid-flight — // cancel any active tween before teleporting. - try { gev.viewer.camera.cancelFlight(); } catch { /* no flight active */ } - gev.viewer.camera.setView({ - destination: ell.cartographicToCartesian({ longitude: v.lon * d2r, latitude: v.lat * d2r, height: v.height }), + try { window.__godsEyeView.engine.camera.cancelFlight(); } catch { /* no flight active */ } + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: v.lon * d2r, latitude: v.lat * d2r, height: v.height }), orientation: { heading: (v.heading || 0) * d2r, pitch: (v.pitch ?? -90) * d2r, roll: 0 }, }); let s = null; @@ -139,17 +120,9 @@ async function main() { } fs.mkdirSync(SHOTS_DIR, { recursive: true }); - const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', - ...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}), - args: [ - '--no-sandbox', '--disable-setuid-sandbox', - ...(!HEADFUL ? ['--use-gl=angle', '--use-angle=swiftshader'] : []), - '--disable-backgrounding-occluded-windows', - '--disable-dev-shm-usage', - '--disable-background-timer-throttling', '--disable-renderer-backgrounding', - '--window-size=1440,900', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', ...(!HEADFUL ? [] : []), '--disable-backgrounding-occluded-windows', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1440,900'], }); let exitCode = 0; @@ -174,10 +147,10 @@ async function main() { console.log('Loading app...'); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForFunction( - () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + () => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager, { timeout: 60000 }, ); - await sleep(5000); + await whenSettled(page); await page.keyboard.press('Escape'); // ── (i) LIVE mode: San Antonio — fast Overpass extract, partial TomTom @@ -201,23 +174,22 @@ async function main() { mumbai.flowCoveragePct > 0 && mumbai.tilesFetched > 0, `coverage=${mumbai.flowCoveragePct}% tiles=${mumbai.tilesFetched}`); if (mumbai.mode !== 'live' || !(mumbai.count > 0) || !(colored > 0)) exitCode = 1; - await sleep(1200); + await whenSettled(page); await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-flow.png') }, { headful: HEADFUL }); } // ── (iv) uncovered-roads param — hide vs sim ───────────────────────────── console.log('\n(iv) uncoveredRoads param — hide vs sim...'); if ((mumbai.flowBuckets?.sim || 0) > 0) { - const hid = await page.evaluate(async () => { + const hid = await evaluateWithGeo(page, async (qaGeo) => { const gev = window.__godsEyeView; const mod = gev.dataManager.layers.get('traffic').module; const before = mod.getStats().lastUpdate; mod.setParams({ uncoveredRoads: 'hide' }); - const ell = gev.viewer.scene.globe.ellipsoid; const d2r = Math.PI / 180; // Shift far enough to defeat the overlap gate and force a re-render. - gev.viewer.camera.setView({ - destination: ell.cartographicToCartesian({ longitude: -98.51 * d2r, latitude: 29.435 * d2r, height: 2800 }), + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: -98.51 * d2r, latitude: 29.435 * d2r, height: 2800 }), orientation: { heading: 0.4, pitch: -1.25, roll: 0 }, }); // Poll for a NEW render (lastUpdate changes) — the pre-shift stats @@ -289,10 +261,10 @@ async function main() { }); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForFunction( - () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + () => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager, { timeout: 60000 }, ); - await sleep(5000); + await whenSettled(page); await page.keyboard.press('Escape'); let simStats = await settleTraffic(page, { lon: -98.4936, lat: 29.4241, height: 3000 }, { minCount: 100, timeoutS: 45 }); if (!simStats || simStats.count === 0) { @@ -316,7 +288,7 @@ async function main() { if (simStats.mode !== 'sim' || !allWhite || keylessFlowReqs.length !== 0 || !keylessHonest) { exitCode = 1; } - await sleep(1000); + await whenFramesDrawn(page); await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') }, { headful: HEADFUL }); } record('BROWSER: no uncaught page exceptions', pageErrors.length === 0, pageErrors.join('; ')); @@ -324,7 +296,7 @@ async function main() { console.error('\x1b[31mHarness error:\x1b[0m', e); exitCode = 3; } finally { - await browser.close(); + await closeBrowser(browser); } const pass = results.filter((r) => r.ok === true).length; diff --git a/scripts/qa-transit-browser.mjs b/scripts/qa-transit-browser.mjs index 735194ad1..d52663da4 100644 --- a/scripts/qa-transit-browser.mjs +++ b/scripts/qa-transit-browser.mjs @@ -1,3 +1,4 @@ +import { whenFramesDrawn } from './qa/ready.mjs'; export function transitQaBase(value) { if (!value) throw new Error('Transit QA requires an explicit QA_BASE_URL'); const url = new URL(value); @@ -21,12 +22,15 @@ export function boundPageEvaluations(page, defaultTimeoutMs = 30000) { ); const label = String(fn).slice(0, 120).replace(/\s+/g, ' '); return evaluate( - async (source, values, timeoutMs, label) => { + async (source, values, timeoutMs, label, isFunction) => { let timer; try { return await Promise.race([ // Puppeteer callbacks are standalone functions, just as in evaluate. - Promise.resolve().then(() => (0, eval)(`(${source})`)(...values)), + Promise.resolve().then(() => { + const result = (0, eval)(`(${source})`); + return isFunction ? result(...values) : result; + }), new Promise((_, reject) => { timer = setTimeout( () => @@ -47,6 +51,7 @@ export function boundPageEvaluations(page, defaultTimeoutMs = 30000) { args, timeoutMs, label, + typeof fn === 'function', ); }; } @@ -62,8 +67,8 @@ export async function reportTransitVisibility(page, label) { visible: state._visible.size, moving: state._moving.size, held: state._renderHeld, - requestRenderMode: app.viewer.scene.requestRenderMode, - renderLoop: app.viewer.useDefaultRenderLoop, + requestRenderMode: !app.engine.render.isContinuous(), + renderLoop: !app.engine.isDestroyed(), renderErrors: window.__transitRenderErrors || [], bounds: state._viewBounds, hidden: layer._transitVisibilityForTest().filter((e) => !e.shown), @@ -76,13 +81,14 @@ export async function reportTransitVisibility(page, label) { ); if (result.renderLoop === false || result.renderErrors.length) throw new Error( - `${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`, + `${label}: render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`, ); + await whenFramesDrawn(page); return result; } /** Intersect primed histories with the new snapshot; disappearance is availability. */ -export async function restoreRetainedTransit(retained) { +export async function restoreRetainedTransit(qaGeo, retained) { if (!retained.length) return { error: 'No multi-fix history observed during the probe', @@ -117,9 +123,8 @@ export async function restoreRetainedTransit(retained) { const entry = state._vehicles.get( `${history.feedId || 'mbta'}:${history.vehicleId}`, ); - const camera = app.viewer.camera, - C = camera.positionCartographic.constructor; - camera.setView({ + const C = qaGeo.cartographic; + window.__godsEyeView.engine.camera.setView({ destination: C.toCartesian( C.fromDegrees(entry.record.lon, entry.record.lat, 600), ), @@ -173,14 +178,12 @@ export async function sampleTransitPixels(page, palette) { ); }, 5000); const remove = - window.__godsEyeView.viewer.scene.postRender.addEventListener(() => { + window.__godsEyeView.engine.frames.postRender.addEventListener(() => { const app = window.__godsEyeView; const layer = app.dataManager.layers.get('transit').module; const state = layer._transitStateForTest(); - const scene = app.viewer.scene; - const canvas = scene.canvas; - const view = scene.camera.viewMatrix; - const proj = scene.camera.frustum.projectionMatrix; + const { engine } = app; + const canvas = window.__godsEyeView.engine.host.canvas; const mul = (m, v) => [ m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3], m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3], @@ -269,9 +272,12 @@ export async function sampleTransitPixels(page, palette) { for (const entry of state._vehicles.values()) { if (!entry.marker || entry.marker.show === false) continue; const p = entry.marker.position; - const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1])); + const clip = mul( + window.__godsEyeView.engine.projector.viewProjection(), + [p.x, p.y, p.z, 1], + ); if (!(clip[3] > 0)) continue; - const screen = entry.marker.computeScreenSpacePosition?.(scene) || { + const screen = { x: ((clip[0] / clip[3]) * 0.5 + 0.5) * width, y: (1 - ((clip[1] / clip[3]) * 0.5 + 0.5)) * height, }; @@ -492,7 +498,7 @@ export async function sampleTransitPixels(page, palette) { // label or sprite must not masquerade as a dim sensor signature. // Check the whole sampled core, not just its centre pick. let coreOwned = true; - if (scene.pick) { + { const framebuffer = gl.getParameter?.(gl.FRAMEBUFFER_BINDING); for (const core of corePoints) { let u = core.x / width, @@ -519,13 +525,12 @@ export async function sampleTransitPixels(page, palette) { v) * intensity; } - const picked = scene.pick( + const picked = engine.picker.pick( { x: u * width, y: (1 - v) * height }, 1, 1, ); - if (picked?.id !== s.key && picked?.primitive?.id !== s.key) - coreOwned = false; + if (picked?.id !== s.key) coreOwned = false; } if (gl.bindFramebuffer) gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); @@ -657,7 +662,7 @@ export async function sampleTransitPixels(page, palette) { ); resolve(out); }); - window.__godsEyeView.viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); }, palette); } diff --git a/scripts/qa-transit-controls.mjs b/scripts/qa-transit-controls.mjs index 70ed1503c..19b77a526 100644 --- a/scripts/qa-transit-controls.mjs +++ b/scripts/qa-transit-controls.mjs @@ -1,4 +1,4 @@ -const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +import { whenSettled, whenUiSettled } from './qa/ready.mjs'; export const BOSTON = { id: 'boston', feedId: 'mbta', @@ -69,7 +69,7 @@ export function chooseLiveCities(now = new Date()) { * @param {Record} [params] aria-label → slider value * @returns {Promise} how the style was set */ -export async function selectStyle(page, name, params = {}, wait = pause) { +export async function selectStyle(page, name, params = {}) { let how = 'button'; // The presets live in the bottom control panel, which auto-dismisses when // the pointer leaves it: pin it open the way a user does, once. @@ -86,7 +86,7 @@ export async function selectStyle(page, name, params = {}, wait = pause) { { explicit: true }, ); }); - await wait(400); + await whenUiSettled(page); try { await page.$eval(`.style-btn[data-style="${name}"]`, (el) => el.scrollIntoView({ block: 'center' }), @@ -118,7 +118,7 @@ export async function selectStyle(page, name, params = {}, wait = pause) { { timeout: 10000 }, name, ); - await wait(1_800); + await whenUiSettled(page); for (const [label, value] of Object.entries(params)) { const selector = `#param-sliders input.param-slider[aria-label="${label}"]`; await page.waitForSelector(selector, { timeout: 10000 }); @@ -130,7 +130,7 @@ export async function selectStyle(page, name, params = {}, wait = pause) { }, value, ); - await wait(700); + await whenUiSettled(page); } return how; } @@ -141,7 +141,7 @@ export async function selectStyle(page, name, params = {}, wait = pause) { * @param {import('puppeteer').Page} page * @returns {Promise} the profile reached */ -export async function detectOn(page, wait = pause) { +export async function detectOn(page) { // The real button toggles OFF/restore. Density determines its on-profile. const mode = () => page.evaluate(() => { @@ -153,11 +153,7 @@ export async function detectOn(page, wait = pause) { }; }); // The loading screen swallows the first click of a fresh page. - await page - .waitForFunction(() => !document.getElementById('loading-screen'), { - timeout: 30_000, - }) - .catch(() => {}); + await whenSettled(page); // The DISPLAY rail starts collapsed on a first run: open it the way a user // does, then press DETECT until it reports on. await page.evaluate(() => { @@ -175,7 +171,7 @@ export async function detectOn(page, wait = pause) { ); if (!(await mode()).on) { await page.click('#detection-toggle'); - await wait(300); + await whenUiSettled(page); } await page.$eval('#detection-density-slider', (el) => { el.value = '100'; diff --git a/scripts/qa-transit-heading.mjs b/scripts/qa-transit-heading.mjs index 36530c829..2a0a389bd 100644 --- a/scripts/qa-transit-heading.mjs +++ b/scripts/qa-transit-heading.mjs @@ -1,4 +1,7 @@ -import puppeteer from 'puppeteer'; +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { hookRenderErrors } from './qa-browserEvidence.mjs'; +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import { blockPaidQaMaps } from './qa-map-policy.mjs'; import { transitQaBase } from './qa-transit-browser.mjs'; import { chromeExecutablePath } from './qa-chrome.mjs'; @@ -6,18 +9,18 @@ import { chromeExecutablePath } from './qa-chrome.mjs'; /** Prove parked and moving headings in the real scene, without live feeds. */ export async function runTransitHeadingRegression(base, check) { base = transitQaBase(base); - const browser = await puppeteer.launch({ + const browser = await launchBrowser({ headless: true, - executablePath: chromeExecutablePath(), args: [ '--no-sandbox', '--disable-background-timer-throttling', - ...(process.platform === 'darwin' ? ['--use-angle=metal'] : []), + ...(process.platform === 'darwin' ? [] : []), '--enable-gpu', ], }); try { const page = await browser.newPage(); + await hookRenderErrors(page); await blockPaidQaMaps(page); await page.setViewport({ width: 1280, height: 800 }); await page.goto( @@ -28,7 +31,7 @@ export async function runTransitHeadingRegression(base, check) { () => window.__godsEyeView?.dataManager?.layers?.has('transit'), { timeout: 90000 }, ); - await new Promise((resolve) => setTimeout(resolve, 6000)); + await whenSettled(page); await page.evaluate(async () => { const app = window.__godsEyeView; await app.dataManager.setEnabled('transit', true, { source: 'qa' }); @@ -63,26 +66,25 @@ export async function runTransitHeadingRegression(base, check) { parts.rendering.schedulePlayback(entry); } parts.rendering.syncRenderHold(); - window.__headingErrors = []; - app.viewer.scene.renderError.addEventListener((scene, error) => - window.__headingErrors.push(error.message), - ); + window.__headingErrors = window.__qaRenderErrors; }); const pose = async (heading) => { - await page.evaluate((heading) => { - const camera = window.__godsEyeView.viewer.camera; - const C = camera.positionCartographic.constructor; - camera.setView({ - destination: C.toCartesian(C.fromDegrees(-71.0589, 42.3601, 1200)), - orientation: { heading, pitch: -Math.PI / 2, roll: 0 }, - }); - }, heading); - await new Promise((resolve) => setTimeout(resolve, 700)); + await evaluateWithGeo( + page, + async (qaGeo, heading) => { + const C = qaGeo.cartographic; + window.__godsEyeView.engine.camera.setView({ + destination: C.toCartesian(C.fromDegrees(-71.0589, 42.3601, 1200)), + orientation: { heading, pitch: -Math.PI / 2, roll: 0 }, + }); + }, + heading, + ); + await whenSettled(page); }; const sample = () => page.evaluate(async () => { - const app = window.__godsEyeView, - scene = app.viewer.scene; + const app = window.__godsEyeView; const state = app.dataManager.layers .get('transit') .module._transitStateForTest(); @@ -96,12 +98,18 @@ export async function runTransitHeadingRegression(base, check) { ); const project = (p) => { const clip = mul( - scene.camera.frustum.projectionMatrix, - mul(scene.camera.viewMatrix, [p.x, p.y, p.z, 1]), + window.__godsEyeView.engine.projector.viewProjection(), + [p.x, p.y, p.z, 1], ); return { - x: ((clip[0] / clip[3] + 1) * scene.canvas.clientWidth) / 2, - y: ((1 - clip[1] / clip[3]) * scene.canvas.clientHeight) / 2, + x: + ((clip[0] / clip[3] + 1) * + window.__godsEyeView.engine.host.canvas.clientWidth) / + 2, + y: + ((1 - clip[1] / clip[3]) * + window.__godsEyeView.engine.host.canvas.clientHeight) / + 2, }; }; return [0, 1, 2, 3].map((i) => { @@ -139,7 +147,7 @@ export async function runTransitHeadingRegression(base, check) { 'unknown parked course remains screen-up', before[2].rotation === 0 && after[2].rotation === 0, ); - await new Promise((resolve) => setTimeout(resolve, 1200)); + await whenSettled(page); const moved = (await sample())[3]; const dx = moved.x - after[3].x, dy = moved.y - after[3].y; @@ -160,6 +168,6 @@ export async function runTransitHeadingRegression(base, check) { (await page.evaluate(() => window.__headingErrors)).length === 0, ); } finally { - await browser.close(); + await closeBrowser(browser); } } diff --git a/scripts/qa-transit-recovery.mjs b/scripts/qa-transit-recovery.mjs index ff23998e7..5c519b3a8 100644 --- a/scripts/qa-transit-recovery.mjs +++ b/scripts/qa-transit-recovery.mjs @@ -1,5 +1,7 @@ +import { evaluateWithGeo } from './qa/geometry.mjs'; /** Camera-jump regression against an existing server; never starts a server. */ -import puppeteer from 'puppeteer'; +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs'; import { mkdir, writeFile } from 'node:fs/promises'; import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js'; @@ -9,11 +11,10 @@ import { transitQaBase } from './qa-transit-browser.mjs'; const base = transitQaBase(process.env.QA_BASE_URL); const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery'; await mkdir(shots, { recursive: true }); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, - executablePath: chromeExecutablePath(), args: [ - ...(process.platform === 'darwin' ? ['--use-angle=metal'] : []), + ...(process.platform === 'darwin' ? [] : []), '--enable-gpu', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', @@ -35,28 +36,16 @@ try { const app = window.__godsEyeView; await app.dataManager.setEnabled('transit', true, { source: 'qa' }); }); - await page.waitForFunction( - () => { - const scene = window.__godsEyeView.viewer.scene; - return scene.globe.tilesLoaded && scene.globe.show; - }, - { timeout: 60000 }, - ); - await page.waitForFunction( - () => { - const cover = document.getElementById('loading-screen'); - return !cover || cover.classList.contains('hidden'); - }, - { timeout: 60000 }, - ); - await page.evaluate(() => { - const app = window.__godsEyeView, - camera = app.viewer.camera; - const C = camera.positionCartographic.constructor, + await whenTilesLoaded(page, { timeoutMs: 60000 }); + await whenSettled(page); + await evaluateWithGeo(page, async (qaGeo) => { + const C = qaGeo.cartographic, lat = 30.267 + 90 / 111320; const floor = - app.viewer.scene.globe.getHeight(C.fromDegrees(-97.7431, lat)) || 0; - camera.setView({ + window.__godsEyeView.engine.surface.terrainHeight( + C.fromDegrees(-97.7431, lat), + ) || 0; + window.__godsEyeView.engine.camera.setView({ destination: C.toCartesian( C.fromDegrees( -97.7431, @@ -67,29 +56,24 @@ try { orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 }, }); }); - await new Promise((resolve) => setTimeout(resolve, 2000)); - await page.waitForFunction( - () => window.__godsEyeView.viewer.scene.globe.tilesLoaded, - { timeout: 60000 }, - ); - await new Promise((resolve) => setTimeout(resolve, 1500)); - const result = await page.evaluate(async () => { - const app = window.__godsEyeView, - scene = app.viewer.scene, - camera = app.viewer.camera; + await whenSettled(page); + await whenTilesLoaded(page, { timeoutMs: 60000 }); + await whenSettled(page); + const result = await evaluateWithGeo(page, async (qaGeo) => { + const app = window.__godsEyeView; const layer = app.dataManager.layers.get('transit').module; const state = layer._transitStateForTest(), parts = layer._transitPartsForTest(); const { getRegisteredTransitFeed } = await import('/src/data/transitFeeds.js'); const { setRate } = await import('/src/data/contactPlayback.js'); - const C = camera.positionCartographic.constructor; + const C = qaGeo.cartographic; const feed = { ...getRegisteredTransitFeed('capmetro-austin'), id: 'qa-camera-recovery', }; const aim = (lat, height = 600) => - camera.setView({ + window.__godsEyeView.engine.camera.setView({ destination: C.toCartesian( C.fromDegrees( -97.7431, @@ -161,7 +145,7 @@ try { const preparedAtSelection = !!parts.trails.diagnostics()?.body; parts.height.requestHeight = requestHeight; const floor = - scene.globe.getHeight( + window.__godsEyeView.engine.surface.terrainHeight( C.fromDegrees(entry.sample.lon, entry.sample.lat), ) || 0; aim(entry.sample.lat, floor + 600); @@ -170,7 +154,7 @@ try { while (performance.now() - started < 1400) { trail = parts.trails.diagnostics(); if (entry.marker.show && trail?.body?.show && trail.body.ready) break; - scene.requestRender(); + window.__godsEyeView.engine.render.request(); await wait(25); } const status = { @@ -213,8 +197,8 @@ try { const lengths = [0]; for (let i = 1; i < points.length; i++) lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i])); - const canvas = scene.canvas, - gl = scene.context._gl, + const canvas = window.__godsEyeView.engine.host.canvas, + gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2'), scale = canvas.width / canvas.clientWidth; const mul = (m, v) => [0, 1, 2, 3].map( @@ -222,10 +206,12 @@ try { m[i] * v[0] + m[i + 4] * v[1] + m[i + 8] * v[2] + m[i + 12] * v[3], ); const project = (p) => { - const q = mul( - camera.frustum.projectionMatrix, - mul(camera.viewMatrix, [p.x, p.y, p.z, 1]), - ); + const q = mul(window.__godsEyeView.engine.projector.viewProjection(), [ + p.x, + p.y, + p.z, + 1, + ]); return { x: ((q[0] / q[3]) * 0.5 + 0.5) * canvas.clientWidth, y: (0.5 - (q[1] / q[3]) * 0.5) * canvas.clientHeight, @@ -242,7 +228,8 @@ try { (d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]), ); const cart = C.fromCartesian(p); - cart.height = scene.globe.getHeight(cart) || 0; + cart.height = + window.__godsEyeView.engine.surface.terrainHeight(cart) || 0; return { ...project(C.toCartesian(cart)), radius: (Math.SQRT2 * Math.ceil(3 * scale)) / scale, @@ -282,18 +269,20 @@ try { remove(); reject(new Error('No recovery frame')); }, 500); - const remove = scene.postRender.addEventListener(() => { - remove(); - clearTimeout(timer); - resolve(read()); - }); - scene.requestRender(); + const remove = + window.__godsEyeView.engine.frames.postRender.addEventListener(() => { + remove(); + clearTimeout(timer); + resolve(read()); + }); + window.__godsEyeView.engine.render.request(); }); let visible = true; // Body and head together, as the trail's own visibility switch does. - const remove = scene.preRender.addEventListener(() => - trailRenderer.setVisible(visible), - ); + const remove = + window.__godsEyeView.engine.frames.preRender.addEventListener(() => + trailRenderer.setVisible(visible), + ); const captures = []; try { for (const show of [false, true, false, true]) { @@ -316,7 +305,7 @@ try { offAgain, onAgain, tilesReady: - scene.globe.tilesLoaded && + window.__godsEyeView.engine.surface.tilesLoaded() && parts.trails.diagnostics().body === trail.body, elapsedMs: performance.now() - started, // The trail's ground corridors (age bands and head, each with a backing). @@ -350,5 +339,5 @@ try { console.log(JSON.stringify(result)); process.exitCode = pass ? 0 : 1; } finally { - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-transit-scenes.mjs b/scripts/qa-transit-scenes.mjs index fb2918409..144d3d3d8 100644 --- a/scripts/qa-transit-scenes.mjs +++ b/scripts/qa-transit-scenes.mjs @@ -1,3 +1,6 @@ +// Cesium-only probes: native globe and ground-primitive coverage are the Cesium visual oracle. +import { whenTilesLoaded, whenFramesDrawn } from './qa/ready.mjs'; +import { evaluateWithGeo } from './qa/geometry.mjs'; import { captureQaScreenshot } from './qa-chrome.mjs'; import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js'; @@ -89,24 +92,27 @@ export async function runFleetBudgets({ const cdp = await page.createCDPSession(); await cdp.send('HeapProfiler.enable'); for (const count of [800, 3000]) { - await page.evaluate(async (count) => { - const app = window.__godsEyeView; - await app.dataManager.setEnabled('transit', true, { source: 'qa' }); - const camera = app.viewer.camera, - C = camera.positionCartographic.constructor; - camera.setView({ - destination: C.toCartesian( - C.fromDegrees(-71.0605, 42.3554, count === 800 ? 600 : 30000), - ), - orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, - }); - const layer = app.dataManager.layers.get('transit').module; - layer._transitPartsForTest().ingestion.abortAllInFlight(); - layer._transitStateForTest()._activeFeeds.clear(); - for (const key of layer._transitStateForTest()._vehicles.keys()) - layer._transitPartsForTest().ingestion.removeVehicle(key); - }, count); - await wait(8000); + await evaluateWithGeo( + page, + async (qaGeo, count) => { + const app = window.__godsEyeView; + await app.dataManager.setEnabled('transit', true, { source: 'qa' }); + const C = qaGeo.cartographic; + window.__godsEyeView.engine.camera.setView({ + destination: C.toCartesian( + C.fromDegrees(-71.0605, 42.3554, count === 800 ? 600 : 30000), + ), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + const layer = app.dataManager.layers.get('transit').module; + layer._transitPartsForTest().ingestion.abortAllInFlight(); + layer._transitStateForTest()._activeFeeds.clear(); + for (const key of layer._transitStateForTest()._vehicles.keys()) + layer._transitPartsForTest().ingestion.removeVehicle(key); + }, + count, + ); + await whenTilesLoaded(page); // Camera proximity can finish a live poll during tile warm-up. Remove it // before the heap baseline, or its memory would discount the fixture fleet. await page.evaluate(() => { @@ -121,9 +127,9 @@ export async function runFleetBudgets({ for (const key of state._vehicles.keys()) parts.ingestion.removeVehicle(key); parts.rendering.syncRenderHold(); - window.__godsEyeView.viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); - await wait(500); + await whenFramesDrawn(page); await cdp.send('HeapProfiler.collectGarbage'); const before = await cdp.send('Runtime.getHeapUsage'); const loaded = await page.evaluate( @@ -141,7 +147,7 @@ export async function runFleetBudgets({ page, `${count} budget visibility after load`, ); - await wait(2000); + await whenFramesDrawn(page); await cdp.send('HeapProfiler.collectGarbage'); // The retained delta is conservative: it includes concurrent page growth. const retained = await cdp.send('Runtime.getHeapUsage'); @@ -156,7 +162,7 @@ export async function runFleetBudgets({ .get('transit') .module._resetTransitFixtureClockForTest(), ); - await wait(1500); + await whenFramesDrawn(page); await cdp.send('HeapProfiler.startSampling', { samplingInterval: 128, includeObjectsCollectedByMajorGC: true, @@ -315,15 +321,18 @@ export async function runBostonMatrix({ await selectStyle(page, 'normal'); for (const candidate of group.candidates) { background = { ...candidate, name: group.name }; - await page.evaluate((b) => { - const camera = window.__godsEyeView.viewer.camera, - C = camera.positionCartographic.constructor; - camera.setView({ - destination: C.toCartesian(C.fromDegrees(b.lon, b.lat, 600)), - orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, - }); - }, background); - await wait(8000); + await evaluateWithGeo( + page, + async (qaGeo, b) => { + const C = qaGeo.cartographic; + window.__godsEyeView.engine.camera.setView({ + destination: C.toCartesian(C.fromDegrees(b.lon, b.lat, 600)), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + }, + background, + ); + await whenTilesLoaded(page); await page.evaluate( ({ background, routes }) => { const layer = @@ -336,7 +345,7 @@ export async function runBostonMatrix({ }, { background, routes }, ); - await wait(1000); + await whenFramesDrawn(page); backgroundResult = reduceBackground( group.name, await sampleRendered(page), @@ -352,7 +361,7 @@ export async function runBostonMatrix({ ); for (const look of looks) { await selectStyle(page, look.style, look.params); - await wait(1200); + await whenFramesDrawn(page); await reportTransitVisibility( page, `Boston ${background.name}/${look.name} visibility`, @@ -407,7 +416,7 @@ export async function runBostonMatrix({ .selection.selectVehicle(key), target.key, ); - await wait(500); + await whenFramesDrawn(page); await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`, type: 'jpeg', @@ -431,7 +440,7 @@ export async function runBostonMatrix({ restored.color === target.color, ); } - await wait(300); + await whenFramesDrawn(page); await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`, type: 'jpeg', @@ -464,12 +473,11 @@ export async function runBostonLive({ sampleRendered, }) { console.log('\n== Boston live oblique streets =='); - await page.evaluate(async () => { + await evaluateWithGeo(page, async (qaGeo) => { const app = window.__godsEyeView; await app.dataManager.setEnabled('transit', false, { source: 'qa' }); - const camera = app.viewer.camera, - C = camera.positionCartographic.constructor; - camera.setView({ + const C = qaGeo.cartographic; + window.__godsEyeView.engine.camera.setView({ destination: C.toCartesian(C.fromDegrees(-71.0605, 42.3554, 600)), orientation: { heading: 0, pitch: -Math.PI / 4, roll: 0 }, }); @@ -478,7 +486,7 @@ export async function runBostonLive({ layer._setTransitFixtureFloorsForTest([], 0); await layer.update(); }); - await wait(20000); + await whenTilesLoaded(page); await detectOn(page); const evidence = []; for (const view of [ @@ -507,27 +515,30 @@ export async function runBostonLive({ params: {}, }, ]) { - await page.evaluate((view) => { - const app = window.__godsEyeView, - camera = app.viewer.camera, - C = camera.positionCartographic.constructor; - app.dataManager.layers - .get('transit') - .module._transitPartsForTest() - .selection.clearSelection(); - camera.setView({ - destination: C.toCartesian( - C.fromDegrees(-71.0605, 42.3554, view.altitude), - ), - orientation: { - heading: (view.heading * Math.PI) / 180, - pitch: (view.pitch * Math.PI) / 180, - roll: 0, - }, - }); - }, view); + await evaluateWithGeo( + page, + async (qaGeo, view) => { + const app = window.__godsEyeView, + C = qaGeo.cartographic; + app.dataManager.layers + .get('transit') + .module._transitPartsForTest() + .selection.clearSelection(); + window.__godsEyeView.engine.camera.setView({ + destination: C.toCartesian( + C.fromDegrees(-71.0605, 42.3554, view.altitude), + ), + orientation: { + heading: (view.heading * Math.PI) / 180, + pitch: (view.pitch * Math.PI) / 180, + roll: 0, + }, + }); + }, + view, + ); await selectStyle(page, view.style, view.params); - await wait(8000); + await whenTilesLoaded(page); const fleet = await page.evaluate(() => { const state = window.__godsEyeView.dataManager.layers .get('transit') @@ -562,7 +573,7 @@ export async function runBostonLive({ let selection = null; if (target) { await page.mouse.click(target.x, target.y); - await wait(250); + await whenFramesDrawn(page); selection = await page.evaluate((key) => { const state = window.__godsEyeView.dataManager.layers .get('transit') @@ -606,15 +617,15 @@ export async function runTrailVisibility({ }) { const name = 'Google 3D selected trail visible at 6 of 8 projected samples'; await selectStyle(page, 'normal'); - await page.evaluate( - async ({ altitude, pitch }) => { + await evaluateWithGeo( + page, + async (qaGeo, { altitude, pitch }) => { const app = window.__godsEyeView; await app.dataManager.setEnabled('transit', true, { source: 'qa' }); if (app.mapStackController.isStackAvailable('photoreal')) await app.mapStackController.setStack('photoreal'); - const camera = app.viewer.camera, - C = camera.positionCartographic.constructor; - camera.setView({ + const C = qaGeo.cartographic; + window.__godsEyeView.engine.camera.setView({ destination: C.toCartesian( C.fromDegrees( -97.7431, @@ -627,11 +638,11 @@ export async function runTrailVisibility({ }, { altitude, pitch }, ); - await wait(8000); - const setup = await page.evaluate( - async ({ altitude, pitch, feed }) => { - const app = window.__godsEyeView, - scene = app.viewer.scene; + await whenTilesLoaded(page); + const setup = await evaluateWithGeo( + page, + async (qaGeo, { altitude, pitch, feed }) => { + const app = window.__godsEyeView; const layer = app.dataManager.layers.get('transit').module; const state = layer._transitStateForTest(), parts = layer._transitPartsForTest(); @@ -641,11 +652,13 @@ export async function runTrailVisibility({ app.mapStackController.getActiveId() === 'photoreal'; if (!google) return { google: false, reason: 'visible Google 3D tileset required' }; - const C = scene.camera.positionCartographic.constructor; + const C = qaGeo.cartographic; const centreLat = 30.267 + 90 / 111320; - const floor = scene.sampleHeight(C.fromDegrees(-97.7431, centreLat)); + const floor = window.__godsEyeView.engine.surface.sampleHeight( + C.fromDegrees(-97.7431, centreLat), + ); if (Number.isFinite(floor)) - scene.camera.setView({ + window.__godsEyeView.engine.camera.setView({ destination: C.toCartesian( C.fromDegrees( -97.7431, @@ -744,7 +757,7 @@ export async function runTrailVisibility({ const layer = app.dataManager.layers.get('transit').module; const parts = layer._transitPartsForTest(), entry = layer._transitStateForTest()._vehicles.get(key); - const gl = scene.context._gl, + const gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2'), originals = new Map(), wrapped = new Map(); let owner = null, @@ -791,29 +804,35 @@ export async function runTrailVisibility({ return original.apply(this, args); }; } - const pre = scene.preRender.addEventListener(() => { - headBytes = bodyBytes = 0; - const walk = (collection) => { - for (let i = 0; i < collection.length; i++) { - const primitive = collection.get(i); - const kind = window.__qaTransitLineKinds.get(primitive?.gevGroundSet); - if (kind) wrap(primitive, kind); - else if (typeof primitive?.get === 'function') walk(primitive); - } - }; - walk(scene.primitives); - walk(scene.groundPrimitives); - }); - const post = scene.postRender.addEventListener(() => { - frames++; - headUploadMax = Math.max(headUploadMax, headBytes); - bodyUploadMax = Math.max(bodyUploadMax, bodyBytes); - }); + const pre = window.__godsEyeView.engine.frames.preRender.addEventListener( + () => { + headBytes = bodyBytes = 0; + const walk = (collection) => { + for (let i = 0; i < collection.length; i++) { + const primitive = collection.get(i); + const kind = window.__qaTransitLineKinds.get( + primitive?.gevGroundSet, + ); + if (kind) wrap(primitive, kind); + else if (typeof primitive?.get === 'function') walk(primitive); + } + }; + walk(scene.primitives); + walk(scene.groundPrimitives); + }, + ); + const post = window.__godsEyeView.engine.frames.postRender.addEventListener( + () => { + frames++; + headUploadMax = Math.max(headUploadMax, headBytes); + bodyUploadMax = Math.max(bodyUploadMax, bodyBytes); + }, + ); try { // Include a full history upload, even if the first selection already rendered. entry.pathRevision = -1; parts.trails.prepareEntry(entry); - scene.requestRender(); + window.__godsEyeView.engine.render.request(); await new Promise((resolve) => setTimeout(resolve, 20000)); headCpu.sort((a, b) => a - b); return { @@ -832,221 +851,237 @@ export async function runTrailVisibility({ if (!primitive.isDestroyed()) primitive.update = update; } }, setup.key); - const result = await page.evaluate(async (setup) => { - const app = window.__godsEyeView, - scene = app.viewer.scene; - const layer = app.dataManager.layers.get('transit').module; - const state = layer._transitStateForTest(), - parts = layer._transitPartsForTest(); - const entry = state._vehicles.get(setup.key); - const trail = parts.trails.diagnostics(); - if (!entry || !trail) - return { ...setup, present: 0, reason: 'selection disappeared' }; - const { setRate } = await import('/src/data/contactPlayback.js'); - setRate(entry.track, 0, { - wallNowMs: Date.now(), - monoNowMs: performance.now(), - }); - parts.rendering.sampleIdle(entry); - parts.rendering.schedulePlayback(entry); - parts.rendering.syncRenderHold(); - const ready = () => - app.tileset?.show !== false && - app.tileset?.tilesLoaded === true && - (scene.globe?.show === false || scene.globe?.tilesLoaded === true) && - trail.body?.ready === true && - (!trail.headPrimitive || trail.headPrimitive.ready === true); - // Require sustained readiness after freezing playback, before projecting - // onto the refined surface. Repeated captures still check pixel stability. - const tilesReady = await new Promise((resolve) => { - const started = performance.now(); - let readySince = null; - const timer = setInterval(() => { - const now = performance.now(); - readySince = ready() ? (readySince ?? now) : null; - scene.requestRender(); - if ( - (readySince !== null && now - readySince >= 500) || - now - started >= 30000 - ) { - clearInterval(timer); - resolve(readySince !== null && now - readySince >= 500); + const result = await evaluateWithGeo( + page, + async (qaGeo, setup) => { + const app = window.__godsEyeView, + scene = app.viewer.scene; + const layer = app.dataManager.layers.get('transit').module; + const state = layer._transitStateForTest(), + parts = layer._transitPartsForTest(); + const entry = state._vehicles.get(setup.key); + const trail = parts.trails.diagnostics(); + if (!entry || !trail) + return { ...setup, present: 0, reason: 'selection disappeared' }; + const { setRate } = await import('/src/data/contactPlayback.js'); + setRate(entry.track, 0, { + wallNowMs: Date.now(), + monoNowMs: performance.now(), + }); + parts.rendering.sampleIdle(entry); + parts.rendering.schedulePlayback(entry); + parts.rendering.syncRenderHold(); + const ready = () => + app.tileset?.show !== false && + window.__godsEyeView.engine.surface.tilesLoaded() === true && + (scene.globe?.show === false || + window.__godsEyeView.engine.surface.tilesLoaded() === true) && + trail.body?.ready === true && + (!trail.headPrimitive || trail.headPrimitive.ready === true); + // Require sustained readiness after freezing playback, before projecting + // onto the refined surface. Repeated captures still check pixel stability. + const tilesReady = await new Promise((resolve) => { + const started = performance.now(); + let readySince = null; + const timer = setInterval(() => { + const now = performance.now(); + readySince = ready() ? (readySince ?? now) : null; + window.__godsEyeView.engine.render.request(); + if ( + (readySince !== null && now - readySince >= 500) || + now - started >= 30000 + ) { + clearInterval(timer); + resolve(readySince !== null && now - readySince >= 500); + } + }, 80); + }); + if (!tilesReady) + return { + ...setup, + present: 0, + reason: 'tiles or trail geometry did not become ready', + }; + const canvas = window.__godsEyeView.engine.host.canvas, + gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); + const scale = canvas.width / canvas.clientWidth; + const points = entry.trailSegments + .filter((s) => s.toT <= entry.sample.displayT) + .flatMap((s) => s.positions); + if (trail.head?.show) points.push(...trail.head.positions); + if (points.length < 2) + return { ...setup, present: 0, reason: 'no completed path' }; + const C = qaGeo.cartographic; + // Marker positions are plain { x, y, z } now (the display's handles), not + // Cesium Cartesian3 instances with static helpers. + const V = { + distance: (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z), + lerp: (a, b, t) => ({ + x: a.x + (b.x - a.x) * t, + y: a.y + (b.y - a.y) * t, + z: a.z + (b.z - a.z) * t, + }), + }; + // What the floor samples leave out: the Cesium primitives the engine + // draws the transit mover sets and trail lines with. + const drawnByTransit = []; + const walk = (collection) => { + for (let i = 0; i < collection.length; i++) { + const item = collection.get(i); + if ( + String(item?.gevMoverSet ?? '').startsWith('transit') || + String(item?.gevGroundSet ?? '').startsWith('transit-trail-') + ) + drawnByTransit.push(item); + else if (typeof item?.get === 'function' && 'length' in item) + walk(item); } - }, 80); - }); - if (!tilesReady) + }; + walk(scene.primitives); + walk(scene.groundPrimitives); + const lengths = [0]; + for (let i = 1; i < points.length; i++) + lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i])); + const mul = (m, v) => + [0, 1, 2, 3].map( + (i) => + m[i] * v[0] + m[i + 4] * v[1] + m[i + 8] * v[2] + m[i + 12] * v[3], + ); + const samples = Array.from({ length: 8 }, (_, i) => { + const d = (lengths.at(-1) * (i + 1)) / 9; + let j = 1; + while (j < lengths.length - 1 && lengths[j] < d) j++; + const p = V.lerp( + points[j - 1], + points[j], + (d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]), + ); + const cart = C.fromCartesian(p); + const height = window.__godsEyeView.engine.surface.sampleHeight( + cart, + drawnByTransit, + ); + if (Number.isFinite(height)) cart.height = height; + const surface = C.toCartesian(cart); + const q = mul(window.__godsEyeView.engine.projector.viewProjection(), [ + surface.x, + surface.y, + surface.z, + 1, + ]); + return { + x: ((q[0] / q[3]) * 0.5 + 0.5) * canvas.clientWidth, + y: (0.5 - (q[1] / q[3]) * 0.5) * canvas.clientHeight, + inFront: q[3] > 0, + radius: (Math.SQRT2 * Math.ceil(3 * scale)) / scale, + }; + }); + const marker = entry.marker.position; + const markerClip = mul( + window.__godsEyeView.engine.projector.viewProjection(), + [marker.x, marker.y, marker.z, 1], + ); + const sprite = { + x: ((markerClip[0] / markerClip[3]) * 0.5 + 0.5) * canvas.clientWidth, + y: (0.5 - (markerClip[1] / markerClip[3]) * 0.5) * canvas.clientHeight, + // Include the padded, rotated sprite and its selection bracket. + radius: Math.hypot(entry.marker.width, entry.marker.height) / 2 + 4, + }; + const read = () => + samples.map(({ x, y, inFront }) => { + const r = Math.ceil(3 * scale), + px = Math.round(x * scale), + py = Math.round(canvas.height - 1 - y * scale); + if ( + !inFront || + px < r || + py < r || + px + r >= canvas.width || + py + r >= canvas.height + ) + return null; + const data = new Uint8Array((2 * r + 1) ** 2 * 4); + gl.readPixels( + px - r, + py - r, + 2 * r + 1, + 2 * r + 1, + gl.RGBA, + gl.UNSIGNED_BYTE, + data, + ); + return data; + }); + const frame = () => + new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + remove(); + reject(new Error('Trail framebuffer did not render within 5 s')); + }, 5000); + const remove = + window.__godsEyeView.engine.frames.postRender.addEventListener( + () => { + remove(); + clearTimeout(timeout); + resolve(read()); + }, + ); + window.__godsEyeView.engine.render.request(); + }); + const trailRenderer = window.__qaTransitTrail; + if (!trailRenderer) + return { ...setup, present: 0, reason: 'no trail renderer captured' }; + let showTrail = false; + // Body and head together, as the trail's own visibility switch does. + const applyVisibility = () => trailRenderer.setVisible(showTrail); + // Override the layer's frame update in both states; selection stays fixed. + const removeVisibility = + window.__godsEyeView.engine.frames.preRender.addEventListener( + applyVisibility, + ); + const captures = []; + let captureReady = true; + try { + for (const visible of [false, true, false, true]) { + showTrail = visible; + // Let post-processing settle before each measured framebuffer. + for (let i = 0; i < 3; i++) await frame(); + captures.push(await frame()); + const current = parts.trails.diagnostics(); + captureReady &&= + ready() && + current?.body === trail.body && + current?.headPrimitive === trail.headPrimitive; + } + } finally { + removeVisibility(); + trailRenderer.setVisible(true); + await frame(); + } + const [off, on, offAgain, onAgain] = captures.map((patches) => + patches.map((p) => (p ? Array.from(p) : null)), + ); return { ...setup, - present: 0, - reason: 'tiles or trail geometry did not become ready', + fixes: entry.track.count, + selected: state._selectedKey === setup.key, + pathMetres: lengths.at(-1), + samples, + on, + off, + onAgain, + offAgain, + sprite, + tilesReady: captureReady, + elapsedDisplayMs: entry.sample.displayT - setup.startDisplayT, + movedMetres: Math.abs(entry.sample.lat - setup.startLat) * 111320, + lines: trail.body?.lines ?? 0, + headStrategy: trail.headStrategy, + headRebuilds: trail.headRebuilds ?? null, }; - const canvas = scene.canvas, - gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); - const scale = canvas.width / canvas.clientWidth; - const points = entry.trailSegments - .filter((s) => s.toT <= entry.sample.displayT) - .flatMap((s) => s.positions); - if (trail.head?.show) points.push(...trail.head.positions); - if (points.length < 2) - return { ...setup, present: 0, reason: 'no completed path' }; - const C = scene.camera.positionCartographic.constructor; - // Marker positions are plain { x, y, z } now (the display's handles), not - // Cesium Cartesian3 instances with static helpers. - const V = { - distance: (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z), - lerp: (a, b, t) => ({ - x: a.x + (b.x - a.x) * t, - y: a.y + (b.y - a.y) * t, - z: a.z + (b.z - a.z) * t, - }), - }; - // What the floor samples leave out: the Cesium primitives the engine - // draws the transit mover sets and trail lines with. - const drawnByTransit = []; - const walk = (collection) => { - for (let i = 0; i < collection.length; i++) { - const item = collection.get(i); - if ( - String(item?.gevMoverSet ?? '').startsWith('transit') || - String(item?.gevGroundSet ?? '').startsWith('transit-trail-') - ) - drawnByTransit.push(item); - else if (typeof item?.get === 'function' && 'length' in item) - walk(item); - } - }; - walk(scene.primitives); - walk(scene.groundPrimitives); - const lengths = [0]; - for (let i = 1; i < points.length; i++) - lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i])); - const mul = (m, v) => - [0, 1, 2, 3].map( - (i) => - m[i] * v[0] + m[i + 4] * v[1] + m[i + 8] * v[2] + m[i + 12] * v[3], - ); - const samples = Array.from({ length: 8 }, (_, i) => { - const d = (lengths.at(-1) * (i + 1)) / 9; - let j = 1; - while (j < lengths.length - 1 && lengths[j] < d) j++; - const p = V.lerp( - points[j - 1], - points[j], - (d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]), - ); - const cart = C.fromCartesian(p); - const height = scene.sampleHeight(cart, drawnByTransit); - if (Number.isFinite(height)) cart.height = height; - const surface = C.toCartesian(cart); - const q = mul( - scene.camera.frustum.projectionMatrix, - mul(scene.camera.viewMatrix, [surface.x, surface.y, surface.z, 1]), - ); - return { - x: ((q[0] / q[3]) * 0.5 + 0.5) * canvas.clientWidth, - y: (0.5 - (q[1] / q[3]) * 0.5) * canvas.clientHeight, - inFront: q[3] > 0, - radius: (Math.SQRT2 * Math.ceil(3 * scale)) / scale, - }; - }); - const marker = entry.marker.position; - const markerClip = mul( - scene.camera.frustum.projectionMatrix, - mul(scene.camera.viewMatrix, [marker.x, marker.y, marker.z, 1]), - ); - const sprite = { - x: ((markerClip[0] / markerClip[3]) * 0.5 + 0.5) * canvas.clientWidth, - y: (0.5 - (markerClip[1] / markerClip[3]) * 0.5) * canvas.clientHeight, - // Include the padded, rotated sprite and its selection bracket. - radius: Math.hypot(entry.marker.width, entry.marker.height) / 2 + 4, - }; - const read = () => - samples.map(({ x, y, inFront }) => { - const r = Math.ceil(3 * scale), - px = Math.round(x * scale), - py = Math.round(canvas.height - 1 - y * scale); - if ( - !inFront || - px < r || - py < r || - px + r >= canvas.width || - py + r >= canvas.height - ) - return null; - const data = new Uint8Array((2 * r + 1) ** 2 * 4); - gl.readPixels( - px - r, - py - r, - 2 * r + 1, - 2 * r + 1, - gl.RGBA, - gl.UNSIGNED_BYTE, - data, - ); - return data; - }); - const frame = () => - new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - remove(); - reject(new Error('Trail framebuffer did not render within 5 s')); - }, 5000); - const remove = scene.postRender.addEventListener(() => { - remove(); - clearTimeout(timeout); - resolve(read()); - }); - scene.requestRender(); - }); - const trailRenderer = window.__qaTransitTrail; - if (!trailRenderer) - return { ...setup, present: 0, reason: 'no trail renderer captured' }; - let showTrail = false; - // Body and head together, as the trail's own visibility switch does. - const applyVisibility = () => trailRenderer.setVisible(showTrail); - // Override the layer's frame update in both states; selection stays fixed. - const removeVisibility = scene.preRender.addEventListener(applyVisibility); - const captures = []; - let captureReady = true; - try { - for (const visible of [false, true, false, true]) { - showTrail = visible; - // Let post-processing settle before each measured framebuffer. - for (let i = 0; i < 3; i++) await frame(); - captures.push(await frame()); - const current = parts.trails.diagnostics(); - captureReady &&= - ready() && - current?.body === trail.body && - current?.headPrimitive === trail.headPrimitive; - } - } finally { - removeVisibility(); - trailRenderer.setVisible(true); - await frame(); - } - const [off, on, offAgain, onAgain] = captures.map((patches) => - patches.map((p) => (p ? Array.from(p) : null)), - ); - return { - ...setup, - fixes: entry.track.count, - selected: state._selectedKey === setup.key, - pathMetres: lengths.at(-1), - samples, - on, - off, - onAgain, - offAgain, - sprite, - tilesReady: captureReady, - elapsedDisplayMs: entry.sample.displayT - setup.startDisplayT, - movedMetres: Math.abs(entry.sample.lat - setup.startLat) * 111320, - lines: trail.body?.lines ?? 0, - headStrategy: trail.headStrategy, - headRebuilds: trail.headRebuilds ?? null, - }; - }, setup); + }, + setup, + ); const pixels = reduceTrailPixels(result.on, result.off, result); result.cost = cost; result.altitude = altitude; diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index 28aeee4b4..8f1e36cb0 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -1,4 +1,9 @@ #!/usr/bin/env node +// Cesium-only probes: native entity counts audit Cesium resource ownership alongside the contract checks. +import { whenSettled, whenUiSettled, whenFramesDrawn } from './qa/ready.mjs'; +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { hookRenderErrors } from './qa-browserEvidence.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import { runTransitHeadingRegression } from './qa-transit-heading.mjs'; import { boundPageEvaluations, @@ -36,7 +41,6 @@ import { * exercises the same paths. */ import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import puppeteer from 'puppeteer'; import { blockPaidQaMaps } from './qa-map-policy.mjs'; import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs'; import { createTransitHistory } from '../server/providers/transitHistory.js'; @@ -167,13 +171,10 @@ async function sampleScreen(page) { const app = window.__godsEyeView; const layer = app.dataManager.layers.get('transit').module; const state = layer._transitStateForTest(); - const scene = app.viewer.scene; - const canvas = scene.canvas; + const canvas = window.__godsEyeView.engine.host.canvas; // The app exposes no `window.Cesium`, so the projection is done here in // plain numbers: Cesium matrices are column-major arrays of 16 doubles, // and window coordinates are the standard clip -> NDC -> viewport walk. - const view = scene.camera.viewMatrix; - const proj = scene.camera.frustum.projectionMatrix; const mul = (m, v) => [ m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3], m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3], @@ -186,7 +187,12 @@ async function sampleScreen(page) { for (const entry of state._vehicles.values()) { if (!entry.marker || entry.marker.show === false) continue; const p = entry.marker.position; - const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1])); + const clip = mul(window.__godsEyeView.engine.projector.viewProjection(), [ + p.x, + p.y, + p.z, + 1, + ]); if (!(clip[3] > 0)) continue; const ndcX = clip[0] / clip[3]; const ndcY = clip[1] / clip[3]; @@ -207,11 +213,11 @@ async function sampleScreen(page) { } async function sampleVisible(page) { - return page.evaluate(() => { + return evaluateWithGeo(page, async (qaGeo) => { const app = window.__godsEyeView; const layer = app.dataManager.layers.get('transit').module; const state = layer._transitStateForTest(); - const Cartographic = app.viewer.camera.positionCartographic.constructor; + const Cartographic = qaGeo.cartographic; return [...state._vehicles.values()] .filter((entry) => entry.marker && entry.marker.show !== false) .slice(0, 200) @@ -263,13 +269,11 @@ check( historyProbe.clear(); await mkdir(SHOTS, { recursive: true }); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, protocolTimeout: 150000, - executablePath: chromeExecutablePath(), args: [ '--no-sandbox', - '--use-angle=metal', '--enable-gpu', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', @@ -278,6 +282,7 @@ const browser = await puppeteer.launch({ ], }); const page = await browser.newPage(); +await hookRenderErrors(page); await blockPaidQaMaps(page); // This gate measures visible-page motion. OS minimization must not silently // switch it into the app's intentional hidden-tab polling pause. @@ -320,7 +325,7 @@ try { Boolean(document.querySelector('[data-layer-id="transit"]')), { timeout: 90_000 }, ); - await wait(6_000); + await whenSettled(page); // The Data Layers panel starts collapsed; a user opens it before toggling. const expanded = await page.$eval( @@ -331,7 +336,7 @@ try { return false; }, ); - if (!expanded) await wait(800); + if (!expanded) await whenUiSettled(page); check( 'the Data Layers panel opens from its own header button', await page.$eval( @@ -371,7 +376,7 @@ try { await page.$eval(row, (element) => element.scrollIntoView({ block: 'center' }), ); - await wait(400); + await whenUiSettled(page); const before = transitRequests.length; await page.click(`${row} .data-toggle-btn`); await page.waitForFunction( @@ -385,24 +390,24 @@ try { ); await page.evaluate(() => { - window.__transitRenderErrors = []; - window.__godsEyeView.viewer.scene.renderError.addEventListener( - (scene, error) => window.__transitRenderErrors.push(error.message), - ); + window.__transitRenderErrors = window.__qaRenderErrors; }); for (const city of runs('cities') ? CITIES : []) { console.log(`\n== ${city.label} ==`); // City-wide first: enough altitude to see the fleet as a fleet. - await page.evaluate((c) => { - const camera = window.__godsEyeView.viewer.camera; - const Cartographic = camera.positionCartographic.constructor; - camera.setView({ - destination: Cartographic.toCartesian( - Cartographic.fromDegrees(c.lon, c.lat - 0.03, 3_500), - ), - orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 }, - }); - }, city); + await evaluateWithGeo( + page, + async (qaGeo, c) => { + const Cartographic = qaGeo.cartographic; + window.__godsEyeView.engine.camera.setView({ + destination: Cartographic.toCartesian( + Cartographic.fromDegrees(c.lon, c.lat - 0.03, 3_500), + ), + orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 }, + }); + }, + city, + ); const arrived = await page .waitForFunction( (feedId) => @@ -428,7 +433,7 @@ try { ); continue; } - await wait(9_000); + await whenSettled(page); const fleet = await page.evaluate((feedId) => { const layer = window.__godsEyeView.dataManager.layers.get('transit').module; @@ -451,49 +456,52 @@ try { // Street level: 600 m up, 45 degrees down, framed on a real vehicle so the // shot shows what a user sees when they drop in on one. - const framed = await page.evaluate((c) => { - const app = window.__godsEyeView; - const camera = app.viewer.camera; - const Cartographic = camera.positionCartographic.constructor; - const state = app.dataManager.layers - .get('transit') - .module._transitStateForTest(); - let best = null; - for (const entry of state._vehicles.values()) { - if (!entry.key.startsWith(`${c.feedId}:`)) continue; - const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon); - if (!best || d < best.d) - best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key }; - } - if (!best) return null; - // Camera 600 m back along the heading, 600 m up, looking 45 degrees down: - // the vehicle lands in the middle of the frame. - const heading = (c.heading * Math.PI) / 180; - const lat = best.lat - (600 * Math.cos(heading)) / 111_320; - const lon = - best.lon - - (600 * Math.sin(heading)) / - (111_320 * Math.cos((best.lat * Math.PI) / 180)); - camera.setView({ - destination: Cartographic.toCartesian( - Cartographic.fromDegrees(lon, lat, 600), - ), - orientation: { heading, pitch: (-45 * Math.PI) / 180, roll: 0 }, - }); - return best; - }, city); + const framed = await evaluateWithGeo( + page, + async (qaGeo, c) => { + const app = window.__godsEyeView; + const Cartographic = qaGeo.cartographic; + const state = app.dataManager.layers + .get('transit') + .module._transitStateForTest(); + let best = null; + for (const entry of state._vehicles.values()) { + if (!entry.key.startsWith(`${c.feedId}:`)) continue; + const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon); + if (!best || d < best.d) + best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key }; + } + if (!best) return null; + // Camera 600 m back along the heading, 600 m up, looking 45 degrees down: + // the vehicle lands in the middle of the frame. + const heading = (c.heading * Math.PI) / 180; + const lat = best.lat - (600 * Math.cos(heading)) / 111_320; + const lon = + best.lon - + (600 * Math.sin(heading)) / + (111_320 * Math.cos((best.lat * Math.PI) / 180)); + window.__godsEyeView.engine.camera.setView({ + destination: Cartographic.toCartesian( + Cartographic.fromDegrees(lon, lat, 600), + ), + orientation: { heading, pitch: (-45 * Math.PI) / 180, roll: 0 }, + }); + return best; + }, + city, + ); check( `${city.label}: a vehicle to fly to`, Boolean(framed), framed?.key || 'none', ); - await wait(9_000); + await whenSettled(page); - const first = await page.evaluate(() => { + const first = await evaluateWithGeo(page, async (qaGeo) => { const app = window.__godsEyeView; const layer = app.dataManager.layers.get('transit').module; const state = layer._transitStateForTest(); - const Cartographic = app.viewer.camera.positionCartographic.constructor; + const Cartographic = qaGeo.cartographic; return { count: state._vehicles.size, stats: layer.getStats(), @@ -528,18 +536,19 @@ try { const state = app.dataManager.layers .get('transit') .module._transitStateForTest(); - const scene = app.viewer.scene; const seen = []; for (const entry of state._vehicles.values()) { - const screen = scene.cartesianToCanvasCoordinates( + const screen = window.__godsEyeView.engine.projector.worldToWindow( entry.marker.position, ); if ( screen && screen.x > 360 && - screen.x < scene.canvas.clientWidth - 120 && + screen.x < + window.__godsEyeView.engine.host.canvas.clientWidth - 120 && screen.y > 120 && - screen.y < scene.canvas.clientHeight - 160 + screen.y < + window.__godsEyeView.engine.host.canvas.clientHeight - 160 ) { seen.push({ key: entry.key, @@ -552,7 +561,7 @@ try { }, attempt); if (!target) break; await page.mouse.click(target.x, target.y); - await wait(900); + await whenUiSettled(page); selected = await page.evaluate(() => { const app = window.__godsEyeView; const key = app.dataManager.layers @@ -711,7 +720,7 @@ try { }); for (const preset of presets) { const how = await selectStyle(page, preset.style, preset.params); - await wait(1_200); + await whenUiSettled(page); const styling = await page.evaluate(() => window.__godsEyeView.dataManager.layers .get('transit') @@ -782,7 +791,7 @@ try { const clickTarget = rendered[0]; if (clickTarget) { await page.mouse.click(clickTarget.x, clickTarget.y); - await wait(100); + await whenUiSettled(page); const picked = await page.evaluate((key) => { const state = window.__godsEyeView.dataManager.layers .get('transit') @@ -816,7 +825,7 @@ try { const target = (await sampleRendered(page))[0]; if (target) { await page.mouse.click(target.x, target.y); - await wait(800); + await whenUiSettled(page); const underFlir = await page.evaluate((key) => { const layer = window.__godsEyeView.dataManager.layers.get('transit').module; @@ -851,7 +860,7 @@ try { JSON.stringify(underFlir), ); await page.keyboard.press('Escape'); - await wait(500); + await whenUiSettled(page); } else { check( 'clicking a sprite under FLIR selects it and keeps it white-hot', @@ -881,16 +890,18 @@ try { // Motion acceptance uses known report-time playback even when live fleets park. await selectStyle(page, 'normal'); - await page.evaluate((city) => { - const app = window.__godsEyeView, - camera = app.viewer.camera; - const C = camera.positionCartographic.constructor; - camera.setView({ - destination: C.toCartesian(C.fromDegrees(city.lon, city.lat, 600)), - orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, - }); - }, city); - await wait(2000); + await evaluateWithGeo( + page, + async (qaGeo, city) => { + const C = qaGeo.cartographic; + window.__godsEyeView.engine.camera.setView({ + destination: C.toCartesian(C.fromDegrees(city.lon, city.lat, 600)), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + }, + city, + ); + await whenUiSettled(page); await page.evaluate( (city) => window.__godsEyeView.dataManager.layers @@ -901,11 +912,11 @@ try { await wait(1000); const motionFirst = await sampleVisible(page); await wait(30_000); - const second = await page.evaluate(() => { + const second = await evaluateWithGeo(page, async (qaGeo) => { const app = window.__godsEyeView; const layer = app.dataManager.layers.get('transit').module; const state = layer._transitStateForTest(); - const Cartographic = app.viewer.camera.positionCartographic.constructor; + const Cartographic = qaGeo.cartographic; return { count: state._vehicles.size, // The RENDERED primitive position, read back through Cesium — not the @@ -1052,6 +1063,7 @@ try { if (runs('history')) { console.log('\n== retained history reload =='); const recovery = await browser.newPage(); + await hookRenderErrors(recovery); await blockPaidQaMaps(recovery); boundPageEvaluations(recovery); try { @@ -1106,10 +1118,7 @@ try { { timeout: 90000 }, ); await recovery.evaluate(() => { - window.__transitRenderErrors = []; - window.__godsEyeView.viewer.scene.renderError.addEventListener( - (scene, error) => window.__transitRenderErrors.push(error.message), - ); + window.__transitRenderErrors = window.__qaRenderErrors; }); const restored = await recovery.evaluate( restoreRetainedTransit, @@ -1124,7 +1133,7 @@ try { .trails.requestDiagnostics().pending, { timeout: 20000 }, ); - await wait(4000); + await whenFramesDrawn(recovery); const proof = await recovery.evaluate( ({ key, oldest }) => { const layer = @@ -1189,16 +1198,19 @@ try { // every rendered frame is sampled. if (runs('playback')) { console.log('\n== deterministic playback =='); - await page.evaluate((c) => { - const camera = window.__godsEyeView.viewer.camera; - const Cartographic = camera.positionCartographic.constructor; - camera.setView({ - destination: Cartographic.toCartesian( - Cartographic.fromDegrees(c.lon, c.lat + 0.005, 2400), - ), - orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, - }); - }, BOSTON); + await evaluateWithGeo( + page, + async (qaGeo, c) => { + const Cartographic = qaGeo.cartographic; + window.__godsEyeView.engine.camera.setView({ + destination: Cartographic.toCartesian( + Cartographic.fromDegrees(c.lon, c.lat + 0.005, 2400), + ), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + }, + BOSTON, + ); await page.evaluate(() => { window.__godsEyeView.dataManager.layers .get('transit') @@ -1345,14 +1357,14 @@ try { console.log(` scripted fleet before trace: ${await simState()}`); await reportTransitVisibility(page, 'scripted visibility before trace'); // Sample every rendered frame for two minutes. - const trace = await page.evaluate( - (durationMs) => + const trace = await evaluateWithGeo( + page, + (qaGeo, durationMs) => new Promise((resolve) => { const app = window.__godsEyeView; const layer = app.dataManager.layers.get('transit').module; const state = layer._transitStateForTest(); - const Cartographic = - app.viewer.camera.positionCartographic.constructor; + const Cartographic = qaGeo.cartographic; const keys = [ 'sim-straight', 'sim-corner', @@ -1410,7 +1422,10 @@ try { resolve({ out, words }); } }; - remove = app.viewer.scene.postRender.addEventListener(tick); + remove = + window.__godsEyeView.engine.frames.postRender.addEventListener( + tick, + ); }), 120_000, ); @@ -1552,23 +1567,22 @@ try { // ---- Card anchor synchronisation --------------------------------------- console.log('\n== card anchor =='); - await page.evaluate(() => { - const app = window.__godsEyeView, - camera = app.viewer.camera; + await evaluateWithGeo(page, async (qaGeo) => { + const app = window.__godsEyeView; const entry = app.dataManager.layers .get('transit') .module._transitStateForTest() ._vehicles.get('mbta:sim-straight'); if (!entry) return; - const C = camera.positionCartographic.constructor; + const C = qaGeo.cartographic; const at = C.fromCartesian(entry.marker.position); at.height = 600; - camera.setView({ + window.__godsEyeView.engine.camera.setView({ destination: C.toCartesian(at), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); }); - await wait(1500); + await whenUiSettled(page); await reportTransitVisibility(page, 'scripted visibility before click'); const target = await page.evaluate(() => { const app = window.__godsEyeView; @@ -1576,10 +1590,7 @@ try { const state = layer._transitStateForTest(); const entry = state._vehicles.get('mbta:sim-straight'); if (!entry?.marker?.show) return null; - const scene = app.viewer.scene; - const canvas = scene.canvas; - const view = scene.camera.viewMatrix; - const proj = scene.camera.frustum.projectionMatrix; + const canvas = window.__godsEyeView.engine.host.canvas; const mul = (m, v) => [ m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3], m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3], @@ -1587,7 +1598,12 @@ try { m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3], ]; const p = entry.marker.position; - const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1])); + const clip = mul(window.__godsEyeView.engine.projector.viewProjection(), [ + p.x, + p.y, + p.z, + 1, + ]); const x = ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth; const y = (1 - ((clip[1] / clip[3]) * 0.5 + 0.5)) * canvas.clientHeight; if ( @@ -1615,7 +1631,7 @@ try { JSON.stringify(target), ); await page.mouse.click(target.x, target.y); - await wait(600); + await whenUiSettled(page); anchor = await page.evaluate( async (key, durationMs) => { const overlay = ( @@ -1637,8 +1653,7 @@ try { ).pointerOwner(), }, }; - const scene = app.viewer.scene; - const canvas = scene.canvas; + const canvas = window.__godsEyeView.engine.host.canvas; const mul = (m, v) => [ m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3], m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3], @@ -1659,10 +1674,11 @@ try { const entry = state._vehicles.get(key); const rect = overlay.getPaintRect('transit-selected', key); if (entry?.marker) { - const view = scene.camera.viewMatrix; - const proj = scene.camera.frustum.projectionMatrix; const p = entry.marker.position; - const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1])); + const clip = mul( + window.__godsEyeView.engine.projector.viewProjection(), + [p.x, p.y, p.z, 1], + ); if ( entry.marker.show !== false && rect && @@ -1698,8 +1714,8 @@ try { if (trail.head.show && trail.head.positions.length) { const project = (p) => { const clip = mul( - scene.camera.frustum.projectionMatrix, - mul(scene.camera.viewMatrix, [p.x, p.y, p.z, 1]), + window.__godsEyeView.engine.projector.viewProjection(), + [p.x, p.y, p.z, 1], ); return [ ((clip[0] / clip[3]) * canvas.clientWidth) / 2, @@ -1742,7 +1758,10 @@ try { }); }; const timer = setTimeout(finish, durationMs + 1000); - remove = scene.postRender.addEventListener(tick); + remove = + window.__godsEyeView.engine.frames.postRender.addEventListener( + tick, + ); }); }, target.key, @@ -1816,21 +1835,24 @@ try { afterRemoval.gone && afterRemoval.selected !== 'mbta:sim-straight', JSON.stringify(afterRemoval), ); - await page.evaluate((c) => { - const camera = window.__godsEyeView.viewer.camera; - const Cartographic = camera.positionCartographic.constructor; - camera.setView({ - destination: Cartographic.toCartesian( - Cartographic.fromDegrees(c.lon, c.lat, 600), - ), - orientation: { - heading: (c.heading * Math.PI) / 180, - pitch: (-45 * Math.PI) / 180, - roll: 0, - }, - }); - }, CITIES.at(-1)); - await wait(4_000); + await evaluateWithGeo( + page, + async (qaGeo, c) => { + const Cartographic = qaGeo.cartographic; + window.__godsEyeView.engine.camera.setView({ + destination: Cartographic.toCartesian( + Cartographic.fromDegrees(c.lon, c.lat, 600), + ), + orientation: { + heading: (c.heading * Math.PI) / 180, + pitch: (-45 * Math.PI) / 180, + roll: 0, + }, + }); + }, + CITIES.at(-1), + ); + await whenSettled(page); } const sceneQA = { @@ -1921,7 +1943,7 @@ try { if (button.getAttribute('aria-expanded') !== 'true') button.click(); }, ); - await wait(1_200); + await whenFramesDrawn(page); check( 'the CONTEXT panel opens from its own header button', await page.$eval( @@ -1937,12 +1959,12 @@ try { origin: 'user', }), ); - await wait(6_000); + await whenFramesDrawn(page); check('transit is on before entering a Context mode', await isOn()); // CONTACTS await page.click('#global-context-flights-btn'); - await wait(18_000); + await whenFramesDrawn(page); const inContacts = { transit: await isOn(), vehicles: await page.evaluate( @@ -1959,7 +1981,7 @@ try { JSON.stringify(inContacts), ); await page.click('#global-context-flights-btn'); - await wait(12_000); + await whenFramesDrawn(page); check('leaving Contacts restores Transit', await isOn()); // SPACE MISSIONS @@ -1968,9 +1990,9 @@ try { origin: 'user', }), ); - await wait(2_000); + await whenFramesDrawn(page); await page.click('#global-context-missions-btn'); - await wait(20_000); + await whenFramesDrawn(page); const blocked = await page.evaluate(async () => { const settled = await window.__godsEyeView.dataManager.setEnabled( 'transit', @@ -1988,7 +2010,7 @@ try { JSON.stringify(blocked), ); await page.click('#global-context-missions-btn'); - await wait(12_000); + await whenFramesDrawn(page); check( 'leaving Space Missions leaves Transit off, as it was on entry', (await isOn()) === false, @@ -2017,7 +2039,7 @@ try { error.stack || error.message, ); } finally { - await browser.close(); + await closeBrowser(browser); } console.log( failures diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs index 9c351f388..636e10f1f 100644 --- a/scripts/qa-ui-disposal.mjs +++ b/scripts/qa-ui-disposal.mjs @@ -1,13 +1,12 @@ #!/usr/bin/env node +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; /** Exercise the real UI disposal path, including unrelated resource owners. */ -import puppeteer from 'puppeteer'; -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: [ '--no-sandbox', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), ], }); const page = await browser.newPage(); @@ -24,11 +23,10 @@ try { { waitUntil: 'domcontentloaded' }, ); await page.waitForFunction( - () => - window.__godsEyeView?.styleManager?._dataManager && - document.getElementById('loading-screen')?.classList.contains('hidden'), + () => window.__godsEyeView?.styleManager?._dataManager, { timeout: 60000 }, ); + await whenSettled(page); const result = await page.evaluate(async () => { const ui = window.__godsEyeView.styleManager; const counts = {}; @@ -287,6 +285,6 @@ try { errors.length === 0, ); } finally { - await browser.close(); + await closeBrowser(browser); } if (failures) process.exitCode = 1; diff --git a/scripts/qa-vessel-cards.mjs b/scripts/qa-vessel-cards.mjs index 4aafaf22c..3408b7797 100644 --- a/scripts/qa-vessel-cards.mjs +++ b/scripts/qa-vessel-cards.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenFramesDrawn } from './qa/ready.mjs'; /** * qa-vessel-cards.mjs — operator-side visual proof harness for AIS cards in * the shared world-overlay host. @@ -26,10 +28,10 @@ * The name and manifest both record the data source; the manifest also records * headless/headful launch mode and the browser's WebGL renderer. */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import puppeteer from 'puppeteer'; const argv = process.argv.slice(2); const getOpt = (name, dflt) => { @@ -220,28 +222,7 @@ function syntheticVesselRows(portKey, port) { })); } -const CHROME_EXECUTABLE_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - // Prefer puppeteer's version-pinned Chrome-for-Testing over the system - // Chrome: /Applications auto-updates underneath the harnesses, and its - // software-GL behavior shifts across majors (system Chrome 150 blew the - // tile-gated drain budget under SwiftShader on 2026-07-30 — six - // false-negative qa-cctv-v2 runs against a healthy build). A deterministic - // pinned browser beats the newest one for regression harnesses. - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -function findChromeExecutable() { - for (const candidate of CHROME_EXECUTABLE_CANDIDATES) { - try { - if (fs.existsSync(candidate)) return candidate; - } catch { /* fall through to Puppeteer's cache */ } - } - return null; -} async function main() { console.log(`\nAIS Vessel Cards — visual proof harness`); @@ -265,19 +246,9 @@ async function main() { fs.mkdirSync(OUT_DIR, { recursive: true }); - const chromeExecutable = findChromeExecutable(); - const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', - ...(chromeExecutable ? { executablePath: chromeExecutable } : {}), - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--use-angle=metal', - '--disable-dev-shm-usage', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--window-size=1600,900', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1600,900'], }); let failures = 0; @@ -312,10 +283,10 @@ async function main() { if (ENGINE) await page.waitForFunction(() => !!window.__godsEyeView?.layers, { timeout: 180000 }); else - await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 60000 }); + await page.waitForFunction(() => !!window.__godsEyeView?.engine, { timeout: 60000 }); // Frame the port (kill the intro flight first) and enable the layer. - const dataEvidence = await page.evaluate(async ({ p, dataMode, fixtureRows, engineNeutral }) => { + const dataEvidence = await evaluateWithGeo(page, async (qaGeo, { p, dataMode, fixtureRows, engineNeutral }) => { const gev = window.__godsEyeView; if (engineNeutral) { const geo = await import('/src/geo/index.js'); @@ -325,16 +296,14 @@ async function main() { orientation: { heading: p.heading, pitch: p.pitch, roll: 0 }, }); } else { - const v = gev.viewer; - v.camera.cancelFlight?.(); - v.scene.tweens?.removeAll?.(); + window.__godsEyeView.engine.camera.cancelFlight?.(); const carto = { longitude: (p.lon * Math.PI) / 180, latitude: (p.lat * Math.PI) / 180, height: p.height, }; - v.camera.setView({ - destination: v.scene.globe.ellipsoid.cartographicToCartesian(carto), + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian(carto), orientation: { heading: p.heading, pitch: p.pitch, roll: 0 }, }); } @@ -347,7 +316,7 @@ async function main() { throw new Error(`Synthetic AIS injection failed (${result?.count || 0}/${fixtureRows.length})`); } if (engineNeutral) gev.engine.render.request('qa-vessel-cards'); - else gev.viewer.scene.requestRender?.(); + else window.__godsEyeView.engine.render.request?.(); return { mode: 'synthetic', injected: result.count }; }, { p: port, dataMode: DATA_MODE, fixtureRows: syntheticRows, engineNeutral: Boolean(ENGINE) }); console.log( @@ -361,7 +330,7 @@ async function main() { const gev = window.__godsEyeView; const tilesLoaded = engineNeutral ? gev.engine.surface.tilesLoaded() - : gev.tileset?.tilesLoaded; + : window.__godsEyeView.engine.surface.tilesLoaded(); const ais = gev.dataManager.getAll().find((l) => l.id === 'ais-live-vessels'); const count = ais?.stats?.count ?? 0; return tilesLoaded && count > 0; @@ -372,7 +341,7 @@ async function main() { // Re-assert the port framing: on slow (SwiftShader) runs the app's // intro flight can start AFTER the first cancelFlight and land mid-wait, // dragging the camera back to the boot city before the screenshot. - await page.evaluate(async (p, engineNeutral) => { + await evaluateWithGeo(page, async (qaGeo, p, engineNeutral) => { const gev = window.__godsEyeView; if (engineNeutral) { const geo = await import('/src/geo/index.js'); @@ -382,44 +351,42 @@ async function main() { orientation: { heading: p.heading, pitch: p.pitch, roll: 0 }, }); } else { - const v = gev.viewer; - v.camera.cancelFlight?.(); - v.scene.tweens?.removeAll?.(); + window.__godsEyeView.engine.camera.cancelFlight?.(); const carto = { longitude: (p.lon * Math.PI) / 180, latitude: (p.lat * Math.PI) / 180, height: p.height, }; - v.camera.setView({ - destination: v.scene.globe.ellipsoid.cartographicToCartesian(carto), + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian(carto), orientation: { heading: p.heading, pitch: p.pitch, roll: 0 }, }); } }, port, Boolean(ENGINE)); // A changed camera invalidates the previous settled result. Let the new // view schedule its tiles before checking readiness again. - await new Promise((resolve) => setTimeout(resolve, 1500)); + await whenFramesDrawn(page); const resettled = await page .waitForFunction((engineNeutral) => { const gev = window.__godsEyeView; const ais = gev.dataManager.getAll().find((l) => l.id === 'ais-live-vessels'); const tilesLoaded = engineNeutral ? gev.engine.surface.tilesLoaded() - : gev.tileset?.tilesLoaded; + : window.__godsEyeView.engine.surface.tilesLoaded(); return tilesLoaded && (ais?.stats?.count ?? 0) > 0; }, { timeout: 60000, polling: 500 }, Boolean(ENGINE)) .then(() => true) .catch(() => false); // Let two visibility/declutter cycles (800 ms) and host rendering settle. - await new Promise((r) => setTimeout(r, 2500)); + await whenFramesDrawn(page); const expectedFixtureIds = syntheticRows.map((row) => row.mmsi); const overlayEvidence = await page.evaluate((fixtureIds, engineNeutral) => { const diagnostics = window.__gevWorldOverlay?.getDiagnostics?.(); const canvas = window.__godsEyeView?.engine?.host?.canvas; const gl = engineNeutral ? canvas?.getContext('webgl2') || canvas?.getContext('webgl') - : window.__godsEyeView?.viewer?.scene?.context?._gl; + : window.__godsEyeView.engine.host.canvas.getContext('webgl2'); const debugInfo = gl?.getExtension?.('WEBGL_debug_renderer_info'); const seam = window.__godsEyeView?.dataManager?.layers ?.get('ais-live-vessels')?.module?.__focusEvidence; @@ -485,7 +452,7 @@ async function main() { await page.close(); } } finally { - await browser.close(); + await closeBrowser(browser); } console.log('\nRetained PNG+JSON pairs are local QA evidence; each basename records live or synthetic provenance.'); diff --git a/scripts/qa-vessel-datum.mjs b/scripts/qa-vessel-datum.mjs index 4a61b4337..c421c051b 100644 --- a/scripts/qa-vessel-datum.mjs +++ b/scripts/qa-vessel-datum.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; /** * qa-vessel-datum.mjs — assertion harness for the AIS vessel vertical-datum * pass (docs/superpowers/specs/2026-07-27-vessel-datum-design.md). @@ -25,9 +26,9 @@ * --engine cesium|navara puts ?engine= on the URL; without it, the app's * default engine. */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; const argv = process.argv.slice(2); const getOpt = (name, dflt) => { @@ -67,28 +68,7 @@ const NEAR_DEG = 1.0; /** Max billboards sampled for the depth-test sweep. */ const SAMPLE_CAP = 500; -const CHROME_EXECUTABLE_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - // Prefer puppeteer's version-pinned Chrome-for-Testing over the system - // Chrome: /Applications auto-updates underneath the harnesses, and its - // software-GL behavior shifts across majors (system Chrome 150 blew the - // tile-gated drain budget under SwiftShader on 2026-07-30 — six - // false-negative qa-cctv-v2 runs against a healthy build). A deterministic - // pinned browser beats the newest one for regression harnesses. - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -function findChromeExecutable() { - for (const candidate of CHROME_EXECUTABLE_CANDIDATES) { - try { - if (fs.existsSync(candidate)) return candidate; - } catch { /* fall through to Puppeteer's cache */ } - } - return null; -} /** * In-page probe on either engine (the QA probe): the chevrons the layer @@ -160,20 +140,9 @@ async function main() { fs.mkdirSync(OUT_DIR, { recursive: true }); - const chromeExecutable = findChromeExecutable(); - const browser = await puppeteer.launch({ - headless: 'new', - ...(chromeExecutable ? { executablePath: chromeExecutable } : {}), - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--use-gl=angle', - '--use-angle=metal', - '--disable-dev-shm-usage', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--window-size=1600,900', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1600,900'], }); let failures = 0; @@ -243,20 +212,16 @@ async function main() { // Visual proof — re-assert the framing (the app's intro flight can land // mid-probe on slow runs), wait for the photoreal tileset, screenshot. await page.evaluate(frameEngine, port, false); - await page - .waitForFunction( - () => window.__godsEyeView?.engine?.surface?.tilesLoaded?.(), - { timeout: 60000, polling: 500 }, - ) + await whenTilesLoaded(page, { timeoutMs: 60000 }) .catch(() => console.log(' (tileset settle timeout — screenshotting anyway)')); - await new Promise((r) => setTimeout(r, 2500)); + await whenSettled(page); const outPath = path.join(OUT_DIR, `vessel-datum-${ENGINE ? `${ENGINE}-` : ''}${key}.png`); await page.screenshot({ path: outPath }); console.log(` [SHOT] ${outPath}`); await page.close(); } } finally { - await browser.close(); + await closeBrowser(browser); } console.log(failures ? `\n\x1b[31m${failures} failure(s)\x1b[0m` : '\n\x1b[32mAll datum checks passed\x1b[0m'); diff --git a/scripts/qa-view-target-prewarm.mjs b/scripts/qa-view-target-prewarm.mjs index d0d6cb711..3a0d01104 100644 --- a/scripts/qa-view-target-prewarm.mjs +++ b/scripts/qa-view-target-prewarm.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +// Cesium-only probes: native pickPosition failure injection exercises the Cesium HUD boundary. +import { evaluateWithGeo } from './qa/geometry.mjs'; /** * Regression proof for the view-target prewarm crash. * @@ -18,10 +20,11 @@ * `QA_LABEL=before` names the evidence files so a before/after pair can sit * side by side in qa-shots/prewarm/. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import puppeteer from 'puppeteer'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const shotsDir = path.join(repoRoot, 'qa-shots', 'prewarm'); @@ -30,22 +33,9 @@ const label = process.env.QA_LABEL || 'after'; const headful = process.argv.includes('--headful'); fs.mkdirSync(shotsDir, { recursive: true }); -const chromeCandidates = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', -].filter(Boolean); -const executablePath = chromeCandidates.find((candidate) => fs.existsSync(candidate)); -const browser = await puppeteer.launch({ - headless: headful ? false : 'new', - ...(executablePath ? { executablePath } : {}), - // Metal is a macOS-only ANGLE backend; anywhere else it fails WebGL init. - args: [ - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), - '--no-sandbox', - ], +const browser = await launchBrowser({ + headless: true, + args: [...(process.platform === 'darwin' ? ['--enable-gpu'] : []), '--no-sandbox'], }); const page = await browser.newPage(); const failures = []; @@ -91,26 +81,22 @@ let rejections = []; try { await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 }); await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 90_000 }); - await page.waitForFunction(() => window.__godsEyeView?.viewer, { timeout: 90_000 }); - await page.waitForFunction( - () => document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 90_000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.engine, { timeout: 90_000 }); + await whenSettled(page); // Boot noise (tile / network warm-up) is not what this harness is about. - await new Promise((resolve) => setTimeout(resolve, 4_000)); + await whenSettled(page); pageErrors.length = 0; consoleErrors.length = 0; await page.evaluate(() => { window.__qaPrewarmRejections.length = 0; }); const legReports = []; for (const leg of FLIGHT_LEGS) { - const report = await page.evaluate(async (spec) => { - const { viewer } = window.__godsEyeView; + const report = await evaluateWithGeo(page, async (qaGeo, spec) => { // No window.Cesium global (qa-cctv-v2 / qa-height-datum precedent) — // borrow Cartesian3's statics off a live camera-position instance. - const Cartesian3 = viewer.camera.position.constructor; + const Cartesian3 = qaGeo.cartesian3; await new Promise((resolve) => { - viewer.camera.flyTo({ + window.__godsEyeView.engine.camera.flyTo({ destination: Cartesian3.fromDegrees(spec.lon, spec.lat, spec.height), orientation: { heading: 0, pitch: (spec.pitch * Math.PI) / 180, roll: 0 }, duration: 1.2, @@ -123,15 +109,14 @@ try { // Record what the center pick actually yields so the root cause is // visible in the evidence dump rather than merely asserted. - const scene = viewer.scene; - const canvas = scene.canvas; + const canvas = window.__godsEyeView.engine.host.canvas; const center = { x: (canvas.clientWidth || canvas.width) / 2, y: (canvas.clientHeight || canvas.height) / 2, }; let picked; try { - const raw = scene.pickPosition(center); + const raw = window.__godsEyeView.engine.picker.pickPosition(center); picked = raw ? { x: raw.x, y: raw.y, z: raw.z } : null; } catch (error) { picked = { threw: String(error?.message || error) }; @@ -151,19 +136,19 @@ try { // `scene.pickPosition` with one that returns each degenerate shape in turn. // Everything downstream — the moveEnd prewarm inside requestIdleCallback and // the HUD summary interval — is the real shipped code. - const injection = await page.evaluate(async () => { + const injection = await evaluateWithGeo(page, async (qaGeo) => { const { viewer, styleManager } = window.__godsEyeView; - const Cartesian3 = viewer.camera.position.constructor; + const Cartesian3 = qaGeo.cartesian3; const shapes = [ // Throws inside Cesium's normalize. - { name: 'nan', value: new Cartesian3(Number.NaN, Number.NaN, Number.NaN) }, - { name: 'origin', value: new Cartesian3(0, 0, 0) }, - { name: 'infinite', value: new Cartesian3(Number.POSITIVE_INFINITY, 0, 0) }, + { name: 'nan', value: Cartesian3.create(Number.NaN, Number.NaN, Number.NaN) }, + { name: 'origin', value: Cartesian3.create(0, 0, 0) }, + { name: 'infinite', value: Cartesian3.create(Number.POSITIVE_INFINITY, 0, 0) }, // Converts SILENTLY to a point 6,378 km underground — the quiet failure a // bare non-zero check lets through, which then reverse-geocodes 0°, 0°. - { name: 'core-interior', value: new Cartesian3(500, 0, 0) }, + { name: 'core-interior', value: Cartesian3.create(500, 0, 0) }, // Finite, but large enough to overflow the geodetic iteration into NaN. - { name: 'absurd-magnitude', value: new Cartesian3(1e155, 0, 0) }, + { name: 'absurd-magnitude', value: Cartesian3.create(1e155, 0, 0) }, ]; const scene = viewer.scene; const originalPick = Object.getOwnPropertyDescriptor(scene, 'pickPosition'); @@ -175,16 +160,16 @@ try { const hudErrors = []; const nudge = (step) => { - const carto = viewer.camera.positionCartographic; - viewer.camera.setView({ + const carto = window.__godsEyeView.engine.camera.pose().cartographic; + window.__godsEyeView.engine.camera.setView({ destination: Cartesian3.fromRadians( carto.longitude + 0.0015 * (step + 1), carto.latitude, carto.height, ), - orientation: { heading: viewer.camera.heading, pitch: viewer.camera.pitch, roll: 0 }, + orientation: { heading: window.__godsEyeView.engine.camera.pose().heading, pitch: window.__godsEyeView.engine.camera.pose().pitch, roll: 0 }, }); - viewer.camera.moveEnd.raiseEvent(); + window.__godsEyeView.engine.camera.moveEnd.raiseEvent(); }; try { @@ -213,7 +198,7 @@ try { } } finally { if (originalPick) Object.defineProperty(scene, 'pickPosition', originalPick); - else delete scene.pickPosition; + else delete window.__godsEyeView.engine.picker.pickPosition; } return { hudErrors, shapes: shapes.length, pickCalls }; }); @@ -348,7 +333,7 @@ try { } catch (error) { check('harness ran to completion', false, String(error?.message || error)); } finally { - await browser.close(); + await closeBrowser(browser); } console.log(`\n${failures.length ? `FAIL (${failures.length})` : 'PASS'} — evidence in ${path.relative(repoRoot, shotsDir)}/`); diff --git a/scripts/qa-visual-effects.mjs b/scripts/qa-visual-effects.mjs index 43bb607e5..1cdfdb02b 100644 --- a/scripts/qa-visual-effects.mjs +++ b/scripts/qa-visual-effects.mjs @@ -1,15 +1,16 @@ #!/usr/bin/env node +// Cesium-only probes: the native bloom stage verifies the Cesium visual-effects adapter. +import { evaluateWithGeo } from './qa/geometry.mjs'; /** Rendered acceptance for style transitions and effect-state ownership. */ +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; -import puppeteer from 'puppeteer'; -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: [ '--no-sandbox', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), ], }); const page = await browser.newPage(); @@ -26,24 +27,21 @@ try { url.searchParams.set('welcome', '0'); await page.goto(url.href, { waitUntil: 'domcontentloaded' }); await page.waitForFunction( - () => - window.__godsEyeView?.styleManager?._visualEffects && - document.getElementById('loading-screen')?.classList.contains('hidden'), + () => window.__godsEyeView?.styleManager?._visualEffects, { timeout: 60_000 }, ); - await page.evaluate(() => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.cancelFlight?.(); - viewer.scene.tweens?.removeAll?.(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + await whenSettled(page); + await evaluateWithGeo(page, async (qaGeo) => { + window.__godsEyeView.engine.camera.cancelFlight?.(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: (-97.7431 * Math.PI) / 180, latitude: (30.2568 * Math.PI) / 180, height: 1200, }), orientation: { heading: 0, pitch: -0.85, roll: 0 }, }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); await page.evaluate( () => @@ -51,26 +49,10 @@ try { requestAnimationFrame(() => requestAnimationFrame(resolve)), ), ); - const settled = await page - .waitForFunction( - () => { - const primitives = window.__godsEyeView.viewer.scene.primitives; - for (let i = 0; i < primitives.length; i++) { - const primitive = primitives.get(i); - if ( - typeof primitive.tilesLoaded === 'boolean' && - !primitive.tilesLoaded - ) - return false; - } - return true; - }, - { timeout: 60_000 }, - ) - .then( - () => true, - () => false, - ); + const settled = await whenTilesLoaded(page, { timeoutMs: 60_000 }).then( + () => true, + () => false, + ); check('visible tile content settles before visual captures', settled); fs.mkdirSync('qa-shots/visual-effects', { recursive: true }); for (const style of [ @@ -228,7 +210,7 @@ try { ); if (errors.length) console.log(JSON.stringify(errors)); } finally { - await browser.close(); + await closeBrowser(browser); } console.log(`RESULT: ${failures} failures`); process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-visual-input.mjs b/scripts/qa-visual-input.mjs index 3c096acdc..08ff4ddbc 100644 --- a/scripts/qa-visual-input.mjs +++ b/scripts/qa-visual-input.mjs @@ -1,15 +1,14 @@ #!/usr/bin/env node /** Browser acceptance for application shortcuts and generated parameter controls. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; -import puppeteer from 'puppeteer'; -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: [ '--no-sandbox', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), ], }); const page = await browser.newPage(); @@ -29,16 +28,7 @@ try { () => window.__godsEyeView?.styleManager?._applicationShortcuts, { timeout: 60_000 }, ); - await page.waitForFunction( - () => { - const cover = document.getElementById('loading-screen'); - return ( - cover?.classList.contains('hidden') && - Number(getComputedStyle(cover).opacity) === 0 - ); - }, - { timeout: 60_000 }, - ); + await whenSettled(page); await page.evaluate(() => { const manager = window.__godsEyeView.styleManager; manager.setStyle('normal'); @@ -279,7 +269,7 @@ try { ); check('runtime has no uncaught browser errors', errors.length === 0); } finally { - await browser.close(); + await closeBrowser(browser); } console.log(`RESULT: ${failures} failures`); process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-voice-auth-focus.mjs b/scripts/qa-voice-auth-focus.mjs index 1382ac851..5e833bcc7 100644 --- a/scripts/qa-voice-auth-focus.mjs +++ b/scripts/qa-voice-auth-focus.mjs @@ -4,12 +4,12 @@ * Run: node --test --test-reporter=spec scripts/qa-voice-auth-focus.mjs * Sign-in responses are fixtures. No credentials or live login are used. */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; import { existsSync } from 'node:fs'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { createServer } from 'node:http'; import { after, before, test } from 'node:test'; -import puppeteer from 'puppeteer'; const root = new URL('../', import.meta.url); const view = await readFile( @@ -63,17 +63,13 @@ before(async () => { server.listen(0, '127.0.0.1', resolve); }); origin = `http://127.0.0.1:${server.address().port}`; - const chrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; - browser = await puppeteer.launch({ + browser = await launchBrowser({ headless: true, - executablePath: - process.env.PUPPETEER_EXECUTABLE_PATH || - (existsSync(chrome) ? chrome : await puppeteer.executablePath()), }); }); after(async () => { - await browser?.close(); + await closeBrowser(browser); if (server?.listening) await new Promise((resolve) => server.close(resolve)); const output = new URL('.gev-logs/voice-auth-focus/', root); await mkdir(output, { recursive: true }); diff --git a/scripts/qa-voice-auth.mjs b/scripts/qa-voice-auth.mjs index 800df9598..5e1a5ff17 100644 --- a/scripts/qa-voice-auth.mjs +++ b/scripts/qa-voice-auth.mjs @@ -6,11 +6,12 @@ * Use a short spoken reply request with three seconds of leading silence. * Saves only usage, transport metadata, and screenshots; never credentials. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import puppeteer from 'puppeteer'; const root = fileURLToPath(new URL('../', import.meta.url)); const url = new URL(process.argv[2] || 'http://127.0.0.1:4189'); @@ -27,14 +28,8 @@ url.searchParams.set('welcome', '0'); url.searchParams.set('setup', '1'); const output = path.join(root, '.gev-logs', 'voice-auth'); fs.mkdirSync(output, { recursive: true }); -const executablePath = - process.env.PUPPETEER_EXECUTABLE_PATH || - (fs.existsSync('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome') - ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' - : puppeteer.executablePath()); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, - executablePath, args: [ '--no-sandbox', '--disable-setuid-sandbox', @@ -149,12 +144,10 @@ try { }; }); await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 30_000 }); - await page.waitForFunction( - () => - window.__godsEyeView?.voiceCommands && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 45_000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.voiceCommands, { + timeout: 45_000, + }); + await whenSettled(page); await page.waitForSelector('[data-cloud-voice-auth-toggle]', { visible: true, }); @@ -281,7 +274,7 @@ try { await page ?.evaluate(() => window.__godsEyeView?.voiceCommands?.session?.stop()) .catch(() => {}); - await browser.close(); + await closeBrowser(browser); fs.writeFileSync( path.join(output, `${auth}-result.json`), `${JSON.stringify(result, null, 2)}\n`, diff --git a/scripts/qa-voice-routing.mjs b/scripts/qa-voice-routing.mjs index 48b06584f..4847a0bc7 100644 --- a/scripts/qa-voice-routing.mjs +++ b/scripts/qa-voice-routing.mjs @@ -1,4 +1,7 @@ #!/usr/bin/env node +// Cesium-only probes: native pick and tracking probes validate the legacy Cesium voice route. +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled } from './qa/ready.mjs'; /** * qa-voice-routing.mjs — voice-surface QA harness (overnight 2026-07-27). * @@ -36,11 +39,11 @@ * routing assertions pin tool NAMES (model wording varies, tool choice must not). */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import WebSocket from 'ws'; -import puppeteer from 'puppeteer'; import sharp from 'sharp'; import { ROUTING_PHRASES } from './voice-bench/phrases.mjs'; import { gradeCore, expectLabel } from './voice-bench/grade.mjs'; @@ -52,19 +55,6 @@ import { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); -const CHROME_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -const CHROME_EXECUTABLE = CHROME_CANDIDATES.find((candidate) => { - try { - return fs.existsSync(candidate); - } catch { - return false; - } -}); // ── CLI ───────────────────────────────────────────────────── function getOpt(flag, fallback) { @@ -674,21 +664,9 @@ async function runDeixisRoutingLayer() { async function runDeixisLiveLayer() { console.log('\nLAYER 1c — deixis live (in-app text turns)'); - const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', - ...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}), - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - ...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']), - '--disable-dev-shm-usage', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--use-fake-device-for-media-stream', - '--use-fake-ui-for-media-stream', - '--autoplay-policy=no-user-gesture-required', - '--window-size=1500,950', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', ...(HEADFUL ? [] : []), '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required', '--window-size=1500,950'], protocolTimeout: 180000, }); const appUrl = new URL(APP_URL); @@ -716,11 +694,12 @@ async function runDeixisLiveLayer() { timeout: 60000, }); await page.waitForFunction( - () => window.__godsEyeView?.viewer && window.__gevVoiceCommands?.session, + () => window.__godsEyeView?.engine && window.__gevVoiceCommands?.session, { timeout: 120000, polling: 250 }, ); + await whenSettled(page); await page.evaluate(() => - window.__godsEyeView.viewer.camera.cancelFlight(), + window.__godsEyeView.engine.camera.cancelFlight(), ); await run('fly_to_location', { query: 'Austin, Texas' }); await settle(9000); @@ -778,7 +757,7 @@ async function runDeixisLiveLayer() { ).find((e) => e.id === p.aircraftId); viewer.scene.render(); at = found?.position - ? viewer.scene.cartesianToCanvasCoordinates(found.position) + ? window.__godsEyeView.engine.projector.worldToWindow(found.position) : null; } if (at) window.__gevVoiceCommands.pointer.moveTo(at.x, at.y); @@ -865,8 +844,8 @@ async function runDeixisLiveLayer() { } const size = await page.evaluate(() => ({ - w: window.__godsEyeView.viewer.scene.canvas.clientWidth, - h: window.__godsEyeView.viewer.scene.canvas.clientHeight, + w: window.__godsEyeView.engine.host.canvas.clientWidth, + h: window.__godsEyeView.engine.host.canvas.clientHeight, })); const events = await turn('How many flights are around here?', { x: size.w * 0.3, @@ -893,7 +872,7 @@ async function runDeixisLiveLayer() { } catch { /* closing */ } - await browser.close(); + await closeBrowser(browser); console.log(` deixis live turns used: ${turnsUsed}`); } } @@ -986,8 +965,8 @@ function findPickableAircraft(page) { return page.evaluate(() => { const { viewer, dataManager } = window.__godsEyeView; const module = dataManager.layers.get('flights')?.module; - const canvas = viewer.scene.canvas; - const eye = viewer.camera.positionWC; + const canvas = window.__godsEyeView.engine.host.canvas; + const eye = window.__godsEyeView.engine.camera.pose().position; viewer.scene.render(); let picks = 0; const candidates = []; @@ -995,7 +974,7 @@ function findPickableAircraft(page) { // Horizon test: far-side contacts still project onto the canvas. const p = entry.position; if (eye.x * p.x + eye.y * p.y + eye.z * p.z < 6356000 ** 2) continue; - const at = viewer.scene.cartesianToCanvasCoordinates(entry.position); + const at = window.__godsEyeView.engine.projector.worldToWindow(entry.position); if ( !at || at.x < 60 || @@ -1176,7 +1155,7 @@ async function runDeixisBehavior(page, run, settle) { // D2 — bare ground: the chip gives coordinates and "around here" is a radius. const size = await page.evaluate(() => { - const canvas = window.__godsEyeView.viewer.scene.canvas; + const canvas = window.__godsEyeView.engine.host.canvas; return { w: canvas.clientWidth, h: canvas.clientHeight }; }); let groundTurn = null; @@ -1320,20 +1299,9 @@ async function runBehaviorLayer() { return; } - const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', - ...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}), - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - ...(HEADFUL ? [] : ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`]), - '--mute-audio', - '--disable-dev-shm-usage', - '--disable-web-security', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--window-size=1500,950', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', ...(HEADFUL ? [] : []), '--mute-audio', '--disable-dev-shm-usage', '--disable-web-security', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1500,950'], protocolTimeout: 180000, }); const page = await browser.newPage(); @@ -1389,14 +1357,15 @@ async function runBehaviorLayer() { }); await page.waitForFunction( () => - window.__godsEyeView?.viewer && + window.__godsEyeView?.engine && window.__gevVoiceCommands?.runner && window.__gevAnnotations, { timeout: 120000, polling: 250 }, ); + await whenSettled(page); // House rule: the intro flight clobbers teleports issued mid-flight. await page.evaluate(() => - window.__godsEyeView.viewer.camera.cancelFlight(), + window.__godsEyeView.engine.camera.cancelFlight(), ); const run = (name, args) => @@ -1414,13 +1383,12 @@ async function runBehaviorLayer() { ); const camState = () => page.evaluate(() => { - const c = window.__godsEyeView.viewer.camera; - const p = c.positionCartographic; + const p = window.__godsEyeView.engine.camera.pose().cartographic; return { lat: (p.latitude * 180) / Math.PI, lon: (p.longitude * 180) / Math.PI, altKm: p.height / 1000, - pitchDeg: (c.pitch * 180) / Math.PI, + pitchDeg: (window.__godsEyeView.engine.camera.pose().pitch * 180) / Math.PI, }; }); const settle = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -1465,7 +1433,7 @@ async function runBehaviorLayer() { // final tween frame. Cancel that already-arrived flight so the assertion // measures Radio's camera ownership, not residual navigation motion. await page.evaluate(() => - window.__godsEyeView.viewer.camera.cancelFlight(), + window.__godsEyeView.engine.camera.cancelFlight(), ); await settle(250); const radioCameraBefore = await camState(); @@ -1832,7 +1800,7 @@ async function runBehaviorLayer() { await page .waitForFunction( () => - window.__godsEyeView.viewer.camera.positionCartographic.height / + window.__godsEyeView.engine.camera.pose().cartographic.height / 1000 > 12000, { timeout: 30_000, polling: 250 }, @@ -1853,7 +1821,7 @@ async function runBehaviorLayer() { await page .waitForFunction( () => - window.__godsEyeView.viewer.camera.positionCartographic.height / + window.__godsEyeView.engine.camera.pose().cartographic.height / 1000 < 900, { timeout: 40_000, polling: 250 }, @@ -1923,7 +1891,7 @@ async function runBehaviorLayer() { // (6c) move_camera orbit ONCE: bounded eased ~30° heading advance. const heading = () => page.evaluate( - () => (window.__godsEyeView.viewer.camera.heading * 180) / Math.PI, + () => (window.__godsEyeView.engine.camera.pose().heading * 180) / Math.PI, ); let h0 = await heading(); r = await run('move_camera', { motion: 'orbit', mode: 'once' }); @@ -2105,10 +2073,9 @@ async function runBehaviorLayer() { // (6e5) tilt at the clamp answers honestly instead of silently no-oping. await page.evaluate(() => { - const c = window.__godsEyeView.viewer.camera; - c.setView({ + window.__godsEyeView.engine.camera.setView({ orientation: { - heading: c.heading, + heading: window.__godsEyeView.engine.camera.pose().heading, pitch: (-5.2 * Math.PI) / 180, roll: 0, }, @@ -2395,7 +2362,7 @@ async function runBehaviorLayer() { String(e?.message || e).slice(0, 200), ); } finally { - await browser.close(); + await closeBrowser(browser); } } @@ -2425,11 +2392,10 @@ async function runBundledOutlineScenarios({ const timings = []; for (const scenario of BUNDLED_OUTLINES) { const label = `behavior: "outline ${scenario.target}" draws the bundled ${scenario.pack} outline`; - await page.evaluate(({ lat, lon }) => { - const { viewer } = window.__godsEyeView; - viewer.camera.cancelFlight(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo, { lat, lon }) => { + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: (lon * Math.PI) / 180, latitude: (lat * Math.PI) / 180, height: 9000, @@ -2529,11 +2495,10 @@ async function runGeometryScenarios({ page, run, settle, overpassRequests }) { const shot = (name) => page.screenshot({ path: path.join(shotDir, `${name}.png`) }).catch(() => {}); const nominatimStart = nominatimCount(); const overpassStart = overpassRequests.length; - const teleport = (lat, lon, height, pitch = -90) => page.evaluate(({ lat, lon, height, pitch }) => { - const { viewer } = window.__godsEyeView; - viewer.camera.cancelFlight(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + const teleport = (lat, lon, height, pitch = -90) => evaluateWithGeo(page, async (qaGeo, { lat, lon, height, pitch }) => { + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: (lon * Math.PI) / 180, latitude: (lat * Math.PI) / 180, height, }), orientation: { heading: 0, pitch: (pitch * Math.PI) / 180, roll: 0 }, @@ -2729,11 +2694,10 @@ async function runOutlineAcceptance({ const overpassStart = overpassRequests.length; if (SHOT_DIR) fs.mkdirSync(path.resolve(ROOT, SHOT_DIR), { recursive: true }); - const teleport = (ask) => page.evaluate(({ lat, lon, height }) => { - const { viewer } = window.__godsEyeView; - viewer.camera.cancelFlight(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + const teleport = (ask) => evaluateWithGeo(page, async (qaGeo, { lat, lon, height }) => { + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: (lon * Math.PI) / 180, latitude: (lat * Math.PI) / 180, height, diff --git a/scripts/qa-voice-wav.mjs b/scripts/qa-voice-wav.mjs index f9d7f8ee9..1a2e2ecc8 100644 --- a/scripts/qa-voice-wav.mjs +++ b/scripts/qa-voice-wav.mjs @@ -4,11 +4,12 @@ * * Run: node scripts/qa-voice-wav.mjs http://localhost:4189 [--push-to-talk] */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import puppeteer from 'puppeteer'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const pushToTalk = process.argv.includes('--push-to-talk'); @@ -33,20 +34,9 @@ if (fixtureSha256 !== expectedFixtureSha256) { } const appOrigin = new URL(appUrl).origin; -const browser = await puppeteer.launch({ - headless: 'new', - executablePath: await puppeteer.executablePath(), - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--use-fake-device-for-media-stream', - `--use-file-for-fake-audio-capture=${wavPath}%noloop`, - '--autoplay-policy=no-user-gesture-required', - '--window-size=1440,900', - ], +const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--use-fake-device-for-media-stream', `--use-file-for-fake-audio-capture=${wavPath}%noloop`, '--autoplay-policy=no-user-gesture-required', '--window-size=1440,900'], }); try { @@ -65,15 +55,14 @@ try { await page.waitForFunction(() => ( window.__godsEyeView?.voiceCommands && document.getElementById('gev-voice-button') - && document.getElementById('loading-screen')?.classList.contains('hidden') ), { timeout: 30_000 }); + await whenSettled(page); const readState = () => page.evaluate(() => { const voice = window.__godsEyeView?.voiceCommands; const radio = window.__godsEyeView?.dataManager?.layers?.get('radio')?.module; const radioState = radio?.getUIState?.() || null; - const camera = window.__godsEyeView?.viewer?.camera; - const cartographic = camera?.positionCartographic; + const cartographic = window.__godsEyeView.engine.camera.pose().cartographic; return { at: Date.now(), voiceStatus: voice?.status || null, @@ -157,5 +146,5 @@ try { console.log(JSON.stringify(result, null, 2)); process.exitCode = result.ok ? 0 : 1; } finally { - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-weather-journey.mjs b/scripts/qa-weather-journey.mjs index f79bf759f..49c89182c 100644 --- a/scripts/qa-weather-journey.mjs +++ b/scripts/qa-weather-journey.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled } from './qa/ready.mjs'; /** * qa-weather-journey — drive the Weather layers the way a person would and * keep the evidence: numbered screenshots plus per-step layer diagnostics. @@ -19,9 +21,9 @@ * Requires a running dev server. Screenshots and steps.json land in * /