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); }