From 6c416f3fe6df4b30496d74f5b29500e5f1207e61 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 16:43:58 +0400 Subject: [PATCH] Move QA scripts to the shared harness, batch 4 of 4 As in the previous batches. Scripts launching Chrome themselves go from 99 to 8 (all outside this conversion), fixed pauses from 86 to 69 and Cesium-only reads from 75 to 46; the QA harness baseline records them. --- scripts/qa-harness-baseline.json | 10 +- scripts/qa-traffic-preset-ab.mjs | 70 +--- scripts/qa-traffic.mjs | 68 +-- scripts/qa-transit-browser.mjs | 47 ++- scripts/qa-transit-controls.mjs | 20 +- scripts/qa-transit-heading.mjs | 60 +-- scripts/qa-transit-recovery.mjs | 101 ++--- scripts/qa-transit-scenes.mjs | 645 +++++++++++++++-------------- scripts/qa-transit.mjs | 316 +++++++------- scripts/qa-ui-disposal.mjs | 16 +- scripts/qa-vessel-cards.mjs | 77 +--- scripts/qa-vessel-datum.mjs | 51 +-- scripts/qa-view-target-prewarm.mjs | 71 ++-- scripts/qa-visual-effects.mjs | 54 +-- scripts/qa-visual-input.mjs | 22 +- scripts/qa-voice-auth-focus.mjs | 10 +- scripts/qa-voice-auth.mjs | 23 +- scripts/qa-voice-routing.mjs | 126 ++---- scripts/qa-voice-wav.mjs | 27 +- scripts/qa-weather-journey.mjs | 85 ++-- scripts/qa-weather-perf.mjs | 82 ++-- scripts/qa-weather-swap.mjs | 38 +- scripts/qa-weather-teardown.mjs | 55 +-- scripts/qa-zoom-out-guard.mjs | 42 +- src/tooling/transitQa.test.mjs | 61 ++- 25 files changed, 1006 insertions(+), 1171 deletions(-) diff --git a/scripts/qa-harness-baseline.json b/scripts/qa-harness-baseline.json index 04b000f91..2425b662b 100644 --- a/scripts/qa-harness-baseline.json +++ b/scripts/qa-harness-baseline.json @@ -1,9 +1,9 @@ { "metrics": { - "ownLaunch": 99, - "fixedSleeps": 86, - "loadingScreenReadiness": 1, - "cesiumOnlyReads": 75, - "ownWaitHelpers": 19 + "ownLaunch": 8, + "fixedSleeps": 69, + "loadingScreenReadiness": 0, + "cesiumOnlyReads": 46, + "ownWaitHelpers": 16 } } diff --git a/scripts/qa-traffic-preset-ab.mjs b/scripts/qa-traffic-preset-ab.mjs index d1b4950fd..ce7126fc9 100644 --- a/scripts/qa-traffic-preset-ab.mjs +++ b/scripts/qa-traffic-preset-ab.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled, whenTilesLoaded, whenUiSettled } from './qa/ready.mjs'; /** * qa-traffic-preset-ab.mjs — A/B screenshot capture for preset-aware * traffic dot styling (owner field finding 2026-07-23: NVG/FLIR/CRT @@ -23,7 +25,7 @@ * param not adopted by the layer). */ -import puppeteer from 'puppeteer'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -69,52 +71,20 @@ const STYLES = [ { id: 'retro', name: 'retro', label: 'CRT', profile: 'crt' }, ]; -const CHROME_EXECUTABLE_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - // Prefer puppeteer's version-pinned Chrome-for-Testing over the system - // Chrome: /Applications auto-updates underneath the harnesses, and its - // software-GL behavior shifts across majors (system Chrome 150 blew the - // tile-gated drain budget under SwiftShader on 2026-07-30 — six - // false-negative qa-cctv-v2 runs against a healthy build). A deterministic - // pinned browser beats the newest one for regression harnesses. - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -const findChromeExecutable = () => - CHROME_EXECUTABLE_CANDIDATES.find((c) => { try { return fs.existsSync(c); } catch { return false; } }) || null; -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** Same pattern as qa-traffic-jamviz-ab: wait for the 3D tileset to settle. */ -function waitForTilesLoaded(page, timeoutMs) { - return page.waitForFunction( - () => { - const prims = window.__godsEyeView.viewer.scene.primitives; - for (let i = 0; i < prims.length; i++) { - const p = prims.get(i); - if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') { - return p.tilesLoaded === true; - } - } - return true; - }, - { timeout: timeoutMs } - ).then(() => true).catch(() => false); -} /** Enable traffic + teleport, then poll the layer until settled. */ function settleTraffic(page, view, { minCount = 100, timeoutS = 45 } = {}) { - return page.evaluate(async (v, minC, tS) => { + return evaluateWithGeo(page, async (qaGeo, v, minC, tS) => { const gev = window.__godsEyeView; await gev.dataManager.setEnabled('traffic', true); const mod = gev.dataManager.layers.get('traffic').module; - try { gev.viewer.camera.cancelFlight(); } catch { /* no flight */ } - const ell = gev.viewer.scene.globe.ellipsoid; + try { window.__godsEyeView.engine.camera.cancelFlight(); } catch { /* no flight */ } const d2r = Math.PI / 180; - gev.viewer.camera.setView({ - destination: ell.cartographicToCartesian({ + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: v.lon * d2r, latitude: v.lat * d2r, height: v.height, }), orientation: { @@ -161,15 +131,9 @@ async function main() { } fs.mkdirSync(OUT_DIR, { recursive: true }); - const browser = await puppeteer.launch({ - headless: 'new', - ...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}), - args: [ - '--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader', - '--disable-dev-shm-usage', '--disable-web-security', - '--disable-background-timer-throttling', '--disable-renderer-backgrounding', - '--window-size=1600,900', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-web-security', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1600,900'], }); const manifest = []; @@ -180,10 +144,10 @@ async function main() { console.log('Loading app...'); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForFunction( - () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + () => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager, { timeout: 60000 }, ); - await sleep(1500); + await whenSettled(page); let firstView = true; for (const view of VIEWS) { @@ -201,7 +165,7 @@ async function main() { exitCode = 1; continue; } - await waitForTilesLoaded(page, firstView ? 30000 : 15000); + await whenTilesLoaded(page, { timeoutMs: firstView ? 30000 : 15000 }).then(() => true).catch(() => false); firstView = false; for (const style of STYLES) { @@ -213,7 +177,7 @@ async function main() { sm.setStyle(s.name, { applyPreset: true }); if (sm.stages.thermal) sm.stages.thermal.uniforms.palette = s.ironbow ? 1.0 : 0.0; }, style); - await sleep(1800); // 500 ms intensity lerp + settle + await whenUiSettled(page); // 500 ms intensity lerp + settle // Round 2 legs: dots alone (DETECT off) vs dots + bucket-colored // brackets (DENSE) — presetDots stays at its default 'on'. @@ -221,7 +185,7 @@ async function main() { await page.evaluate((d) => { window.__godsEyeView.styleManager._setDetectionMode(d === 'on' ? 'DENSE' : 'OFF'); }, detect); - await sleep(1200); // label-arbiter solve + a few rendered frames + await whenUiSettled(page); // label-arbiter solve + a few rendered frames const final = await page.evaluate(() => { const mod = window.__godsEyeView.dataManager.layers.get('traffic').module; @@ -272,14 +236,14 @@ async function main() { sm._setDetectionMode('OFF'); if (sm.stages.thermal) sm.stages.thermal.uniforms.palette = 0.0; }); - await sleep(1000); + await whenUiSettled(page); } } catch (e) { console.error('Harness error:', e); exitCode = 3; } finally { fs.writeFileSync(path.join(OUT_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2)); - await browser.close(); + await closeBrowser(browser); } console.log(`\nShots + manifest.json → ${OUT_DIR}`); process.exit(exitCode); diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index 9770999df..e2520c14b 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled, whenFramesDrawn } from './qa/ready.mjs'; /** * qa-traffic.mjs — headless proof for the TomTom live-flow traffic layer. * @@ -30,7 +32,7 @@ * Exits non-zero on any FAIL. Does not commit anything. */ -import puppeteer from 'puppeteer'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import { captureQaScreenshot } from './qa-chrome.mjs'; import fs from 'node:fs'; import path from 'node:path'; @@ -52,26 +54,7 @@ const HEADFUL = argv.includes('--headful'); const FIXTURES = argv.includes('--fixtures'); const { installTrafficRoutes } = await import('./traffic-fixtures.mjs'); -const CHROME_EXECUTABLE_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - // Prefer puppeteer's version-pinned Chrome-for-Testing over the system - // Chrome: /Applications auto-updates underneath the harnesses, and its - // software-GL behavior shifts across majors (system Chrome 150 blew the - // tile-gated drain budget under SwiftShader on 2026-07-30 — six - // false-negative qa-cctv-v2 runs against a healthy build). A deterministic - // pinned browser beats the newest one for regression harnesses. - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -function findChromeExecutable() { - for (const candidate of CHROME_EXECUTABLE_CANDIDATES) { - try { if (fs.existsSync(candidate)) return candidate; } catch { /* ignore */ } - } - return null; -} const results = []; function record(name, ok, detail) { @@ -79,7 +62,6 @@ function record(name, ok, detail) { const tag = ok === null ? '\x1b[33mINCONCLUSIVE\x1b[0m' : ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); } -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** Enable traffic + teleport, then poll the layer until settled. */ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) { @@ -87,19 +69,18 @@ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) { // changes do not activate a headful window the way a real UI click does. await page.bringToFront(); await page.waitForFunction(() => document.visibilityState === 'visible'); - return page.evaluate(async (v, minC, tS) => { + return evaluateWithGeo(page, async (qaGeo, v, minC, tS) => { const gev = window.__godsEyeView; const dm = gev.dataManager; await dm.setEnabled('traffic', true); const mod = dm.layers.get('traffic').module; const beforeUpdate = mod.getStats().lastUpdate; - const ell = gev.viewer.scene.globe.ellipsoid; const d2r = Math.PI / 180; // The app's intro flyTo animation clobbers a setView issued mid-flight — // cancel any active tween before teleporting. - try { gev.viewer.camera.cancelFlight(); } catch { /* no flight active */ } - gev.viewer.camera.setView({ - destination: ell.cartographicToCartesian({ longitude: v.lon * d2r, latitude: v.lat * d2r, height: v.height }), + try { window.__godsEyeView.engine.camera.cancelFlight(); } catch { /* no flight active */ } + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: v.lon * d2r, latitude: v.lat * d2r, height: v.height }), orientation: { heading: (v.heading || 0) * d2r, pitch: (v.pitch ?? -90) * d2r, roll: 0 }, }); let s = null; @@ -139,17 +120,9 @@ async function main() { } fs.mkdirSync(SHOTS_DIR, { recursive: true }); - const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', - ...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}), - args: [ - '--no-sandbox', '--disable-setuid-sandbox', - ...(!HEADFUL ? ['--use-gl=angle', '--use-angle=swiftshader'] : []), - '--disable-backgrounding-occluded-windows', - '--disable-dev-shm-usage', - '--disable-background-timer-throttling', '--disable-renderer-backgrounding', - '--window-size=1440,900', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', ...(!HEADFUL ? [] : []), '--disable-backgrounding-occluded-windows', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1440,900'], }); let exitCode = 0; @@ -174,10 +147,10 @@ async function main() { console.log('Loading app...'); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForFunction( - () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + () => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager, { timeout: 60000 }, ); - await sleep(5000); + await whenSettled(page); await page.keyboard.press('Escape'); // ── (i) LIVE mode: San Antonio — fast Overpass extract, partial TomTom @@ -201,23 +174,22 @@ async function main() { mumbai.flowCoveragePct > 0 && mumbai.tilesFetched > 0, `coverage=${mumbai.flowCoveragePct}% tiles=${mumbai.tilesFetched}`); if (mumbai.mode !== 'live' || !(mumbai.count > 0) || !(colored > 0)) exitCode = 1; - await sleep(1200); + await whenSettled(page); await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-flow.png') }, { headful: HEADFUL }); } // ── (iv) uncovered-roads param — hide vs sim ───────────────────────────── console.log('\n(iv) uncoveredRoads param — hide vs sim...'); if ((mumbai.flowBuckets?.sim || 0) > 0) { - const hid = await page.evaluate(async () => { + const hid = await evaluateWithGeo(page, async (qaGeo) => { const gev = window.__godsEyeView; const mod = gev.dataManager.layers.get('traffic').module; const before = mod.getStats().lastUpdate; mod.setParams({ uncoveredRoads: 'hide' }); - const ell = gev.viewer.scene.globe.ellipsoid; const d2r = Math.PI / 180; // Shift far enough to defeat the overlap gate and force a re-render. - gev.viewer.camera.setView({ - destination: ell.cartographicToCartesian({ longitude: -98.51 * d2r, latitude: 29.435 * d2r, height: 2800 }), + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: -98.51 * d2r, latitude: 29.435 * d2r, height: 2800 }), orientation: { heading: 0.4, pitch: -1.25, roll: 0 }, }); // Poll for a NEW render (lastUpdate changes) — the pre-shift stats @@ -289,10 +261,10 @@ async function main() { }); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForFunction( - () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + () => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager, { timeout: 60000 }, ); - await sleep(5000); + await whenSettled(page); await page.keyboard.press('Escape'); let simStats = await settleTraffic(page, { lon: -98.4936, lat: 29.4241, height: 3000 }, { minCount: 100, timeoutS: 45 }); if (!simStats || simStats.count === 0) { @@ -316,7 +288,7 @@ async function main() { if (simStats.mode !== 'sim' || !allWhite || keylessFlowReqs.length !== 0 || !keylessHonest) { exitCode = 1; } - await sleep(1000); + await whenFramesDrawn(page); await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') }, { headful: HEADFUL }); } record('BROWSER: no uncaught page exceptions', pageErrors.length === 0, pageErrors.join('; ')); @@ -324,7 +296,7 @@ async function main() { console.error('\x1b[31mHarness error:\x1b[0m', e); exitCode = 3; } finally { - await browser.close(); + await closeBrowser(browser); } const pass = results.filter((r) => r.ok === true).length; diff --git a/scripts/qa-transit-browser.mjs b/scripts/qa-transit-browser.mjs index 735194ad1..d52663da4 100644 --- a/scripts/qa-transit-browser.mjs +++ b/scripts/qa-transit-browser.mjs @@ -1,3 +1,4 @@ +import { whenFramesDrawn } from './qa/ready.mjs'; export function transitQaBase(value) { if (!value) throw new Error('Transit QA requires an explicit QA_BASE_URL'); const url = new URL(value); @@ -21,12 +22,15 @@ export function boundPageEvaluations(page, defaultTimeoutMs = 30000) { ); const label = String(fn).slice(0, 120).replace(/\s+/g, ' '); return evaluate( - async (source, values, timeoutMs, label) => { + async (source, values, timeoutMs, label, isFunction) => { let timer; try { return await Promise.race([ // Puppeteer callbacks are standalone functions, just as in evaluate. - Promise.resolve().then(() => (0, eval)(`(${source})`)(...values)), + Promise.resolve().then(() => { + const result = (0, eval)(`(${source})`); + return isFunction ? result(...values) : result; + }), new Promise((_, reject) => { timer = setTimeout( () => @@ -47,6 +51,7 @@ export function boundPageEvaluations(page, defaultTimeoutMs = 30000) { args, timeoutMs, label, + typeof fn === 'function', ); }; } @@ -62,8 +67,8 @@ export async function reportTransitVisibility(page, label) { visible: state._visible.size, moving: state._moving.size, held: state._renderHeld, - requestRenderMode: app.viewer.scene.requestRenderMode, - renderLoop: app.viewer.useDefaultRenderLoop, + requestRenderMode: !app.engine.render.isContinuous(), + renderLoop: !app.engine.isDestroyed(), renderErrors: window.__transitRenderErrors || [], bounds: state._viewBounds, hidden: layer._transitVisibilityForTest().filter((e) => !e.shown), @@ -76,13 +81,14 @@ export async function reportTransitVisibility(page, label) { ); if (result.renderLoop === false || result.renderErrors.length) throw new Error( - `${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`, + `${label}: render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`, ); + await whenFramesDrawn(page); return result; } /** Intersect primed histories with the new snapshot; disappearance is availability. */ -export async function restoreRetainedTransit(retained) { +export async function restoreRetainedTransit(qaGeo, retained) { if (!retained.length) return { error: 'No multi-fix history observed during the probe', @@ -117,9 +123,8 @@ export async function restoreRetainedTransit(retained) { const entry = state._vehicles.get( `${history.feedId || 'mbta'}:${history.vehicleId}`, ); - const camera = app.viewer.camera, - C = camera.positionCartographic.constructor; - camera.setView({ + const C = qaGeo.cartographic; + window.__godsEyeView.engine.camera.setView({ destination: C.toCartesian( C.fromDegrees(entry.record.lon, entry.record.lat, 600), ), @@ -173,14 +178,12 @@ export async function sampleTransitPixels(page, palette) { ); }, 5000); const remove = - window.__godsEyeView.viewer.scene.postRender.addEventListener(() => { + window.__godsEyeView.engine.frames.postRender.addEventListener(() => { const app = window.__godsEyeView; const layer = app.dataManager.layers.get('transit').module; const state = layer._transitStateForTest(); - const scene = app.viewer.scene; - const canvas = scene.canvas; - const view = scene.camera.viewMatrix; - const proj = scene.camera.frustum.projectionMatrix; + const { engine } = app; + const canvas = window.__godsEyeView.engine.host.canvas; const mul = (m, v) => [ m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3], m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3], @@ -269,9 +272,12 @@ export async function sampleTransitPixels(page, palette) { for (const entry of state._vehicles.values()) { if (!entry.marker || entry.marker.show === false) continue; const p = entry.marker.position; - const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1])); + const clip = mul( + window.__godsEyeView.engine.projector.viewProjection(), + [p.x, p.y, p.z, 1], + ); if (!(clip[3] > 0)) continue; - const screen = entry.marker.computeScreenSpacePosition?.(scene) || { + const screen = { x: ((clip[0] / clip[3]) * 0.5 + 0.5) * width, y: (1 - ((clip[1] / clip[3]) * 0.5 + 0.5)) * height, }; @@ -492,7 +498,7 @@ export async function sampleTransitPixels(page, palette) { // label or sprite must not masquerade as a dim sensor signature. // Check the whole sampled core, not just its centre pick. let coreOwned = true; - if (scene.pick) { + { const framebuffer = gl.getParameter?.(gl.FRAMEBUFFER_BINDING); for (const core of corePoints) { let u = core.x / width, @@ -519,13 +525,12 @@ export async function sampleTransitPixels(page, palette) { v) * intensity; } - const picked = scene.pick( + const picked = engine.picker.pick( { x: u * width, y: (1 - v) * height }, 1, 1, ); - if (picked?.id !== s.key && picked?.primitive?.id !== s.key) - coreOwned = false; + if (picked?.id !== s.key) coreOwned = false; } if (gl.bindFramebuffer) gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); @@ -657,7 +662,7 @@ export async function sampleTransitPixels(page, palette) { ); resolve(out); }); - window.__godsEyeView.viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); }, palette); } diff --git a/scripts/qa-transit-controls.mjs b/scripts/qa-transit-controls.mjs index 70ed1503c..19b77a526 100644 --- a/scripts/qa-transit-controls.mjs +++ b/scripts/qa-transit-controls.mjs @@ -1,4 +1,4 @@ -const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +import { whenSettled, whenUiSettled } from './qa/ready.mjs'; export const BOSTON = { id: 'boston', feedId: 'mbta', @@ -69,7 +69,7 @@ export function chooseLiveCities(now = new Date()) { * @param {Record} [params] aria-label → slider value * @returns {Promise} how the style was set */ -export async function selectStyle(page, name, params = {}, wait = pause) { +export async function selectStyle(page, name, params = {}) { let how = 'button'; // The presets live in the bottom control panel, which auto-dismisses when // the pointer leaves it: pin it open the way a user does, once. @@ -86,7 +86,7 @@ export async function selectStyle(page, name, params = {}, wait = pause) { { explicit: true }, ); }); - await wait(400); + await whenUiSettled(page); try { await page.$eval(`.style-btn[data-style="${name}"]`, (el) => el.scrollIntoView({ block: 'center' }), @@ -118,7 +118,7 @@ export async function selectStyle(page, name, params = {}, wait = pause) { { timeout: 10000 }, name, ); - await wait(1_800); + await whenUiSettled(page); for (const [label, value] of Object.entries(params)) { const selector = `#param-sliders input.param-slider[aria-label="${label}"]`; await page.waitForSelector(selector, { timeout: 10000 }); @@ -130,7 +130,7 @@ export async function selectStyle(page, name, params = {}, wait = pause) { }, value, ); - await wait(700); + await whenUiSettled(page); } return how; } @@ -141,7 +141,7 @@ export async function selectStyle(page, name, params = {}, wait = pause) { * @param {import('puppeteer').Page} page * @returns {Promise} the profile reached */ -export async function detectOn(page, wait = pause) { +export async function detectOn(page) { // The real button toggles OFF/restore. Density determines its on-profile. const mode = () => page.evaluate(() => { @@ -153,11 +153,7 @@ export async function detectOn(page, wait = pause) { }; }); // The loading screen swallows the first click of a fresh page. - await page - .waitForFunction(() => !document.getElementById('loading-screen'), { - timeout: 30_000, - }) - .catch(() => {}); + await whenSettled(page); // The DISPLAY rail starts collapsed on a first run: open it the way a user // does, then press DETECT until it reports on. await page.evaluate(() => { @@ -175,7 +171,7 @@ export async function detectOn(page, wait = pause) { ); if (!(await mode()).on) { await page.click('#detection-toggle'); - await wait(300); + await whenUiSettled(page); } await page.$eval('#detection-density-slider', (el) => { el.value = '100'; diff --git a/scripts/qa-transit-heading.mjs b/scripts/qa-transit-heading.mjs index 36530c829..2a0a389bd 100644 --- a/scripts/qa-transit-heading.mjs +++ b/scripts/qa-transit-heading.mjs @@ -1,4 +1,7 @@ -import puppeteer from 'puppeteer'; +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { hookRenderErrors } from './qa-browserEvidence.mjs'; +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import { blockPaidQaMaps } from './qa-map-policy.mjs'; import { transitQaBase } from './qa-transit-browser.mjs'; import { chromeExecutablePath } from './qa-chrome.mjs'; @@ -6,18 +9,18 @@ import { chromeExecutablePath } from './qa-chrome.mjs'; /** Prove parked and moving headings in the real scene, without live feeds. */ export async function runTransitHeadingRegression(base, check) { base = transitQaBase(base); - const browser = await puppeteer.launch({ + const browser = await launchBrowser({ headless: true, - executablePath: chromeExecutablePath(), args: [ '--no-sandbox', '--disable-background-timer-throttling', - ...(process.platform === 'darwin' ? ['--use-angle=metal'] : []), + ...(process.platform === 'darwin' ? [] : []), '--enable-gpu', ], }); try { const page = await browser.newPage(); + await hookRenderErrors(page); await blockPaidQaMaps(page); await page.setViewport({ width: 1280, height: 800 }); await page.goto( @@ -28,7 +31,7 @@ export async function runTransitHeadingRegression(base, check) { () => window.__godsEyeView?.dataManager?.layers?.has('transit'), { timeout: 90000 }, ); - await new Promise((resolve) => setTimeout(resolve, 6000)); + await whenSettled(page); await page.evaluate(async () => { const app = window.__godsEyeView; await app.dataManager.setEnabled('transit', true, { source: 'qa' }); @@ -63,26 +66,25 @@ export async function runTransitHeadingRegression(base, check) { parts.rendering.schedulePlayback(entry); } parts.rendering.syncRenderHold(); - window.__headingErrors = []; - app.viewer.scene.renderError.addEventListener((scene, error) => - window.__headingErrors.push(error.message), - ); + window.__headingErrors = window.__qaRenderErrors; }); const pose = async (heading) => { - await page.evaluate((heading) => { - const camera = window.__godsEyeView.viewer.camera; - const C = camera.positionCartographic.constructor; - camera.setView({ - destination: C.toCartesian(C.fromDegrees(-71.0589, 42.3601, 1200)), - orientation: { heading, pitch: -Math.PI / 2, roll: 0 }, - }); - }, heading); - await new Promise((resolve) => setTimeout(resolve, 700)); + await evaluateWithGeo( + page, + async (qaGeo, heading) => { + const C = qaGeo.cartographic; + window.__godsEyeView.engine.camera.setView({ + destination: C.toCartesian(C.fromDegrees(-71.0589, 42.3601, 1200)), + orientation: { heading, pitch: -Math.PI / 2, roll: 0 }, + }); + }, + heading, + ); + await whenSettled(page); }; const sample = () => page.evaluate(async () => { - const app = window.__godsEyeView, - scene = app.viewer.scene; + const app = window.__godsEyeView; const state = app.dataManager.layers .get('transit') .module._transitStateForTest(); @@ -96,12 +98,18 @@ export async function runTransitHeadingRegression(base, check) { ); const project = (p) => { const clip = mul( - scene.camera.frustum.projectionMatrix, - mul(scene.camera.viewMatrix, [p.x, p.y, p.z, 1]), + window.__godsEyeView.engine.projector.viewProjection(), + [p.x, p.y, p.z, 1], ); return { - x: ((clip[0] / clip[3] + 1) * scene.canvas.clientWidth) / 2, - y: ((1 - clip[1] / clip[3]) * scene.canvas.clientHeight) / 2, + x: + ((clip[0] / clip[3] + 1) * + window.__godsEyeView.engine.host.canvas.clientWidth) / + 2, + y: + ((1 - clip[1] / clip[3]) * + window.__godsEyeView.engine.host.canvas.clientHeight) / + 2, }; }; return [0, 1, 2, 3].map((i) => { @@ -139,7 +147,7 @@ export async function runTransitHeadingRegression(base, check) { 'unknown parked course remains screen-up', before[2].rotation === 0 && after[2].rotation === 0, ); - await new Promise((resolve) => setTimeout(resolve, 1200)); + await whenSettled(page); const moved = (await sample())[3]; const dx = moved.x - after[3].x, dy = moved.y - after[3].y; @@ -160,6 +168,6 @@ export async function runTransitHeadingRegression(base, check) { (await page.evaluate(() => window.__headingErrors)).length === 0, ); } finally { - await browser.close(); + await closeBrowser(browser); } } diff --git a/scripts/qa-transit-recovery.mjs b/scripts/qa-transit-recovery.mjs index ff23998e7..5c519b3a8 100644 --- a/scripts/qa-transit-recovery.mjs +++ b/scripts/qa-transit-recovery.mjs @@ -1,5 +1,7 @@ +import { evaluateWithGeo } from './qa/geometry.mjs'; /** Camera-jump regression against an existing server; never starts a server. */ -import puppeteer from 'puppeteer'; +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs'; import { mkdir, writeFile } from 'node:fs/promises'; import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js'; @@ -9,11 +11,10 @@ import { transitQaBase } from './qa-transit-browser.mjs'; const base = transitQaBase(process.env.QA_BASE_URL); const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery'; await mkdir(shots, { recursive: true }); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, - executablePath: chromeExecutablePath(), args: [ - ...(process.platform === 'darwin' ? ['--use-angle=metal'] : []), + ...(process.platform === 'darwin' ? [] : []), '--enable-gpu', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', @@ -35,28 +36,16 @@ try { const app = window.__godsEyeView; await app.dataManager.setEnabled('transit', true, { source: 'qa' }); }); - await page.waitForFunction( - () => { - const scene = window.__godsEyeView.viewer.scene; - return scene.globe.tilesLoaded && scene.globe.show; - }, - { timeout: 60000 }, - ); - await page.waitForFunction( - () => { - const cover = document.getElementById('loading-screen'); - return !cover || cover.classList.contains('hidden'); - }, - { timeout: 60000 }, - ); - await page.evaluate(() => { - const app = window.__godsEyeView, - camera = app.viewer.camera; - const C = camera.positionCartographic.constructor, + await whenTilesLoaded(page, { timeoutMs: 60000 }); + await whenSettled(page); + await evaluateWithGeo(page, async (qaGeo) => { + const C = qaGeo.cartographic, lat = 30.267 + 90 / 111320; const floor = - app.viewer.scene.globe.getHeight(C.fromDegrees(-97.7431, lat)) || 0; - camera.setView({ + window.__godsEyeView.engine.surface.terrainHeight( + C.fromDegrees(-97.7431, lat), + ) || 0; + window.__godsEyeView.engine.camera.setView({ destination: C.toCartesian( C.fromDegrees( -97.7431, @@ -67,29 +56,24 @@ try { orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 }, }); }); - await new Promise((resolve) => setTimeout(resolve, 2000)); - await page.waitForFunction( - () => window.__godsEyeView.viewer.scene.globe.tilesLoaded, - { timeout: 60000 }, - ); - await new Promise((resolve) => setTimeout(resolve, 1500)); - const result = await page.evaluate(async () => { - const app = window.__godsEyeView, - scene = app.viewer.scene, - camera = app.viewer.camera; + await whenSettled(page); + await whenTilesLoaded(page, { timeoutMs: 60000 }); + await whenSettled(page); + const result = await evaluateWithGeo(page, async (qaGeo) => { + const app = window.__godsEyeView; const layer = app.dataManager.layers.get('transit').module; const state = layer._transitStateForTest(), parts = layer._transitPartsForTest(); const { getRegisteredTransitFeed } = await import('/src/data/transitFeeds.js'); const { setRate } = await import('/src/data/contactPlayback.js'); - const C = camera.positionCartographic.constructor; + const C = qaGeo.cartographic; const feed = { ...getRegisteredTransitFeed('capmetro-austin'), id: 'qa-camera-recovery', }; const aim = (lat, height = 600) => - camera.setView({ + window.__godsEyeView.engine.camera.setView({ destination: C.toCartesian( C.fromDegrees( -97.7431, @@ -161,7 +145,7 @@ try { const preparedAtSelection = !!parts.trails.diagnostics()?.body; parts.height.requestHeight = requestHeight; const floor = - scene.globe.getHeight( + window.__godsEyeView.engine.surface.terrainHeight( C.fromDegrees(entry.sample.lon, entry.sample.lat), ) || 0; aim(entry.sample.lat, floor + 600); @@ -170,7 +154,7 @@ try { while (performance.now() - started < 1400) { trail = parts.trails.diagnostics(); if (entry.marker.show && trail?.body?.show && trail.body.ready) break; - scene.requestRender(); + window.__godsEyeView.engine.render.request(); await wait(25); } const status = { @@ -213,8 +197,8 @@ try { const lengths = [0]; for (let i = 1; i < points.length; i++) lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i])); - const canvas = scene.canvas, - gl = scene.context._gl, + const canvas = window.__godsEyeView.engine.host.canvas, + gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2'), scale = canvas.width / canvas.clientWidth; const mul = (m, v) => [0, 1, 2, 3].map( @@ -222,10 +206,12 @@ try { m[i] * v[0] + m[i + 4] * v[1] + m[i + 8] * v[2] + m[i + 12] * v[3], ); const project = (p) => { - const q = mul( - camera.frustum.projectionMatrix, - mul(camera.viewMatrix, [p.x, p.y, p.z, 1]), - ); + const q = mul(window.__godsEyeView.engine.projector.viewProjection(), [ + p.x, + p.y, + p.z, + 1, + ]); return { x: ((q[0] / q[3]) * 0.5 + 0.5) * canvas.clientWidth, y: (0.5 - (q[1] / q[3]) * 0.5) * canvas.clientHeight, @@ -242,7 +228,8 @@ try { (d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]), ); const cart = C.fromCartesian(p); - cart.height = scene.globe.getHeight(cart) || 0; + cart.height = + window.__godsEyeView.engine.surface.terrainHeight(cart) || 0; return { ...project(C.toCartesian(cart)), radius: (Math.SQRT2 * Math.ceil(3 * scale)) / scale, @@ -282,18 +269,20 @@ try { remove(); reject(new Error('No recovery frame')); }, 500); - const remove = scene.postRender.addEventListener(() => { - remove(); - clearTimeout(timer); - resolve(read()); - }); - scene.requestRender(); + const remove = + window.__godsEyeView.engine.frames.postRender.addEventListener(() => { + remove(); + clearTimeout(timer); + resolve(read()); + }); + window.__godsEyeView.engine.render.request(); }); let visible = true; // Body and head together, as the trail's own visibility switch does. - const remove = scene.preRender.addEventListener(() => - trailRenderer.setVisible(visible), - ); + const remove = + window.__godsEyeView.engine.frames.preRender.addEventListener(() => + trailRenderer.setVisible(visible), + ); const captures = []; try { for (const show of [false, true, false, true]) { @@ -316,7 +305,7 @@ try { offAgain, onAgain, tilesReady: - scene.globe.tilesLoaded && + window.__godsEyeView.engine.surface.tilesLoaded() && parts.trails.diagnostics().body === trail.body, elapsedMs: performance.now() - started, // The trail's ground corridors (age bands and head, each with a backing). @@ -350,5 +339,5 @@ try { console.log(JSON.stringify(result)); process.exitCode = pass ? 0 : 1; } finally { - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-transit-scenes.mjs b/scripts/qa-transit-scenes.mjs index fb2918409..144d3d3d8 100644 --- a/scripts/qa-transit-scenes.mjs +++ b/scripts/qa-transit-scenes.mjs @@ -1,3 +1,6 @@ +// Cesium-only probes: native globe and ground-primitive coverage are the Cesium visual oracle. +import { whenTilesLoaded, whenFramesDrawn } from './qa/ready.mjs'; +import { evaluateWithGeo } from './qa/geometry.mjs'; import { captureQaScreenshot } from './qa-chrome.mjs'; import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js'; @@ -89,24 +92,27 @@ export async function runFleetBudgets({ const cdp = await page.createCDPSession(); await cdp.send('HeapProfiler.enable'); for (const count of [800, 3000]) { - await page.evaluate(async (count) => { - const app = window.__godsEyeView; - await app.dataManager.setEnabled('transit', true, { source: 'qa' }); - const camera = app.viewer.camera, - C = camera.positionCartographic.constructor; - camera.setView({ - destination: C.toCartesian( - C.fromDegrees(-71.0605, 42.3554, count === 800 ? 600 : 30000), - ), - orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, - }); - const layer = app.dataManager.layers.get('transit').module; - layer._transitPartsForTest().ingestion.abortAllInFlight(); - layer._transitStateForTest()._activeFeeds.clear(); - for (const key of layer._transitStateForTest()._vehicles.keys()) - layer._transitPartsForTest().ingestion.removeVehicle(key); - }, count); - await wait(8000); + await evaluateWithGeo( + page, + async (qaGeo, count) => { + const app = window.__godsEyeView; + await app.dataManager.setEnabled('transit', true, { source: 'qa' }); + const C = qaGeo.cartographic; + window.__godsEyeView.engine.camera.setView({ + destination: C.toCartesian( + C.fromDegrees(-71.0605, 42.3554, count === 800 ? 600 : 30000), + ), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + const layer = app.dataManager.layers.get('transit').module; + layer._transitPartsForTest().ingestion.abortAllInFlight(); + layer._transitStateForTest()._activeFeeds.clear(); + for (const key of layer._transitStateForTest()._vehicles.keys()) + layer._transitPartsForTest().ingestion.removeVehicle(key); + }, + count, + ); + await whenTilesLoaded(page); // Camera proximity can finish a live poll during tile warm-up. Remove it // before the heap baseline, or its memory would discount the fixture fleet. await page.evaluate(() => { @@ -121,9 +127,9 @@ export async function runFleetBudgets({ for (const key of state._vehicles.keys()) parts.ingestion.removeVehicle(key); parts.rendering.syncRenderHold(); - window.__godsEyeView.viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); - await wait(500); + await whenFramesDrawn(page); await cdp.send('HeapProfiler.collectGarbage'); const before = await cdp.send('Runtime.getHeapUsage'); const loaded = await page.evaluate( @@ -141,7 +147,7 @@ export async function runFleetBudgets({ page, `${count} budget visibility after load`, ); - await wait(2000); + await whenFramesDrawn(page); await cdp.send('HeapProfiler.collectGarbage'); // The retained delta is conservative: it includes concurrent page growth. const retained = await cdp.send('Runtime.getHeapUsage'); @@ -156,7 +162,7 @@ export async function runFleetBudgets({ .get('transit') .module._resetTransitFixtureClockForTest(), ); - await wait(1500); + await whenFramesDrawn(page); await cdp.send('HeapProfiler.startSampling', { samplingInterval: 128, includeObjectsCollectedByMajorGC: true, @@ -315,15 +321,18 @@ export async function runBostonMatrix({ await selectStyle(page, 'normal'); for (const candidate of group.candidates) { background = { ...candidate, name: group.name }; - await page.evaluate((b) => { - const camera = window.__godsEyeView.viewer.camera, - C = camera.positionCartographic.constructor; - camera.setView({ - destination: C.toCartesian(C.fromDegrees(b.lon, b.lat, 600)), - orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, - }); - }, background); - await wait(8000); + await evaluateWithGeo( + page, + async (qaGeo, b) => { + const C = qaGeo.cartographic; + window.__godsEyeView.engine.camera.setView({ + destination: C.toCartesian(C.fromDegrees(b.lon, b.lat, 600)), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + }, + background, + ); + await whenTilesLoaded(page); await page.evaluate( ({ background, routes }) => { const layer = @@ -336,7 +345,7 @@ export async function runBostonMatrix({ }, { background, routes }, ); - await wait(1000); + await whenFramesDrawn(page); backgroundResult = reduceBackground( group.name, await sampleRendered(page), @@ -352,7 +361,7 @@ export async function runBostonMatrix({ ); for (const look of looks) { await selectStyle(page, look.style, look.params); - await wait(1200); + await whenFramesDrawn(page); await reportTransitVisibility( page, `Boston ${background.name}/${look.name} visibility`, @@ -407,7 +416,7 @@ export async function runBostonMatrix({ .selection.selectVehicle(key), target.key, ); - await wait(500); + await whenFramesDrawn(page); await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`, type: 'jpeg', @@ -431,7 +440,7 @@ export async function runBostonMatrix({ restored.color === target.color, ); } - await wait(300); + await whenFramesDrawn(page); await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`, type: 'jpeg', @@ -464,12 +473,11 @@ export async function runBostonLive({ sampleRendered, }) { console.log('\n== Boston live oblique streets =='); - await page.evaluate(async () => { + await evaluateWithGeo(page, async (qaGeo) => { const app = window.__godsEyeView; await app.dataManager.setEnabled('transit', false, { source: 'qa' }); - const camera = app.viewer.camera, - C = camera.positionCartographic.constructor; - camera.setView({ + const C = qaGeo.cartographic; + window.__godsEyeView.engine.camera.setView({ destination: C.toCartesian(C.fromDegrees(-71.0605, 42.3554, 600)), orientation: { heading: 0, pitch: -Math.PI / 4, roll: 0 }, }); @@ -478,7 +486,7 @@ export async function runBostonLive({ layer._setTransitFixtureFloorsForTest([], 0); await layer.update(); }); - await wait(20000); + await whenTilesLoaded(page); await detectOn(page); const evidence = []; for (const view of [ @@ -507,27 +515,30 @@ export async function runBostonLive({ params: {}, }, ]) { - await page.evaluate((view) => { - const app = window.__godsEyeView, - camera = app.viewer.camera, - C = camera.positionCartographic.constructor; - app.dataManager.layers - .get('transit') - .module._transitPartsForTest() - .selection.clearSelection(); - camera.setView({ - destination: C.toCartesian( - C.fromDegrees(-71.0605, 42.3554, view.altitude), - ), - orientation: { - heading: (view.heading * Math.PI) / 180, - pitch: (view.pitch * Math.PI) / 180, - roll: 0, - }, - }); - }, view); + await evaluateWithGeo( + page, + async (qaGeo, view) => { + const app = window.__godsEyeView, + C = qaGeo.cartographic; + app.dataManager.layers + .get('transit') + .module._transitPartsForTest() + .selection.clearSelection(); + window.__godsEyeView.engine.camera.setView({ + destination: C.toCartesian( + C.fromDegrees(-71.0605, 42.3554, view.altitude), + ), + orientation: { + heading: (view.heading * Math.PI) / 180, + pitch: (view.pitch * Math.PI) / 180, + roll: 0, + }, + }); + }, + view, + ); await selectStyle(page, view.style, view.params); - await wait(8000); + await whenTilesLoaded(page); const fleet = await page.evaluate(() => { const state = window.__godsEyeView.dataManager.layers .get('transit') @@ -562,7 +573,7 @@ export async function runBostonLive({ let selection = null; if (target) { await page.mouse.click(target.x, target.y); - await wait(250); + await whenFramesDrawn(page); selection = await page.evaluate((key) => { const state = window.__godsEyeView.dataManager.layers .get('transit') @@ -606,15 +617,15 @@ export async function runTrailVisibility({ }) { const name = 'Google 3D selected trail visible at 6 of 8 projected samples'; await selectStyle(page, 'normal'); - await page.evaluate( - async ({ altitude, pitch }) => { + await evaluateWithGeo( + page, + async (qaGeo, { altitude, pitch }) => { const app = window.__godsEyeView; await app.dataManager.setEnabled('transit', true, { source: 'qa' }); if (app.mapStackController.isStackAvailable('photoreal')) await app.mapStackController.setStack('photoreal'); - const camera = app.viewer.camera, - C = camera.positionCartographic.constructor; - camera.setView({ + const C = qaGeo.cartographic; + window.__godsEyeView.engine.camera.setView({ destination: C.toCartesian( C.fromDegrees( -97.7431, @@ -627,11 +638,11 @@ export async function runTrailVisibility({ }, { altitude, pitch }, ); - await wait(8000); - const setup = await page.evaluate( - async ({ altitude, pitch, feed }) => { - const app = window.__godsEyeView, - scene = app.viewer.scene; + await whenTilesLoaded(page); + const setup = await evaluateWithGeo( + page, + async (qaGeo, { altitude, pitch, feed }) => { + const app = window.__godsEyeView; const layer = app.dataManager.layers.get('transit').module; const state = layer._transitStateForTest(), parts = layer._transitPartsForTest(); @@ -641,11 +652,13 @@ export async function runTrailVisibility({ app.mapStackController.getActiveId() === 'photoreal'; if (!google) return { google: false, reason: 'visible Google 3D tileset required' }; - const C = scene.camera.positionCartographic.constructor; + const C = qaGeo.cartographic; const centreLat = 30.267 + 90 / 111320; - const floor = scene.sampleHeight(C.fromDegrees(-97.7431, centreLat)); + const floor = window.__godsEyeView.engine.surface.sampleHeight( + C.fromDegrees(-97.7431, centreLat), + ); if (Number.isFinite(floor)) - scene.camera.setView({ + window.__godsEyeView.engine.camera.setView({ destination: C.toCartesian( C.fromDegrees( -97.7431, @@ -744,7 +757,7 @@ export async function runTrailVisibility({ const layer = app.dataManager.layers.get('transit').module; const parts = layer._transitPartsForTest(), entry = layer._transitStateForTest()._vehicles.get(key); - const gl = scene.context._gl, + const gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2'), originals = new Map(), wrapped = new Map(); let owner = null, @@ -791,29 +804,35 @@ export async function runTrailVisibility({ return original.apply(this, args); }; } - const pre = scene.preRender.addEventListener(() => { - headBytes = bodyBytes = 0; - const walk = (collection) => { - for (let i = 0; i < collection.length; i++) { - const primitive = collection.get(i); - const kind = window.__qaTransitLineKinds.get(primitive?.gevGroundSet); - if (kind) wrap(primitive, kind); - else if (typeof primitive?.get === 'function') walk(primitive); - } - }; - walk(scene.primitives); - walk(scene.groundPrimitives); - }); - const post = scene.postRender.addEventListener(() => { - frames++; - headUploadMax = Math.max(headUploadMax, headBytes); - bodyUploadMax = Math.max(bodyUploadMax, bodyBytes); - }); + const pre = window.__godsEyeView.engine.frames.preRender.addEventListener( + () => { + headBytes = bodyBytes = 0; + const walk = (collection) => { + for (let i = 0; i < collection.length; i++) { + const primitive = collection.get(i); + const kind = window.__qaTransitLineKinds.get( + primitive?.gevGroundSet, + ); + if (kind) wrap(primitive, kind); + else if (typeof primitive?.get === 'function') walk(primitive); + } + }; + walk(scene.primitives); + walk(scene.groundPrimitives); + }, + ); + const post = window.__godsEyeView.engine.frames.postRender.addEventListener( + () => { + frames++; + headUploadMax = Math.max(headUploadMax, headBytes); + bodyUploadMax = Math.max(bodyUploadMax, bodyBytes); + }, + ); try { // Include a full history upload, even if the first selection already rendered. entry.pathRevision = -1; parts.trails.prepareEntry(entry); - scene.requestRender(); + window.__godsEyeView.engine.render.request(); await new Promise((resolve) => setTimeout(resolve, 20000)); headCpu.sort((a, b) => a - b); return { @@ -832,221 +851,237 @@ export async function runTrailVisibility({ if (!primitive.isDestroyed()) primitive.update = update; } }, setup.key); - const result = await page.evaluate(async (setup) => { - const app = window.__godsEyeView, - scene = app.viewer.scene; - const layer = app.dataManager.layers.get('transit').module; - const state = layer._transitStateForTest(), - parts = layer._transitPartsForTest(); - const entry = state._vehicles.get(setup.key); - const trail = parts.trails.diagnostics(); - if (!entry || !trail) - return { ...setup, present: 0, reason: 'selection disappeared' }; - const { setRate } = await import('/src/data/contactPlayback.js'); - setRate(entry.track, 0, { - wallNowMs: Date.now(), - monoNowMs: performance.now(), - }); - parts.rendering.sampleIdle(entry); - parts.rendering.schedulePlayback(entry); - parts.rendering.syncRenderHold(); - const ready = () => - app.tileset?.show !== false && - app.tileset?.tilesLoaded === true && - (scene.globe?.show === false || scene.globe?.tilesLoaded === true) && - trail.body?.ready === true && - (!trail.headPrimitive || trail.headPrimitive.ready === true); - // Require sustained readiness after freezing playback, before projecting - // onto the refined surface. Repeated captures still check pixel stability. - const tilesReady = await new Promise((resolve) => { - const started = performance.now(); - let readySince = null; - const timer = setInterval(() => { - const now = performance.now(); - readySince = ready() ? (readySince ?? now) : null; - scene.requestRender(); - if ( - (readySince !== null && now - readySince >= 500) || - now - started >= 30000 - ) { - clearInterval(timer); - resolve(readySince !== null && now - readySince >= 500); + const result = await evaluateWithGeo( + page, + async (qaGeo, setup) => { + const app = window.__godsEyeView, + scene = app.viewer.scene; + const layer = app.dataManager.layers.get('transit').module; + const state = layer._transitStateForTest(), + parts = layer._transitPartsForTest(); + const entry = state._vehicles.get(setup.key); + const trail = parts.trails.diagnostics(); + if (!entry || !trail) + return { ...setup, present: 0, reason: 'selection disappeared' }; + const { setRate } = await import('/src/data/contactPlayback.js'); + setRate(entry.track, 0, { + wallNowMs: Date.now(), + monoNowMs: performance.now(), + }); + parts.rendering.sampleIdle(entry); + parts.rendering.schedulePlayback(entry); + parts.rendering.syncRenderHold(); + const ready = () => + app.tileset?.show !== false && + window.__godsEyeView.engine.surface.tilesLoaded() === true && + (scene.globe?.show === false || + window.__godsEyeView.engine.surface.tilesLoaded() === true) && + trail.body?.ready === true && + (!trail.headPrimitive || trail.headPrimitive.ready === true); + // Require sustained readiness after freezing playback, before projecting + // onto the refined surface. Repeated captures still check pixel stability. + const tilesReady = await new Promise((resolve) => { + const started = performance.now(); + let readySince = null; + const timer = setInterval(() => { + const now = performance.now(); + readySince = ready() ? (readySince ?? now) : null; + window.__godsEyeView.engine.render.request(); + if ( + (readySince !== null && now - readySince >= 500) || + now - started >= 30000 + ) { + clearInterval(timer); + resolve(readySince !== null && now - readySince >= 500); + } + }, 80); + }); + if (!tilesReady) + return { + ...setup, + present: 0, + reason: 'tiles or trail geometry did not become ready', + }; + const canvas = window.__godsEyeView.engine.host.canvas, + gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); + const scale = canvas.width / canvas.clientWidth; + const points = entry.trailSegments + .filter((s) => s.toT <= entry.sample.displayT) + .flatMap((s) => s.positions); + if (trail.head?.show) points.push(...trail.head.positions); + if (points.length < 2) + return { ...setup, present: 0, reason: 'no completed path' }; + const C = qaGeo.cartographic; + // Marker positions are plain { x, y, z } now (the display's handles), not + // Cesium Cartesian3 instances with static helpers. + const V = { + distance: (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z), + lerp: (a, b, t) => ({ + x: a.x + (b.x - a.x) * t, + y: a.y + (b.y - a.y) * t, + z: a.z + (b.z - a.z) * t, + }), + }; + // What the floor samples leave out: the Cesium primitives the engine + // draws the transit mover sets and trail lines with. + const drawnByTransit = []; + const walk = (collection) => { + for (let i = 0; i < collection.length; i++) { + const item = collection.get(i); + if ( + String(item?.gevMoverSet ?? '').startsWith('transit') || + String(item?.gevGroundSet ?? '').startsWith('transit-trail-') + ) + drawnByTransit.push(item); + else if (typeof item?.get === 'function' && 'length' in item) + walk(item); } - }, 80); - }); - if (!tilesReady) + }; + walk(scene.primitives); + walk(scene.groundPrimitives); + const lengths = [0]; + for (let i = 1; i < points.length; i++) + lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i])); + const mul = (m, v) => + [0, 1, 2, 3].map( + (i) => + m[i] * v[0] + m[i + 4] * v[1] + m[i + 8] * v[2] + m[i + 12] * v[3], + ); + const samples = Array.from({ length: 8 }, (_, i) => { + const d = (lengths.at(-1) * (i + 1)) / 9; + let j = 1; + while (j < lengths.length - 1 && lengths[j] < d) j++; + const p = V.lerp( + points[j - 1], + points[j], + (d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]), + ); + const cart = C.fromCartesian(p); + const height = window.__godsEyeView.engine.surface.sampleHeight( + cart, + drawnByTransit, + ); + if (Number.isFinite(height)) cart.height = height; + const surface = C.toCartesian(cart); + const q = mul(window.__godsEyeView.engine.projector.viewProjection(), [ + surface.x, + surface.y, + surface.z, + 1, + ]); + return { + x: ((q[0] / q[3]) * 0.5 + 0.5) * canvas.clientWidth, + y: (0.5 - (q[1] / q[3]) * 0.5) * canvas.clientHeight, + inFront: q[3] > 0, + radius: (Math.SQRT2 * Math.ceil(3 * scale)) / scale, + }; + }); + const marker = entry.marker.position; + const markerClip = mul( + window.__godsEyeView.engine.projector.viewProjection(), + [marker.x, marker.y, marker.z, 1], + ); + const sprite = { + x: ((markerClip[0] / markerClip[3]) * 0.5 + 0.5) * canvas.clientWidth, + y: (0.5 - (markerClip[1] / markerClip[3]) * 0.5) * canvas.clientHeight, + // Include the padded, rotated sprite and its selection bracket. + radius: Math.hypot(entry.marker.width, entry.marker.height) / 2 + 4, + }; + const read = () => + samples.map(({ x, y, inFront }) => { + const r = Math.ceil(3 * scale), + px = Math.round(x * scale), + py = Math.round(canvas.height - 1 - y * scale); + if ( + !inFront || + px < r || + py < r || + px + r >= canvas.width || + py + r >= canvas.height + ) + return null; + const data = new Uint8Array((2 * r + 1) ** 2 * 4); + gl.readPixels( + px - r, + py - r, + 2 * r + 1, + 2 * r + 1, + gl.RGBA, + gl.UNSIGNED_BYTE, + data, + ); + return data; + }); + const frame = () => + new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + remove(); + reject(new Error('Trail framebuffer did not render within 5 s')); + }, 5000); + const remove = + window.__godsEyeView.engine.frames.postRender.addEventListener( + () => { + remove(); + clearTimeout(timeout); + resolve(read()); + }, + ); + window.__godsEyeView.engine.render.request(); + }); + const trailRenderer = window.__qaTransitTrail; + if (!trailRenderer) + return { ...setup, present: 0, reason: 'no trail renderer captured' }; + let showTrail = false; + // Body and head together, as the trail's own visibility switch does. + const applyVisibility = () => trailRenderer.setVisible(showTrail); + // Override the layer's frame update in both states; selection stays fixed. + const removeVisibility = + window.__godsEyeView.engine.frames.preRender.addEventListener( + applyVisibility, + ); + const captures = []; + let captureReady = true; + try { + for (const visible of [false, true, false, true]) { + showTrail = visible; + // Let post-processing settle before each measured framebuffer. + for (let i = 0; i < 3; i++) await frame(); + captures.push(await frame()); + const current = parts.trails.diagnostics(); + captureReady &&= + ready() && + current?.body === trail.body && + current?.headPrimitive === trail.headPrimitive; + } + } finally { + removeVisibility(); + trailRenderer.setVisible(true); + await frame(); + } + const [off, on, offAgain, onAgain] = captures.map((patches) => + patches.map((p) => (p ? Array.from(p) : null)), + ); return { ...setup, - present: 0, - reason: 'tiles or trail geometry did not become ready', + fixes: entry.track.count, + selected: state._selectedKey === setup.key, + pathMetres: lengths.at(-1), + samples, + on, + off, + onAgain, + offAgain, + sprite, + tilesReady: captureReady, + elapsedDisplayMs: entry.sample.displayT - setup.startDisplayT, + movedMetres: Math.abs(entry.sample.lat - setup.startLat) * 111320, + lines: trail.body?.lines ?? 0, + headStrategy: trail.headStrategy, + headRebuilds: trail.headRebuilds ?? null, }; - const canvas = scene.canvas, - gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); - const scale = canvas.width / canvas.clientWidth; - const points = entry.trailSegments - .filter((s) => s.toT <= entry.sample.displayT) - .flatMap((s) => s.positions); - if (trail.head?.show) points.push(...trail.head.positions); - if (points.length < 2) - return { ...setup, present: 0, reason: 'no completed path' }; - const C = scene.camera.positionCartographic.constructor; - // Marker positions are plain { x, y, z } now (the display's handles), not - // Cesium Cartesian3 instances with static helpers. - const V = { - distance: (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z), - lerp: (a, b, t) => ({ - x: a.x + (b.x - a.x) * t, - y: a.y + (b.y - a.y) * t, - z: a.z + (b.z - a.z) * t, - }), - }; - // What the floor samples leave out: the Cesium primitives the engine - // draws the transit mover sets and trail lines with. - const drawnByTransit = []; - const walk = (collection) => { - for (let i = 0; i < collection.length; i++) { - const item = collection.get(i); - if ( - String(item?.gevMoverSet ?? '').startsWith('transit') || - String(item?.gevGroundSet ?? '').startsWith('transit-trail-') - ) - drawnByTransit.push(item); - else if (typeof item?.get === 'function' && 'length' in item) - walk(item); - } - }; - walk(scene.primitives); - walk(scene.groundPrimitives); - const lengths = [0]; - for (let i = 1; i < points.length; i++) - lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i])); - const mul = (m, v) => - [0, 1, 2, 3].map( - (i) => - m[i] * v[0] + m[i + 4] * v[1] + m[i + 8] * v[2] + m[i + 12] * v[3], - ); - const samples = Array.from({ length: 8 }, (_, i) => { - const d = (lengths.at(-1) * (i + 1)) / 9; - let j = 1; - while (j < lengths.length - 1 && lengths[j] < d) j++; - const p = V.lerp( - points[j - 1], - points[j], - (d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]), - ); - const cart = C.fromCartesian(p); - const height = scene.sampleHeight(cart, drawnByTransit); - if (Number.isFinite(height)) cart.height = height; - const surface = C.toCartesian(cart); - const q = mul( - scene.camera.frustum.projectionMatrix, - mul(scene.camera.viewMatrix, [surface.x, surface.y, surface.z, 1]), - ); - return { - x: ((q[0] / q[3]) * 0.5 + 0.5) * canvas.clientWidth, - y: (0.5 - (q[1] / q[3]) * 0.5) * canvas.clientHeight, - inFront: q[3] > 0, - radius: (Math.SQRT2 * Math.ceil(3 * scale)) / scale, - }; - }); - const marker = entry.marker.position; - const markerClip = mul( - scene.camera.frustum.projectionMatrix, - mul(scene.camera.viewMatrix, [marker.x, marker.y, marker.z, 1]), - ); - const sprite = { - x: ((markerClip[0] / markerClip[3]) * 0.5 + 0.5) * canvas.clientWidth, - y: (0.5 - (markerClip[1] / markerClip[3]) * 0.5) * canvas.clientHeight, - // Include the padded, rotated sprite and its selection bracket. - radius: Math.hypot(entry.marker.width, entry.marker.height) / 2 + 4, - }; - const read = () => - samples.map(({ x, y, inFront }) => { - const r = Math.ceil(3 * scale), - px = Math.round(x * scale), - py = Math.round(canvas.height - 1 - y * scale); - if ( - !inFront || - px < r || - py < r || - px + r >= canvas.width || - py + r >= canvas.height - ) - return null; - const data = new Uint8Array((2 * r + 1) ** 2 * 4); - gl.readPixels( - px - r, - py - r, - 2 * r + 1, - 2 * r + 1, - gl.RGBA, - gl.UNSIGNED_BYTE, - data, - ); - return data; - }); - const frame = () => - new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - remove(); - reject(new Error('Trail framebuffer did not render within 5 s')); - }, 5000); - const remove = scene.postRender.addEventListener(() => { - remove(); - clearTimeout(timeout); - resolve(read()); - }); - scene.requestRender(); - }); - const trailRenderer = window.__qaTransitTrail; - if (!trailRenderer) - return { ...setup, present: 0, reason: 'no trail renderer captured' }; - let showTrail = false; - // Body and head together, as the trail's own visibility switch does. - const applyVisibility = () => trailRenderer.setVisible(showTrail); - // Override the layer's frame update in both states; selection stays fixed. - const removeVisibility = scene.preRender.addEventListener(applyVisibility); - const captures = []; - let captureReady = true; - try { - for (const visible of [false, true, false, true]) { - showTrail = visible; - // Let post-processing settle before each measured framebuffer. - for (let i = 0; i < 3; i++) await frame(); - captures.push(await frame()); - const current = parts.trails.diagnostics(); - captureReady &&= - ready() && - current?.body === trail.body && - current?.headPrimitive === trail.headPrimitive; - } - } finally { - removeVisibility(); - trailRenderer.setVisible(true); - await frame(); - } - const [off, on, offAgain, onAgain] = captures.map((patches) => - patches.map((p) => (p ? Array.from(p) : null)), - ); - return { - ...setup, - fixes: entry.track.count, - selected: state._selectedKey === setup.key, - pathMetres: lengths.at(-1), - samples, - on, - off, - onAgain, - offAgain, - sprite, - tilesReady: captureReady, - elapsedDisplayMs: entry.sample.displayT - setup.startDisplayT, - movedMetres: Math.abs(entry.sample.lat - setup.startLat) * 111320, - lines: trail.body?.lines ?? 0, - headStrategy: trail.headStrategy, - headRebuilds: trail.headRebuilds ?? null, - }; - }, setup); + }, + setup, + ); const pixels = reduceTrailPixels(result.on, result.off, result); result.cost = cost; result.altitude = altitude; diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index 28aeee4b4..8f1e36cb0 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -1,4 +1,9 @@ #!/usr/bin/env node +// Cesium-only probes: native entity counts audit Cesium resource ownership alongside the contract checks. +import { whenSettled, whenUiSettled, whenFramesDrawn } from './qa/ready.mjs'; +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { hookRenderErrors } from './qa-browserEvidence.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import { runTransitHeadingRegression } from './qa-transit-heading.mjs'; import { boundPageEvaluations, @@ -36,7 +41,6 @@ import { * exercises the same paths. */ import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import puppeteer from 'puppeteer'; import { blockPaidQaMaps } from './qa-map-policy.mjs'; import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs'; import { createTransitHistory } from '../server/providers/transitHistory.js'; @@ -167,13 +171,10 @@ async function sampleScreen(page) { const app = window.__godsEyeView; const layer = app.dataManager.layers.get('transit').module; const state = layer._transitStateForTest(); - const scene = app.viewer.scene; - const canvas = scene.canvas; + const canvas = window.__godsEyeView.engine.host.canvas; // The app exposes no `window.Cesium`, so the projection is done here in // plain numbers: Cesium matrices are column-major arrays of 16 doubles, // and window coordinates are the standard clip -> NDC -> viewport walk. - const view = scene.camera.viewMatrix; - const proj = scene.camera.frustum.projectionMatrix; const mul = (m, v) => [ m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3], m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3], @@ -186,7 +187,12 @@ async function sampleScreen(page) { for (const entry of state._vehicles.values()) { if (!entry.marker || entry.marker.show === false) continue; const p = entry.marker.position; - const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1])); + const clip = mul(window.__godsEyeView.engine.projector.viewProjection(), [ + p.x, + p.y, + p.z, + 1, + ]); if (!(clip[3] > 0)) continue; const ndcX = clip[0] / clip[3]; const ndcY = clip[1] / clip[3]; @@ -207,11 +213,11 @@ async function sampleScreen(page) { } async function sampleVisible(page) { - return page.evaluate(() => { + return evaluateWithGeo(page, async (qaGeo) => { const app = window.__godsEyeView; const layer = app.dataManager.layers.get('transit').module; const state = layer._transitStateForTest(); - const Cartographic = app.viewer.camera.positionCartographic.constructor; + const Cartographic = qaGeo.cartographic; return [...state._vehicles.values()] .filter((entry) => entry.marker && entry.marker.show !== false) .slice(0, 200) @@ -263,13 +269,11 @@ check( historyProbe.clear(); await mkdir(SHOTS, { recursive: true }); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, protocolTimeout: 150000, - executablePath: chromeExecutablePath(), args: [ '--no-sandbox', - '--use-angle=metal', '--enable-gpu', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', @@ -278,6 +282,7 @@ const browser = await puppeteer.launch({ ], }); const page = await browser.newPage(); +await hookRenderErrors(page); await blockPaidQaMaps(page); // This gate measures visible-page motion. OS minimization must not silently // switch it into the app's intentional hidden-tab polling pause. @@ -320,7 +325,7 @@ try { Boolean(document.querySelector('[data-layer-id="transit"]')), { timeout: 90_000 }, ); - await wait(6_000); + await whenSettled(page); // The Data Layers panel starts collapsed; a user opens it before toggling. const expanded = await page.$eval( @@ -331,7 +336,7 @@ try { return false; }, ); - if (!expanded) await wait(800); + if (!expanded) await whenUiSettled(page); check( 'the Data Layers panel opens from its own header button', await page.$eval( @@ -371,7 +376,7 @@ try { await page.$eval(row, (element) => element.scrollIntoView({ block: 'center' }), ); - await wait(400); + await whenUiSettled(page); const before = transitRequests.length; await page.click(`${row} .data-toggle-btn`); await page.waitForFunction( @@ -385,24 +390,24 @@ try { ); await page.evaluate(() => { - window.__transitRenderErrors = []; - window.__godsEyeView.viewer.scene.renderError.addEventListener( - (scene, error) => window.__transitRenderErrors.push(error.message), - ); + window.__transitRenderErrors = window.__qaRenderErrors; }); for (const city of runs('cities') ? CITIES : []) { console.log(`\n== ${city.label} ==`); // City-wide first: enough altitude to see the fleet as a fleet. - await page.evaluate((c) => { - const camera = window.__godsEyeView.viewer.camera; - const Cartographic = camera.positionCartographic.constructor; - camera.setView({ - destination: Cartographic.toCartesian( - Cartographic.fromDegrees(c.lon, c.lat - 0.03, 3_500), - ), - orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 }, - }); - }, city); + await evaluateWithGeo( + page, + async (qaGeo, c) => { + const Cartographic = qaGeo.cartographic; + window.__godsEyeView.engine.camera.setView({ + destination: Cartographic.toCartesian( + Cartographic.fromDegrees(c.lon, c.lat - 0.03, 3_500), + ), + orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 }, + }); + }, + city, + ); const arrived = await page .waitForFunction( (feedId) => @@ -428,7 +433,7 @@ try { ); continue; } - await wait(9_000); + await whenSettled(page); const fleet = await page.evaluate((feedId) => { const layer = window.__godsEyeView.dataManager.layers.get('transit').module; @@ -451,49 +456,52 @@ try { // Street level: 600 m up, 45 degrees down, framed on a real vehicle so the // shot shows what a user sees when they drop in on one. - const framed = await page.evaluate((c) => { - const app = window.__godsEyeView; - const camera = app.viewer.camera; - const Cartographic = camera.positionCartographic.constructor; - const state = app.dataManager.layers - .get('transit') - .module._transitStateForTest(); - let best = null; - for (const entry of state._vehicles.values()) { - if (!entry.key.startsWith(`${c.feedId}:`)) continue; - const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon); - if (!best || d < best.d) - best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key }; - } - if (!best) return null; - // Camera 600 m back along the heading, 600 m up, looking 45 degrees down: - // the vehicle lands in the middle of the frame. - const heading = (c.heading * Math.PI) / 180; - const lat = best.lat - (600 * Math.cos(heading)) / 111_320; - const lon = - best.lon - - (600 * Math.sin(heading)) / - (111_320 * Math.cos((best.lat * Math.PI) / 180)); - camera.setView({ - destination: Cartographic.toCartesian( - Cartographic.fromDegrees(lon, lat, 600), - ), - orientation: { heading, pitch: (-45 * Math.PI) / 180, roll: 0 }, - }); - return best; - }, city); + const framed = await evaluateWithGeo( + page, + async (qaGeo, c) => { + const app = window.__godsEyeView; + const Cartographic = qaGeo.cartographic; + const state = app.dataManager.layers + .get('transit') + .module._transitStateForTest(); + let best = null; + for (const entry of state._vehicles.values()) { + if (!entry.key.startsWith(`${c.feedId}:`)) continue; + const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon); + if (!best || d < best.d) + best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key }; + } + if (!best) return null; + // Camera 600 m back along the heading, 600 m up, looking 45 degrees down: + // the vehicle lands in the middle of the frame. + const heading = (c.heading * Math.PI) / 180; + const lat = best.lat - (600 * Math.cos(heading)) / 111_320; + const lon = + best.lon - + (600 * Math.sin(heading)) / + (111_320 * Math.cos((best.lat * Math.PI) / 180)); + window.__godsEyeView.engine.camera.setView({ + destination: Cartographic.toCartesian( + Cartographic.fromDegrees(lon, lat, 600), + ), + orientation: { heading, pitch: (-45 * Math.PI) / 180, roll: 0 }, + }); + return best; + }, + city, + ); check( `${city.label}: a vehicle to fly to`, Boolean(framed), framed?.key || 'none', ); - await wait(9_000); + await whenSettled(page); - const first = await page.evaluate(() => { + const first = await evaluateWithGeo(page, async (qaGeo) => { const app = window.__godsEyeView; const layer = app.dataManager.layers.get('transit').module; const state = layer._transitStateForTest(); - const Cartographic = app.viewer.camera.positionCartographic.constructor; + const Cartographic = qaGeo.cartographic; return { count: state._vehicles.size, stats: layer.getStats(), @@ -528,18 +536,19 @@ try { const state = app.dataManager.layers .get('transit') .module._transitStateForTest(); - const scene = app.viewer.scene; const seen = []; for (const entry of state._vehicles.values()) { - const screen = scene.cartesianToCanvasCoordinates( + const screen = window.__godsEyeView.engine.projector.worldToWindow( entry.marker.position, ); if ( screen && screen.x > 360 && - screen.x < scene.canvas.clientWidth - 120 && + screen.x < + window.__godsEyeView.engine.host.canvas.clientWidth - 120 && screen.y > 120 && - screen.y < scene.canvas.clientHeight - 160 + screen.y < + window.__godsEyeView.engine.host.canvas.clientHeight - 160 ) { seen.push({ key: entry.key, @@ -552,7 +561,7 @@ try { }, attempt); if (!target) break; await page.mouse.click(target.x, target.y); - await wait(900); + await whenUiSettled(page); selected = await page.evaluate(() => { const app = window.__godsEyeView; const key = app.dataManager.layers @@ -711,7 +720,7 @@ try { }); for (const preset of presets) { const how = await selectStyle(page, preset.style, preset.params); - await wait(1_200); + await whenUiSettled(page); const styling = await page.evaluate(() => window.__godsEyeView.dataManager.layers .get('transit') @@ -782,7 +791,7 @@ try { const clickTarget = rendered[0]; if (clickTarget) { await page.mouse.click(clickTarget.x, clickTarget.y); - await wait(100); + await whenUiSettled(page); const picked = await page.evaluate((key) => { const state = window.__godsEyeView.dataManager.layers .get('transit') @@ -816,7 +825,7 @@ try { const target = (await sampleRendered(page))[0]; if (target) { await page.mouse.click(target.x, target.y); - await wait(800); + await whenUiSettled(page); const underFlir = await page.evaluate((key) => { const layer = window.__godsEyeView.dataManager.layers.get('transit').module; @@ -851,7 +860,7 @@ try { JSON.stringify(underFlir), ); await page.keyboard.press('Escape'); - await wait(500); + await whenUiSettled(page); } else { check( 'clicking a sprite under FLIR selects it and keeps it white-hot', @@ -881,16 +890,18 @@ try { // Motion acceptance uses known report-time playback even when live fleets park. await selectStyle(page, 'normal'); - await page.evaluate((city) => { - const app = window.__godsEyeView, - camera = app.viewer.camera; - const C = camera.positionCartographic.constructor; - camera.setView({ - destination: C.toCartesian(C.fromDegrees(city.lon, city.lat, 600)), - orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, - }); - }, city); - await wait(2000); + await evaluateWithGeo( + page, + async (qaGeo, city) => { + const C = qaGeo.cartographic; + window.__godsEyeView.engine.camera.setView({ + destination: C.toCartesian(C.fromDegrees(city.lon, city.lat, 600)), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + }, + city, + ); + await whenUiSettled(page); await page.evaluate( (city) => window.__godsEyeView.dataManager.layers @@ -901,11 +912,11 @@ try { await wait(1000); const motionFirst = await sampleVisible(page); await wait(30_000); - const second = await page.evaluate(() => { + const second = await evaluateWithGeo(page, async (qaGeo) => { const app = window.__godsEyeView; const layer = app.dataManager.layers.get('transit').module; const state = layer._transitStateForTest(); - const Cartographic = app.viewer.camera.positionCartographic.constructor; + const Cartographic = qaGeo.cartographic; return { count: state._vehicles.size, // The RENDERED primitive position, read back through Cesium — not the @@ -1052,6 +1063,7 @@ try { if (runs('history')) { console.log('\n== retained history reload =='); const recovery = await browser.newPage(); + await hookRenderErrors(recovery); await blockPaidQaMaps(recovery); boundPageEvaluations(recovery); try { @@ -1106,10 +1118,7 @@ try { { timeout: 90000 }, ); await recovery.evaluate(() => { - window.__transitRenderErrors = []; - window.__godsEyeView.viewer.scene.renderError.addEventListener( - (scene, error) => window.__transitRenderErrors.push(error.message), - ); + window.__transitRenderErrors = window.__qaRenderErrors; }); const restored = await recovery.evaluate( restoreRetainedTransit, @@ -1124,7 +1133,7 @@ try { .trails.requestDiagnostics().pending, { timeout: 20000 }, ); - await wait(4000); + await whenFramesDrawn(recovery); const proof = await recovery.evaluate( ({ key, oldest }) => { const layer = @@ -1189,16 +1198,19 @@ try { // every rendered frame is sampled. if (runs('playback')) { console.log('\n== deterministic playback =='); - await page.evaluate((c) => { - const camera = window.__godsEyeView.viewer.camera; - const Cartographic = camera.positionCartographic.constructor; - camera.setView({ - destination: Cartographic.toCartesian( - Cartographic.fromDegrees(c.lon, c.lat + 0.005, 2400), - ), - orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, - }); - }, BOSTON); + await evaluateWithGeo( + page, + async (qaGeo, c) => { + const Cartographic = qaGeo.cartographic; + window.__godsEyeView.engine.camera.setView({ + destination: Cartographic.toCartesian( + Cartographic.fromDegrees(c.lon, c.lat + 0.005, 2400), + ), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + }, + BOSTON, + ); await page.evaluate(() => { window.__godsEyeView.dataManager.layers .get('transit') @@ -1345,14 +1357,14 @@ try { console.log(` scripted fleet before trace: ${await simState()}`); await reportTransitVisibility(page, 'scripted visibility before trace'); // Sample every rendered frame for two minutes. - const trace = await page.evaluate( - (durationMs) => + const trace = await evaluateWithGeo( + page, + (qaGeo, durationMs) => new Promise((resolve) => { const app = window.__godsEyeView; const layer = app.dataManager.layers.get('transit').module; const state = layer._transitStateForTest(); - const Cartographic = - app.viewer.camera.positionCartographic.constructor; + const Cartographic = qaGeo.cartographic; const keys = [ 'sim-straight', 'sim-corner', @@ -1410,7 +1422,10 @@ try { resolve({ out, words }); } }; - remove = app.viewer.scene.postRender.addEventListener(tick); + remove = + window.__godsEyeView.engine.frames.postRender.addEventListener( + tick, + ); }), 120_000, ); @@ -1552,23 +1567,22 @@ try { // ---- Card anchor synchronisation --------------------------------------- console.log('\n== card anchor =='); - await page.evaluate(() => { - const app = window.__godsEyeView, - camera = app.viewer.camera; + await evaluateWithGeo(page, async (qaGeo) => { + const app = window.__godsEyeView; const entry = app.dataManager.layers .get('transit') .module._transitStateForTest() ._vehicles.get('mbta:sim-straight'); if (!entry) return; - const C = camera.positionCartographic.constructor; + const C = qaGeo.cartographic; const at = C.fromCartesian(entry.marker.position); at.height = 600; - camera.setView({ + window.__godsEyeView.engine.camera.setView({ destination: C.toCartesian(at), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); }); - await wait(1500); + await whenUiSettled(page); await reportTransitVisibility(page, 'scripted visibility before click'); const target = await page.evaluate(() => { const app = window.__godsEyeView; @@ -1576,10 +1590,7 @@ try { const state = layer._transitStateForTest(); const entry = state._vehicles.get('mbta:sim-straight'); if (!entry?.marker?.show) return null; - const scene = app.viewer.scene; - const canvas = scene.canvas; - const view = scene.camera.viewMatrix; - const proj = scene.camera.frustum.projectionMatrix; + const canvas = window.__godsEyeView.engine.host.canvas; const mul = (m, v) => [ m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3], m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3], @@ -1587,7 +1598,12 @@ try { m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3], ]; const p = entry.marker.position; - const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1])); + const clip = mul(window.__godsEyeView.engine.projector.viewProjection(), [ + p.x, + p.y, + p.z, + 1, + ]); const x = ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth; const y = (1 - ((clip[1] / clip[3]) * 0.5 + 0.5)) * canvas.clientHeight; if ( @@ -1615,7 +1631,7 @@ try { JSON.stringify(target), ); await page.mouse.click(target.x, target.y); - await wait(600); + await whenUiSettled(page); anchor = await page.evaluate( async (key, durationMs) => { const overlay = ( @@ -1637,8 +1653,7 @@ try { ).pointerOwner(), }, }; - const scene = app.viewer.scene; - const canvas = scene.canvas; + const canvas = window.__godsEyeView.engine.host.canvas; const mul = (m, v) => [ m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3], m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3], @@ -1659,10 +1674,11 @@ try { const entry = state._vehicles.get(key); const rect = overlay.getPaintRect('transit-selected', key); if (entry?.marker) { - const view = scene.camera.viewMatrix; - const proj = scene.camera.frustum.projectionMatrix; const p = entry.marker.position; - const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1])); + const clip = mul( + window.__godsEyeView.engine.projector.viewProjection(), + [p.x, p.y, p.z, 1], + ); if ( entry.marker.show !== false && rect && @@ -1698,8 +1714,8 @@ try { if (trail.head.show && trail.head.positions.length) { const project = (p) => { const clip = mul( - scene.camera.frustum.projectionMatrix, - mul(scene.camera.viewMatrix, [p.x, p.y, p.z, 1]), + window.__godsEyeView.engine.projector.viewProjection(), + [p.x, p.y, p.z, 1], ); return [ ((clip[0] / clip[3]) * canvas.clientWidth) / 2, @@ -1742,7 +1758,10 @@ try { }); }; const timer = setTimeout(finish, durationMs + 1000); - remove = scene.postRender.addEventListener(tick); + remove = + window.__godsEyeView.engine.frames.postRender.addEventListener( + tick, + ); }); }, target.key, @@ -1816,21 +1835,24 @@ try { afterRemoval.gone && afterRemoval.selected !== 'mbta:sim-straight', JSON.stringify(afterRemoval), ); - await page.evaluate((c) => { - const camera = window.__godsEyeView.viewer.camera; - const Cartographic = camera.positionCartographic.constructor; - camera.setView({ - destination: Cartographic.toCartesian( - Cartographic.fromDegrees(c.lon, c.lat, 600), - ), - orientation: { - heading: (c.heading * Math.PI) / 180, - pitch: (-45 * Math.PI) / 180, - roll: 0, - }, - }); - }, CITIES.at(-1)); - await wait(4_000); + await evaluateWithGeo( + page, + async (qaGeo, c) => { + const Cartographic = qaGeo.cartographic; + window.__godsEyeView.engine.camera.setView({ + destination: Cartographic.toCartesian( + Cartographic.fromDegrees(c.lon, c.lat, 600), + ), + orientation: { + heading: (c.heading * Math.PI) / 180, + pitch: (-45 * Math.PI) / 180, + roll: 0, + }, + }); + }, + CITIES.at(-1), + ); + await whenSettled(page); } const sceneQA = { @@ -1921,7 +1943,7 @@ try { if (button.getAttribute('aria-expanded') !== 'true') button.click(); }, ); - await wait(1_200); + await whenFramesDrawn(page); check( 'the CONTEXT panel opens from its own header button', await page.$eval( @@ -1937,12 +1959,12 @@ try { origin: 'user', }), ); - await wait(6_000); + await whenFramesDrawn(page); check('transit is on before entering a Context mode', await isOn()); // CONTACTS await page.click('#global-context-flights-btn'); - await wait(18_000); + await whenFramesDrawn(page); const inContacts = { transit: await isOn(), vehicles: await page.evaluate( @@ -1959,7 +1981,7 @@ try { JSON.stringify(inContacts), ); await page.click('#global-context-flights-btn'); - await wait(12_000); + await whenFramesDrawn(page); check('leaving Contacts restores Transit', await isOn()); // SPACE MISSIONS @@ -1968,9 +1990,9 @@ try { origin: 'user', }), ); - await wait(2_000); + await whenFramesDrawn(page); await page.click('#global-context-missions-btn'); - await wait(20_000); + await whenFramesDrawn(page); const blocked = await page.evaluate(async () => { const settled = await window.__godsEyeView.dataManager.setEnabled( 'transit', @@ -1988,7 +2010,7 @@ try { JSON.stringify(blocked), ); await page.click('#global-context-missions-btn'); - await wait(12_000); + await whenFramesDrawn(page); check( 'leaving Space Missions leaves Transit off, as it was on entry', (await isOn()) === false, @@ -2017,7 +2039,7 @@ try { error.stack || error.message, ); } finally { - await browser.close(); + await closeBrowser(browser); } console.log( failures diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs index 9c351f388..636e10f1f 100644 --- a/scripts/qa-ui-disposal.mjs +++ b/scripts/qa-ui-disposal.mjs @@ -1,13 +1,12 @@ #!/usr/bin/env node +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; /** Exercise the real UI disposal path, including unrelated resource owners. */ -import puppeteer from 'puppeteer'; -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: [ '--no-sandbox', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), ], }); const page = await browser.newPage(); @@ -24,11 +23,10 @@ try { { waitUntil: 'domcontentloaded' }, ); await page.waitForFunction( - () => - window.__godsEyeView?.styleManager?._dataManager && - document.getElementById('loading-screen')?.classList.contains('hidden'), + () => window.__godsEyeView?.styleManager?._dataManager, { timeout: 60000 }, ); + await whenSettled(page); const result = await page.evaluate(async () => { const ui = window.__godsEyeView.styleManager; const counts = {}; @@ -287,6 +285,6 @@ try { errors.length === 0, ); } finally { - await browser.close(); + await closeBrowser(browser); } if (failures) process.exitCode = 1; diff --git a/scripts/qa-vessel-cards.mjs b/scripts/qa-vessel-cards.mjs index 4aafaf22c..3408b7797 100644 --- a/scripts/qa-vessel-cards.mjs +++ b/scripts/qa-vessel-cards.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenFramesDrawn } from './qa/ready.mjs'; /** * qa-vessel-cards.mjs — operator-side visual proof harness for AIS cards in * the shared world-overlay host. @@ -26,10 +28,10 @@ * The name and manifest both record the data source; the manifest also records * headless/headful launch mode and the browser's WebGL renderer. */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import puppeteer from 'puppeteer'; const argv = process.argv.slice(2); const getOpt = (name, dflt) => { @@ -220,28 +222,7 @@ function syntheticVesselRows(portKey, port) { })); } -const CHROME_EXECUTABLE_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - // Prefer puppeteer's version-pinned Chrome-for-Testing over the system - // Chrome: /Applications auto-updates underneath the harnesses, and its - // software-GL behavior shifts across majors (system Chrome 150 blew the - // tile-gated drain budget under SwiftShader on 2026-07-30 — six - // false-negative qa-cctv-v2 runs against a healthy build). A deterministic - // pinned browser beats the newest one for regression harnesses. - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -function findChromeExecutable() { - for (const candidate of CHROME_EXECUTABLE_CANDIDATES) { - try { - if (fs.existsSync(candidate)) return candidate; - } catch { /* fall through to Puppeteer's cache */ } - } - return null; -} async function main() { console.log(`\nAIS Vessel Cards — visual proof harness`); @@ -265,19 +246,9 @@ async function main() { fs.mkdirSync(OUT_DIR, { recursive: true }); - const chromeExecutable = findChromeExecutable(); - const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', - ...(chromeExecutable ? { executablePath: chromeExecutable } : {}), - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--use-angle=metal', - '--disable-dev-shm-usage', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--window-size=1600,900', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1600,900'], }); let failures = 0; @@ -312,10 +283,10 @@ async function main() { if (ENGINE) await page.waitForFunction(() => !!window.__godsEyeView?.layers, { timeout: 180000 }); else - await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 60000 }); + await page.waitForFunction(() => !!window.__godsEyeView?.engine, { timeout: 60000 }); // Frame the port (kill the intro flight first) and enable the layer. - const dataEvidence = await page.evaluate(async ({ p, dataMode, fixtureRows, engineNeutral }) => { + const dataEvidence = await evaluateWithGeo(page, async (qaGeo, { p, dataMode, fixtureRows, engineNeutral }) => { const gev = window.__godsEyeView; if (engineNeutral) { const geo = await import('/src/geo/index.js'); @@ -325,16 +296,14 @@ async function main() { orientation: { heading: p.heading, pitch: p.pitch, roll: 0 }, }); } else { - const v = gev.viewer; - v.camera.cancelFlight?.(); - v.scene.tweens?.removeAll?.(); + window.__godsEyeView.engine.camera.cancelFlight?.(); const carto = { longitude: (p.lon * Math.PI) / 180, latitude: (p.lat * Math.PI) / 180, height: p.height, }; - v.camera.setView({ - destination: v.scene.globe.ellipsoid.cartographicToCartesian(carto), + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian(carto), orientation: { heading: p.heading, pitch: p.pitch, roll: 0 }, }); } @@ -347,7 +316,7 @@ async function main() { throw new Error(`Synthetic AIS injection failed (${result?.count || 0}/${fixtureRows.length})`); } if (engineNeutral) gev.engine.render.request('qa-vessel-cards'); - else gev.viewer.scene.requestRender?.(); + else window.__godsEyeView.engine.render.request?.(); return { mode: 'synthetic', injected: result.count }; }, { p: port, dataMode: DATA_MODE, fixtureRows: syntheticRows, engineNeutral: Boolean(ENGINE) }); console.log( @@ -361,7 +330,7 @@ async function main() { const gev = window.__godsEyeView; const tilesLoaded = engineNeutral ? gev.engine.surface.tilesLoaded() - : gev.tileset?.tilesLoaded; + : window.__godsEyeView.engine.surface.tilesLoaded(); const ais = gev.dataManager.getAll().find((l) => l.id === 'ais-live-vessels'); const count = ais?.stats?.count ?? 0; return tilesLoaded && count > 0; @@ -372,7 +341,7 @@ async function main() { // Re-assert the port framing: on slow (SwiftShader) runs the app's // intro flight can start AFTER the first cancelFlight and land mid-wait, // dragging the camera back to the boot city before the screenshot. - await page.evaluate(async (p, engineNeutral) => { + await evaluateWithGeo(page, async (qaGeo, p, engineNeutral) => { const gev = window.__godsEyeView; if (engineNeutral) { const geo = await import('/src/geo/index.js'); @@ -382,44 +351,42 @@ async function main() { orientation: { heading: p.heading, pitch: p.pitch, roll: 0 }, }); } else { - const v = gev.viewer; - v.camera.cancelFlight?.(); - v.scene.tweens?.removeAll?.(); + window.__godsEyeView.engine.camera.cancelFlight?.(); const carto = { longitude: (p.lon * Math.PI) / 180, latitude: (p.lat * Math.PI) / 180, height: p.height, }; - v.camera.setView({ - destination: v.scene.globe.ellipsoid.cartographicToCartesian(carto), + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian(carto), orientation: { heading: p.heading, pitch: p.pitch, roll: 0 }, }); } }, port, Boolean(ENGINE)); // A changed camera invalidates the previous settled result. Let the new // view schedule its tiles before checking readiness again. - await new Promise((resolve) => setTimeout(resolve, 1500)); + await whenFramesDrawn(page); const resettled = await page .waitForFunction((engineNeutral) => { const gev = window.__godsEyeView; const ais = gev.dataManager.getAll().find((l) => l.id === 'ais-live-vessels'); const tilesLoaded = engineNeutral ? gev.engine.surface.tilesLoaded() - : gev.tileset?.tilesLoaded; + : window.__godsEyeView.engine.surface.tilesLoaded(); return tilesLoaded && (ais?.stats?.count ?? 0) > 0; }, { timeout: 60000, polling: 500 }, Boolean(ENGINE)) .then(() => true) .catch(() => false); // Let two visibility/declutter cycles (800 ms) and host rendering settle. - await new Promise((r) => setTimeout(r, 2500)); + await whenFramesDrawn(page); const expectedFixtureIds = syntheticRows.map((row) => row.mmsi); const overlayEvidence = await page.evaluate((fixtureIds, engineNeutral) => { const diagnostics = window.__gevWorldOverlay?.getDiagnostics?.(); const canvas = window.__godsEyeView?.engine?.host?.canvas; const gl = engineNeutral ? canvas?.getContext('webgl2') || canvas?.getContext('webgl') - : window.__godsEyeView?.viewer?.scene?.context?._gl; + : window.__godsEyeView.engine.host.canvas.getContext('webgl2'); const debugInfo = gl?.getExtension?.('WEBGL_debug_renderer_info'); const seam = window.__godsEyeView?.dataManager?.layers ?.get('ais-live-vessels')?.module?.__focusEvidence; @@ -485,7 +452,7 @@ async function main() { await page.close(); } } finally { - await browser.close(); + await closeBrowser(browser); } console.log('\nRetained PNG+JSON pairs are local QA evidence; each basename records live or synthetic provenance.'); diff --git a/scripts/qa-vessel-datum.mjs b/scripts/qa-vessel-datum.mjs index 4a61b4337..c421c051b 100644 --- a/scripts/qa-vessel-datum.mjs +++ b/scripts/qa-vessel-datum.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; /** * qa-vessel-datum.mjs — assertion harness for the AIS vessel vertical-datum * pass (docs/superpowers/specs/2026-07-27-vessel-datum-design.md). @@ -25,9 +26,9 @@ * --engine cesium|navara puts ?engine= on the URL; without it, the app's * default engine. */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; const argv = process.argv.slice(2); const getOpt = (name, dflt) => { @@ -67,28 +68,7 @@ const NEAR_DEG = 1.0; /** Max billboards sampled for the depth-test sweep. */ const SAMPLE_CAP = 500; -const CHROME_EXECUTABLE_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - // Prefer puppeteer's version-pinned Chrome-for-Testing over the system - // Chrome: /Applications auto-updates underneath the harnesses, and its - // software-GL behavior shifts across majors (system Chrome 150 blew the - // tile-gated drain budget under SwiftShader on 2026-07-30 — six - // false-negative qa-cctv-v2 runs against a healthy build). A deterministic - // pinned browser beats the newest one for regression harnesses. - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -function findChromeExecutable() { - for (const candidate of CHROME_EXECUTABLE_CANDIDATES) { - try { - if (fs.existsSync(candidate)) return candidate; - } catch { /* fall through to Puppeteer's cache */ } - } - return null; -} /** * In-page probe on either engine (the QA probe): the chevrons the layer @@ -160,20 +140,9 @@ async function main() { fs.mkdirSync(OUT_DIR, { recursive: true }); - const chromeExecutable = findChromeExecutable(); - const browser = await puppeteer.launch({ - headless: 'new', - ...(chromeExecutable ? { executablePath: chromeExecutable } : {}), - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--use-gl=angle', - '--use-angle=metal', - '--disable-dev-shm-usage', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--window-size=1600,900', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1600,900'], }); let failures = 0; @@ -243,20 +212,16 @@ async function main() { // Visual proof — re-assert the framing (the app's intro flight can land // mid-probe on slow runs), wait for the photoreal tileset, screenshot. await page.evaluate(frameEngine, port, false); - await page - .waitForFunction( - () => window.__godsEyeView?.engine?.surface?.tilesLoaded?.(), - { timeout: 60000, polling: 500 }, - ) + await whenTilesLoaded(page, { timeoutMs: 60000 }) .catch(() => console.log(' (tileset settle timeout — screenshotting anyway)')); - await new Promise((r) => setTimeout(r, 2500)); + await whenSettled(page); const outPath = path.join(OUT_DIR, `vessel-datum-${ENGINE ? `${ENGINE}-` : ''}${key}.png`); await page.screenshot({ path: outPath }); console.log(` [SHOT] ${outPath}`); await page.close(); } } finally { - await browser.close(); + await closeBrowser(browser); } console.log(failures ? `\n\x1b[31m${failures} failure(s)\x1b[0m` : '\n\x1b[32mAll datum checks passed\x1b[0m'); diff --git a/scripts/qa-view-target-prewarm.mjs b/scripts/qa-view-target-prewarm.mjs index d0d6cb711..3a0d01104 100644 --- a/scripts/qa-view-target-prewarm.mjs +++ b/scripts/qa-view-target-prewarm.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +// Cesium-only probes: native pickPosition failure injection exercises the Cesium HUD boundary. +import { evaluateWithGeo } from './qa/geometry.mjs'; /** * Regression proof for the view-target prewarm crash. * @@ -18,10 +20,11 @@ * `QA_LABEL=before` names the evidence files so a before/after pair can sit * side by side in qa-shots/prewarm/. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import puppeteer from 'puppeteer'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const shotsDir = path.join(repoRoot, 'qa-shots', 'prewarm'); @@ -30,22 +33,9 @@ const label = process.env.QA_LABEL || 'after'; const headful = process.argv.includes('--headful'); fs.mkdirSync(shotsDir, { recursive: true }); -const chromeCandidates = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', -].filter(Boolean); -const executablePath = chromeCandidates.find((candidate) => fs.existsSync(candidate)); -const browser = await puppeteer.launch({ - headless: headful ? false : 'new', - ...(executablePath ? { executablePath } : {}), - // Metal is a macOS-only ANGLE backend; anywhere else it fails WebGL init. - args: [ - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), - '--no-sandbox', - ], +const browser = await launchBrowser({ + headless: true, + args: [...(process.platform === 'darwin' ? ['--enable-gpu'] : []), '--no-sandbox'], }); const page = await browser.newPage(); const failures = []; @@ -91,26 +81,22 @@ let rejections = []; try { await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 }); await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 90_000 }); - await page.waitForFunction(() => window.__godsEyeView?.viewer, { timeout: 90_000 }); - await page.waitForFunction( - () => document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 90_000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.engine, { timeout: 90_000 }); + await whenSettled(page); // Boot noise (tile / network warm-up) is not what this harness is about. - await new Promise((resolve) => setTimeout(resolve, 4_000)); + await whenSettled(page); pageErrors.length = 0; consoleErrors.length = 0; await page.evaluate(() => { window.__qaPrewarmRejections.length = 0; }); const legReports = []; for (const leg of FLIGHT_LEGS) { - const report = await page.evaluate(async (spec) => { - const { viewer } = window.__godsEyeView; + const report = await evaluateWithGeo(page, async (qaGeo, spec) => { // No window.Cesium global (qa-cctv-v2 / qa-height-datum precedent) — // borrow Cartesian3's statics off a live camera-position instance. - const Cartesian3 = viewer.camera.position.constructor; + const Cartesian3 = qaGeo.cartesian3; await new Promise((resolve) => { - viewer.camera.flyTo({ + window.__godsEyeView.engine.camera.flyTo({ destination: Cartesian3.fromDegrees(spec.lon, spec.lat, spec.height), orientation: { heading: 0, pitch: (spec.pitch * Math.PI) / 180, roll: 0 }, duration: 1.2, @@ -123,15 +109,14 @@ try { // Record what the center pick actually yields so the root cause is // visible in the evidence dump rather than merely asserted. - const scene = viewer.scene; - const canvas = scene.canvas; + const canvas = window.__godsEyeView.engine.host.canvas; const center = { x: (canvas.clientWidth || canvas.width) / 2, y: (canvas.clientHeight || canvas.height) / 2, }; let picked; try { - const raw = scene.pickPosition(center); + const raw = window.__godsEyeView.engine.picker.pickPosition(center); picked = raw ? { x: raw.x, y: raw.y, z: raw.z } : null; } catch (error) { picked = { threw: String(error?.message || error) }; @@ -151,19 +136,19 @@ try { // `scene.pickPosition` with one that returns each degenerate shape in turn. // Everything downstream — the moveEnd prewarm inside requestIdleCallback and // the HUD summary interval — is the real shipped code. - const injection = await page.evaluate(async () => { + const injection = await evaluateWithGeo(page, async (qaGeo) => { const { viewer, styleManager } = window.__godsEyeView; - const Cartesian3 = viewer.camera.position.constructor; + const Cartesian3 = qaGeo.cartesian3; const shapes = [ // Throws inside Cesium's normalize. - { name: 'nan', value: new Cartesian3(Number.NaN, Number.NaN, Number.NaN) }, - { name: 'origin', value: new Cartesian3(0, 0, 0) }, - { name: 'infinite', value: new Cartesian3(Number.POSITIVE_INFINITY, 0, 0) }, + { name: 'nan', value: Cartesian3.create(Number.NaN, Number.NaN, Number.NaN) }, + { name: 'origin', value: Cartesian3.create(0, 0, 0) }, + { name: 'infinite', value: Cartesian3.create(Number.POSITIVE_INFINITY, 0, 0) }, // Converts SILENTLY to a point 6,378 km underground — the quiet failure a // bare non-zero check lets through, which then reverse-geocodes 0°, 0°. - { name: 'core-interior', value: new Cartesian3(500, 0, 0) }, + { name: 'core-interior', value: Cartesian3.create(500, 0, 0) }, // Finite, but large enough to overflow the geodetic iteration into NaN. - { name: 'absurd-magnitude', value: new Cartesian3(1e155, 0, 0) }, + { name: 'absurd-magnitude', value: Cartesian3.create(1e155, 0, 0) }, ]; const scene = viewer.scene; const originalPick = Object.getOwnPropertyDescriptor(scene, 'pickPosition'); @@ -175,16 +160,16 @@ try { const hudErrors = []; const nudge = (step) => { - const carto = viewer.camera.positionCartographic; - viewer.camera.setView({ + const carto = window.__godsEyeView.engine.camera.pose().cartographic; + window.__godsEyeView.engine.camera.setView({ destination: Cartesian3.fromRadians( carto.longitude + 0.0015 * (step + 1), carto.latitude, carto.height, ), - orientation: { heading: viewer.camera.heading, pitch: viewer.camera.pitch, roll: 0 }, + orientation: { heading: window.__godsEyeView.engine.camera.pose().heading, pitch: window.__godsEyeView.engine.camera.pose().pitch, roll: 0 }, }); - viewer.camera.moveEnd.raiseEvent(); + window.__godsEyeView.engine.camera.moveEnd.raiseEvent(); }; try { @@ -213,7 +198,7 @@ try { } } finally { if (originalPick) Object.defineProperty(scene, 'pickPosition', originalPick); - else delete scene.pickPosition; + else delete window.__godsEyeView.engine.picker.pickPosition; } return { hudErrors, shapes: shapes.length, pickCalls }; }); @@ -348,7 +333,7 @@ try { } catch (error) { check('harness ran to completion', false, String(error?.message || error)); } finally { - await browser.close(); + await closeBrowser(browser); } console.log(`\n${failures.length ? `FAIL (${failures.length})` : 'PASS'} — evidence in ${path.relative(repoRoot, shotsDir)}/`); diff --git a/scripts/qa-visual-effects.mjs b/scripts/qa-visual-effects.mjs index 43bb607e5..1cdfdb02b 100644 --- a/scripts/qa-visual-effects.mjs +++ b/scripts/qa-visual-effects.mjs @@ -1,15 +1,16 @@ #!/usr/bin/env node +// Cesium-only probes: the native bloom stage verifies the Cesium visual-effects adapter. +import { evaluateWithGeo } from './qa/geometry.mjs'; /** Rendered acceptance for style transitions and effect-state ownership. */ +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; -import puppeteer from 'puppeteer'; -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: [ '--no-sandbox', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), ], }); const page = await browser.newPage(); @@ -26,24 +27,21 @@ try { url.searchParams.set('welcome', '0'); await page.goto(url.href, { waitUntil: 'domcontentloaded' }); await page.waitForFunction( - () => - window.__godsEyeView?.styleManager?._visualEffects && - document.getElementById('loading-screen')?.classList.contains('hidden'), + () => window.__godsEyeView?.styleManager?._visualEffects, { timeout: 60_000 }, ); - await page.evaluate(() => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.cancelFlight?.(); - viewer.scene.tweens?.removeAll?.(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + await whenSettled(page); + await evaluateWithGeo(page, async (qaGeo) => { + window.__godsEyeView.engine.camera.cancelFlight?.(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: (-97.7431 * Math.PI) / 180, latitude: (30.2568 * Math.PI) / 180, height: 1200, }), orientation: { heading: 0, pitch: -0.85, roll: 0 }, }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); await page.evaluate( () => @@ -51,26 +49,10 @@ try { requestAnimationFrame(() => requestAnimationFrame(resolve)), ), ); - const settled = await page - .waitForFunction( - () => { - const primitives = window.__godsEyeView.viewer.scene.primitives; - for (let i = 0; i < primitives.length; i++) { - const primitive = primitives.get(i); - if ( - typeof primitive.tilesLoaded === 'boolean' && - !primitive.tilesLoaded - ) - return false; - } - return true; - }, - { timeout: 60_000 }, - ) - .then( - () => true, - () => false, - ); + const settled = await whenTilesLoaded(page, { timeoutMs: 60_000 }).then( + () => true, + () => false, + ); check('visible tile content settles before visual captures', settled); fs.mkdirSync('qa-shots/visual-effects', { recursive: true }); for (const style of [ @@ -228,7 +210,7 @@ try { ); if (errors.length) console.log(JSON.stringify(errors)); } finally { - await browser.close(); + await closeBrowser(browser); } console.log(`RESULT: ${failures} failures`); process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-visual-input.mjs b/scripts/qa-visual-input.mjs index 3c096acdc..08ff4ddbc 100644 --- a/scripts/qa-visual-input.mjs +++ b/scripts/qa-visual-input.mjs @@ -1,15 +1,14 @@ #!/usr/bin/env node /** Browser acceptance for application shortcuts and generated parameter controls. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; -import puppeteer from 'puppeteer'; -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: [ '--no-sandbox', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), ], }); const page = await browser.newPage(); @@ -29,16 +28,7 @@ try { () => window.__godsEyeView?.styleManager?._applicationShortcuts, { timeout: 60_000 }, ); - await page.waitForFunction( - () => { - const cover = document.getElementById('loading-screen'); - return ( - cover?.classList.contains('hidden') && - Number(getComputedStyle(cover).opacity) === 0 - ); - }, - { timeout: 60_000 }, - ); + await whenSettled(page); await page.evaluate(() => { const manager = window.__godsEyeView.styleManager; manager.setStyle('normal'); @@ -279,7 +269,7 @@ try { ); check('runtime has no uncaught browser errors', errors.length === 0); } finally { - await browser.close(); + await closeBrowser(browser); } console.log(`RESULT: ${failures} failures`); process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-voice-auth-focus.mjs b/scripts/qa-voice-auth-focus.mjs index 1382ac851..5e833bcc7 100644 --- a/scripts/qa-voice-auth-focus.mjs +++ b/scripts/qa-voice-auth-focus.mjs @@ -4,12 +4,12 @@ * Run: node --test --test-reporter=spec scripts/qa-voice-auth-focus.mjs * Sign-in responses are fixtures. No credentials or live login are used. */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; import { existsSync } from 'node:fs'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { createServer } from 'node:http'; import { after, before, test } from 'node:test'; -import puppeteer from 'puppeteer'; const root = new URL('../', import.meta.url); const view = await readFile( @@ -63,17 +63,13 @@ before(async () => { server.listen(0, '127.0.0.1', resolve); }); origin = `http://127.0.0.1:${server.address().port}`; - const chrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; - browser = await puppeteer.launch({ + browser = await launchBrowser({ headless: true, - executablePath: - process.env.PUPPETEER_EXECUTABLE_PATH || - (existsSync(chrome) ? chrome : await puppeteer.executablePath()), }); }); after(async () => { - await browser?.close(); + await closeBrowser(browser); if (server?.listening) await new Promise((resolve) => server.close(resolve)); const output = new URL('.gev-logs/voice-auth-focus/', root); await mkdir(output, { recursive: true }); diff --git a/scripts/qa-voice-auth.mjs b/scripts/qa-voice-auth.mjs index 800df9598..5e1a5ff17 100644 --- a/scripts/qa-voice-auth.mjs +++ b/scripts/qa-voice-auth.mjs @@ -6,11 +6,12 @@ * Use a short spoken reply request with three seconds of leading silence. * Saves only usage, transport metadata, and screenshots; never credentials. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import puppeteer from 'puppeteer'; const root = fileURLToPath(new URL('../', import.meta.url)); const url = new URL(process.argv[2] || 'http://127.0.0.1:4189'); @@ -27,14 +28,8 @@ url.searchParams.set('welcome', '0'); url.searchParams.set('setup', '1'); const output = path.join(root, '.gev-logs', 'voice-auth'); fs.mkdirSync(output, { recursive: true }); -const executablePath = - process.env.PUPPETEER_EXECUTABLE_PATH || - (fs.existsSync('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome') - ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' - : puppeteer.executablePath()); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, - executablePath, args: [ '--no-sandbox', '--disable-setuid-sandbox', @@ -149,12 +144,10 @@ try { }; }); await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 30_000 }); - await page.waitForFunction( - () => - window.__godsEyeView?.voiceCommands && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 45_000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.voiceCommands, { + timeout: 45_000, + }); + await whenSettled(page); await page.waitForSelector('[data-cloud-voice-auth-toggle]', { visible: true, }); @@ -281,7 +274,7 @@ try { await page ?.evaluate(() => window.__godsEyeView?.voiceCommands?.session?.stop()) .catch(() => {}); - await browser.close(); + await closeBrowser(browser); fs.writeFileSync( path.join(output, `${auth}-result.json`), `${JSON.stringify(result, null, 2)}\n`, diff --git a/scripts/qa-voice-routing.mjs b/scripts/qa-voice-routing.mjs index 48b06584f..4847a0bc7 100644 --- a/scripts/qa-voice-routing.mjs +++ b/scripts/qa-voice-routing.mjs @@ -1,4 +1,7 @@ #!/usr/bin/env node +// Cesium-only probes: native pick and tracking probes validate the legacy Cesium voice route. +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled } from './qa/ready.mjs'; /** * qa-voice-routing.mjs — voice-surface QA harness (overnight 2026-07-27). * @@ -36,11 +39,11 @@ * routing assertions pin tool NAMES (model wording varies, tool choice must not). */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import WebSocket from 'ws'; -import puppeteer from 'puppeteer'; import sharp from 'sharp'; import { ROUTING_PHRASES } from './voice-bench/phrases.mjs'; import { gradeCore, expectLabel } from './voice-bench/grade.mjs'; @@ -52,19 +55,6 @@ import { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); -const CHROME_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -const CHROME_EXECUTABLE = CHROME_CANDIDATES.find((candidate) => { - try { - return fs.existsSync(candidate); - } catch { - return false; - } -}); // ── CLI ───────────────────────────────────────────────────── function getOpt(flag, fallback) { @@ -674,21 +664,9 @@ async function runDeixisRoutingLayer() { async function runDeixisLiveLayer() { console.log('\nLAYER 1c — deixis live (in-app text turns)'); - const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', - ...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}), - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - ...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']), - '--disable-dev-shm-usage', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--use-fake-device-for-media-stream', - '--use-fake-ui-for-media-stream', - '--autoplay-policy=no-user-gesture-required', - '--window-size=1500,950', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', ...(HEADFUL ? [] : []), '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required', '--window-size=1500,950'], protocolTimeout: 180000, }); const appUrl = new URL(APP_URL); @@ -716,11 +694,12 @@ async function runDeixisLiveLayer() { timeout: 60000, }); await page.waitForFunction( - () => window.__godsEyeView?.viewer && window.__gevVoiceCommands?.session, + () => window.__godsEyeView?.engine && window.__gevVoiceCommands?.session, { timeout: 120000, polling: 250 }, ); + await whenSettled(page); await page.evaluate(() => - window.__godsEyeView.viewer.camera.cancelFlight(), + window.__godsEyeView.engine.camera.cancelFlight(), ); await run('fly_to_location', { query: 'Austin, Texas' }); await settle(9000); @@ -778,7 +757,7 @@ async function runDeixisLiveLayer() { ).find((e) => e.id === p.aircraftId); viewer.scene.render(); at = found?.position - ? viewer.scene.cartesianToCanvasCoordinates(found.position) + ? window.__godsEyeView.engine.projector.worldToWindow(found.position) : null; } if (at) window.__gevVoiceCommands.pointer.moveTo(at.x, at.y); @@ -865,8 +844,8 @@ async function runDeixisLiveLayer() { } const size = await page.evaluate(() => ({ - w: window.__godsEyeView.viewer.scene.canvas.clientWidth, - h: window.__godsEyeView.viewer.scene.canvas.clientHeight, + w: window.__godsEyeView.engine.host.canvas.clientWidth, + h: window.__godsEyeView.engine.host.canvas.clientHeight, })); const events = await turn('How many flights are around here?', { x: size.w * 0.3, @@ -893,7 +872,7 @@ async function runDeixisLiveLayer() { } catch { /* closing */ } - await browser.close(); + await closeBrowser(browser); console.log(` deixis live turns used: ${turnsUsed}`); } } @@ -986,8 +965,8 @@ function findPickableAircraft(page) { return page.evaluate(() => { const { viewer, dataManager } = window.__godsEyeView; const module = dataManager.layers.get('flights')?.module; - const canvas = viewer.scene.canvas; - const eye = viewer.camera.positionWC; + const canvas = window.__godsEyeView.engine.host.canvas; + const eye = window.__godsEyeView.engine.camera.pose().position; viewer.scene.render(); let picks = 0; const candidates = []; @@ -995,7 +974,7 @@ function findPickableAircraft(page) { // Horizon test: far-side contacts still project onto the canvas. const p = entry.position; if (eye.x * p.x + eye.y * p.y + eye.z * p.z < 6356000 ** 2) continue; - const at = viewer.scene.cartesianToCanvasCoordinates(entry.position); + const at = window.__godsEyeView.engine.projector.worldToWindow(entry.position); if ( !at || at.x < 60 || @@ -1176,7 +1155,7 @@ async function runDeixisBehavior(page, run, settle) { // D2 — bare ground: the chip gives coordinates and "around here" is a radius. const size = await page.evaluate(() => { - const canvas = window.__godsEyeView.viewer.scene.canvas; + const canvas = window.__godsEyeView.engine.host.canvas; return { w: canvas.clientWidth, h: canvas.clientHeight }; }); let groundTurn = null; @@ -1320,20 +1299,9 @@ async function runBehaviorLayer() { return; } - const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', - ...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}), - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - ...(HEADFUL ? [] : ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`]), - '--mute-audio', - '--disable-dev-shm-usage', - '--disable-web-security', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--window-size=1500,950', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', ...(HEADFUL ? [] : []), '--mute-audio', '--disable-dev-shm-usage', '--disable-web-security', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1500,950'], protocolTimeout: 180000, }); const page = await browser.newPage(); @@ -1389,14 +1357,15 @@ async function runBehaviorLayer() { }); await page.waitForFunction( () => - window.__godsEyeView?.viewer && + window.__godsEyeView?.engine && window.__gevVoiceCommands?.runner && window.__gevAnnotations, { timeout: 120000, polling: 250 }, ); + await whenSettled(page); // House rule: the intro flight clobbers teleports issued mid-flight. await page.evaluate(() => - window.__godsEyeView.viewer.camera.cancelFlight(), + window.__godsEyeView.engine.camera.cancelFlight(), ); const run = (name, args) => @@ -1414,13 +1383,12 @@ async function runBehaviorLayer() { ); const camState = () => page.evaluate(() => { - const c = window.__godsEyeView.viewer.camera; - const p = c.positionCartographic; + const p = window.__godsEyeView.engine.camera.pose().cartographic; return { lat: (p.latitude * 180) / Math.PI, lon: (p.longitude * 180) / Math.PI, altKm: p.height / 1000, - pitchDeg: (c.pitch * 180) / Math.PI, + pitchDeg: (window.__godsEyeView.engine.camera.pose().pitch * 180) / Math.PI, }; }); const settle = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -1465,7 +1433,7 @@ async function runBehaviorLayer() { // final tween frame. Cancel that already-arrived flight so the assertion // measures Radio's camera ownership, not residual navigation motion. await page.evaluate(() => - window.__godsEyeView.viewer.camera.cancelFlight(), + window.__godsEyeView.engine.camera.cancelFlight(), ); await settle(250); const radioCameraBefore = await camState(); @@ -1832,7 +1800,7 @@ async function runBehaviorLayer() { await page .waitForFunction( () => - window.__godsEyeView.viewer.camera.positionCartographic.height / + window.__godsEyeView.engine.camera.pose().cartographic.height / 1000 > 12000, { timeout: 30_000, polling: 250 }, @@ -1853,7 +1821,7 @@ async function runBehaviorLayer() { await page .waitForFunction( () => - window.__godsEyeView.viewer.camera.positionCartographic.height / + window.__godsEyeView.engine.camera.pose().cartographic.height / 1000 < 900, { timeout: 40_000, polling: 250 }, @@ -1923,7 +1891,7 @@ async function runBehaviorLayer() { // (6c) move_camera orbit ONCE: bounded eased ~30° heading advance. const heading = () => page.evaluate( - () => (window.__godsEyeView.viewer.camera.heading * 180) / Math.PI, + () => (window.__godsEyeView.engine.camera.pose().heading * 180) / Math.PI, ); let h0 = await heading(); r = await run('move_camera', { motion: 'orbit', mode: 'once' }); @@ -2105,10 +2073,9 @@ async function runBehaviorLayer() { // (6e5) tilt at the clamp answers honestly instead of silently no-oping. await page.evaluate(() => { - const c = window.__godsEyeView.viewer.camera; - c.setView({ + window.__godsEyeView.engine.camera.setView({ orientation: { - heading: c.heading, + heading: window.__godsEyeView.engine.camera.pose().heading, pitch: (-5.2 * Math.PI) / 180, roll: 0, }, @@ -2395,7 +2362,7 @@ async function runBehaviorLayer() { String(e?.message || e).slice(0, 200), ); } finally { - await browser.close(); + await closeBrowser(browser); } } @@ -2425,11 +2392,10 @@ async function runBundledOutlineScenarios({ const timings = []; for (const scenario of BUNDLED_OUTLINES) { const label = `behavior: "outline ${scenario.target}" draws the bundled ${scenario.pack} outline`; - await page.evaluate(({ lat, lon }) => { - const { viewer } = window.__godsEyeView; - viewer.camera.cancelFlight(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo, { lat, lon }) => { + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: (lon * Math.PI) / 180, latitude: (lat * Math.PI) / 180, height: 9000, @@ -2529,11 +2495,10 @@ async function runGeometryScenarios({ page, run, settle, overpassRequests }) { const shot = (name) => page.screenshot({ path: path.join(shotDir, `${name}.png`) }).catch(() => {}); const nominatimStart = nominatimCount(); const overpassStart = overpassRequests.length; - const teleport = (lat, lon, height, pitch = -90) => page.evaluate(({ lat, lon, height, pitch }) => { - const { viewer } = window.__godsEyeView; - viewer.camera.cancelFlight(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + const teleport = (lat, lon, height, pitch = -90) => evaluateWithGeo(page, async (qaGeo, { lat, lon, height, pitch }) => { + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: (lon * Math.PI) / 180, latitude: (lat * Math.PI) / 180, height, }), orientation: { heading: 0, pitch: (pitch * Math.PI) / 180, roll: 0 }, @@ -2729,11 +2694,10 @@ async function runOutlineAcceptance({ const overpassStart = overpassRequests.length; if (SHOT_DIR) fs.mkdirSync(path.resolve(ROOT, SHOT_DIR), { recursive: true }); - const teleport = (ask) => page.evaluate(({ lat, lon, height }) => { - const { viewer } = window.__godsEyeView; - viewer.camera.cancelFlight(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + const teleport = (ask) => evaluateWithGeo(page, async (qaGeo, { lat, lon, height }) => { + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: (lon * Math.PI) / 180, latitude: (lat * Math.PI) / 180, height, diff --git a/scripts/qa-voice-wav.mjs b/scripts/qa-voice-wav.mjs index f9d7f8ee9..1a2e2ecc8 100644 --- a/scripts/qa-voice-wav.mjs +++ b/scripts/qa-voice-wav.mjs @@ -4,11 +4,12 @@ * * Run: node scripts/qa-voice-wav.mjs http://localhost:4189 [--push-to-talk] */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import puppeteer from 'puppeteer'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const pushToTalk = process.argv.includes('--push-to-talk'); @@ -33,20 +34,9 @@ if (fixtureSha256 !== expectedFixtureSha256) { } const appOrigin = new URL(appUrl).origin; -const browser = await puppeteer.launch({ - headless: 'new', - executablePath: await puppeteer.executablePath(), - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--use-fake-device-for-media-stream', - `--use-file-for-fake-audio-capture=${wavPath}%noloop`, - '--autoplay-policy=no-user-gesture-required', - '--window-size=1440,900', - ], +const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--use-fake-device-for-media-stream', `--use-file-for-fake-audio-capture=${wavPath}%noloop`, '--autoplay-policy=no-user-gesture-required', '--window-size=1440,900'], }); try { @@ -65,15 +55,14 @@ try { await page.waitForFunction(() => ( window.__godsEyeView?.voiceCommands && document.getElementById('gev-voice-button') - && document.getElementById('loading-screen')?.classList.contains('hidden') ), { timeout: 30_000 }); + await whenSettled(page); const readState = () => page.evaluate(() => { const voice = window.__godsEyeView?.voiceCommands; const radio = window.__godsEyeView?.dataManager?.layers?.get('radio')?.module; const radioState = radio?.getUIState?.() || null; - const camera = window.__godsEyeView?.viewer?.camera; - const cartographic = camera?.positionCartographic; + const cartographic = window.__godsEyeView.engine.camera.pose().cartographic; return { at: Date.now(), voiceStatus: voice?.status || null, @@ -157,5 +146,5 @@ try { console.log(JSON.stringify(result, null, 2)); process.exitCode = result.ok ? 0 : 1; } finally { - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-weather-journey.mjs b/scripts/qa-weather-journey.mjs index f79bf759f..49c89182c 100644 --- a/scripts/qa-weather-journey.mjs +++ b/scripts/qa-weather-journey.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled } from './qa/ready.mjs'; /** * qa-weather-journey — drive the Weather layers the way a person would and * keep the evidence: numbered screenshots plus per-step layer diagnostics. @@ -19,9 +21,9 @@ * Requires a running dev server. Screenshots and steps.json land in * /