diff --git a/scripts/qa-action-buttons.mjs b/scripts/qa-action-buttons.mjs index ef11e8ff2..dd22946e2 100644 --- a/scripts/qa-action-buttons.mjs +++ b/scripts/qa-action-buttons.mjs @@ -6,12 +6,13 @@ * native dispatch/disabled/teardown without providers or world calibration writes. * Real Scene authoring/playback remains covered by qa-scene-controls.mjs. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; -import puppeteer from 'puppeteer'; -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, - args: ['--use-gl=angle', '--use-angle=swiftshader'], + args: [], }); const page = await browser.newPage(); const errors = []; @@ -34,11 +35,10 @@ try { { waitUntil: 'domcontentloaded' }, ); await page.waitForFunction( - () => - window.__godsEyeView?.styleManager?._cctvControls && - document.getElementById('loading-screen')?.classList.contains('hidden'), + () => window.__godsEyeView?.styleManager?._cctvControls, { timeout: 60000 }, ); + await whenSettled(page); const inventory = await page.evaluate(() => { const ids = [ 'scene-new-btn', @@ -304,11 +304,10 @@ try { localized.searchParams.set('lang', 'ru'); await page.goto(localized.href, { waitUntil: 'domcontentloaded' }); await page.waitForFunction( - () => - window.__godsEyeView?.styleManager?._cctvControls && - document.getElementById('loading-screen')?.classList.contains('hidden'), + () => window.__godsEyeView?.styleManager?._cctvControls, { timeout: 60000 }, ); + await whenSettled(page); for (const panel of ['scene-panel', 'cctv-panel']) { await page.evaluate((panel) => { window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, { @@ -329,7 +328,7 @@ try { } check('no uncaught browser errors', errors.length === 0, errors); } finally { - await browser.close(); + await closeBrowser(browser); } console.log(`RESULT: ${failures} failures`); process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-admin-outlines.mjs b/scripts/qa-admin-outlines.mjs index 114daacad..5f030764b 100644 --- a/scripts/qa-admin-outlines.mjs +++ b/scripts/qa-admin-outlines.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// Cesium-only probes: DataSource and ground-primitive readiness validate the native outline adapter. /** * qa-admin-outlines.mjs — bundled state/province/county outlines, driven like a * user would drive them. @@ -23,11 +24,12 @@ * timings, frame-time p50/p95 and still-camera frame-difference statistics. * Exits nonzero on any failed assertion. */ +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import fs from 'node:fs/promises'; import path from 'node:path'; -import puppeteer from 'puppeteer'; import sharp from 'sharp'; const args = process.argv.slice(2); @@ -63,12 +65,11 @@ const result = { video: null, }; -const browser = await puppeteer.launch({ - headless: !args.includes('--headful'), +const browser = await launchBrowser({ + headless: true, protocolTimeout: 300_000, args: [ '--no-sandbox', - '--use-gl=angle', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', @@ -145,14 +146,10 @@ try { timeout: 60_000, }); await page.waitForFunction( - () => window.__godsEyeView?.viewer && window.__godsEyeView?.annotations, + () => window.__godsEyeView?.engine && window.__godsEyeView?.annotations, { timeout: 90_000, polling: 500 }, ); - await page.waitForFunction( - () => - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 90_000, polling: 250 }, - ); + await whenSettled(page); await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, ); @@ -162,7 +159,7 @@ try { { timeout: 10_000 }, ); result.renderer = await page.evaluate(() => { - const gl = window.__godsEyeView.viewer.scene.context._gl; + const gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2'); const debug = gl.getExtension('WEBGL_debug_renderer_info'); return debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) @@ -177,7 +174,7 @@ try { window.__qaOutlines = { fly(view, duration) { return new Promise((resolve) => { - viewer.camera.cancelFlight(); + window.__godsEyeView.engine.camera.cancelFlight(); viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees( view.lon, @@ -264,20 +261,7 @@ try { }); const settle = () => - page - .waitForFunction( - () => { - const { scene } = window.__godsEyeView.viewer; - for (let i = 0; i < scene.primitives.length; i++) { - const p = scene.primitives.get(i); - if (p?.show && typeof p.tilesLoaded === 'boolean' && !p.tilesLoaded) - return false; - } - return true; - }, - { timeout: 60_000, polling: 500 }, - ) - .catch(() => {}); + whenTilesLoaded(page, { timeoutMs: 60_000 }).catch(() => {}); async function shot(name) { await page.evaluate( @@ -317,11 +301,14 @@ try { // built the mark's primitives (each tick updates them before render). const { viewer } = window.__godsEyeView; await new Promise((resolve) => { - const off = viewer.scene.postRender.addEventListener(() => { - if (!viewer.dataSourceDisplay.ready) return; - off(); - resolve(); - }); + const off = + window.__godsEyeView.engine.frames.postRender.addEventListener( + () => { + if (!viewer.dataSourceDisplay.ready) return; + off(); + resolve(); + }, + ); }); const drawnMs = performance.now() - started; const mark = window.__godsEyeView.annotations @@ -343,7 +330,7 @@ try { target, flyTo, ); - if (flyTo) await new Promise((r) => setTimeout(r, 2200)); + if (flyTo) await whenSettled(page); await settle(); // Hold still: the drawn mark must not rebuild or grow. marker = `still:${name}`; @@ -551,7 +538,7 @@ try { .screenshot({ path: path.join(shots, 'admin-outlines-failure.png') }) .catch(() => {}); } finally { - await browser.close(); + await closeBrowser(browser); } if (recordVideo && frames.length > 1) { diff --git a/scripts/qa-air-quality.mjs b/scripts/qa-air-quality.mjs index 68e6bd505..817d91eb4 100644 --- a/scripts/qa-air-quality.mjs +++ b/scripts/qa-air-quality.mjs @@ -1,3 +1,6 @@ +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; /** * qa-air-quality.mjs — headless proof for the Air quality layer. * @@ -12,7 +15,6 @@ * Run: node scripts/qa-air-quality.mjs --url http://localhost:4173 */ -import puppeteer from 'puppeteer'; const argv = process.argv.slice(2); const getOpt = (name, dflt) => { @@ -27,19 +29,13 @@ function record(name, ok, detail) { const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); } -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function main() { let exitCode = 0; const consoleErrors = []; - const browser = await puppeteer.launch({ - headless: 'new', - args: [ - '--use-gl=swiftshader', - '--enable-unsafe-swiftshader', - '--no-sandbox', - '--disable-dev-shm-usage', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--enable-unsafe-swiftshader', '--no-sandbox', '--disable-dev-shm-usage'], defaultViewport: { width: 1400, height: 900 }, }); @@ -52,10 +48,10 @@ async function main() { await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90000 }); await page.waitForFunction( - () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + () => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager, { timeout: 90000 }, ); - await sleep(1500); + await whenSettled(page); const stats = await page.evaluate(async () => { const dm = window.__godsEyeView.dataManager; @@ -106,26 +102,24 @@ async function main() { // The card's whole point is "highest readings in view", so put the camera // over Canada before asserting on it — the default view is over Austin, // where an empty in-view list is correct and proves nothing. - await page.evaluate(() => { - const gev = window.__godsEyeView; - const ellipsoid = gev.viewer.scene.globe.ellipsoid; + await evaluateWithGeo(page, async (qaGeo) => { const d2r = Math.PI / 180; try { - gev.viewer.camera.cancelFlight(); + window.__godsEyeView.engine.camera.cancelFlight(); } catch { /* no flight in progress */ } - gev.viewer.camera.setView({ - destination: ellipsoid.cartographicToCartesian({ + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: -100 * d2r, latitude: 55 * d2r, height: 4_000_000, }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); - gev.viewer.scene.requestRender?.(); + window.__godsEyeView.engine.render.request?.(); }); - await sleep(2500); + await whenSettled(page); const controls = await page.evaluate( () => @@ -154,7 +148,7 @@ async function main() { console.error('\x1b[31mHarness error:\x1b[0m', e); exitCode = 3; } finally { - await browser.close(); + await closeBrowser(browser); } const pass = results.filter((r) => r.ok === true).length; diff --git a/scripts/qa-aircraft-heading.mjs b/scripts/qa-aircraft-heading.mjs index 3bab0e4b1..26d77eecd 100644 --- a/scripts/qa-aircraft-heading.mjs +++ b/scripts/qa-aircraft-heading.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// Cesium-only probes: tracked entities and native model axes validate Cesium aircraft heading. /** * qa-aircraft-heading.mjs — aircraft display-heading checks * (path-derived, rate-limited display course; Batch 3 overnight run). @@ -97,9 +98,10 @@ * Exits non-zero if any assertion fails. Never commits anything. */ +import { whenSettled, whenFramesDrawn } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; import { installNavaraHeadingProbe } from './qa-heading-navara.mjs'; import { stubPlanespotters } from './qa-photo-stub.mjs'; @@ -124,26 +126,7 @@ const HEADFUL = getFlag('--headful'); const SHOT_DIR = path.resolve('qa-shots/aircraft-heading'); const SHOT_DIR_V2 = path.resolve('qa-shots/heading-v2'); -const CHROME_EXECUTABLE_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - // Prefer puppeteer's version-pinned Chrome-for-Testing over the system - // Chrome: /Applications auto-updates underneath the harnesses, and its - // software-GL behavior shifts across majors (system Chrome 150 blew the - // tile-gated drain budget under SwiftShader on 2026-07-30 — six - // false-negative qa-cctv-v2 runs against a healthy build). A deterministic - // pinned browser beats the newest one for regression harnesses. - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -function findChromeExecutable() { - for (const candidate of CHROME_EXECUTABLE_CANDIDATES) { - try { if (fs.existsSync(candidate)) return candidate; } catch { /* skip */ } - } - return null; -} // --------------------------------------------------------------------------- // PASS/FAIL reporting @@ -157,7 +140,7 @@ function record(name, ok, detail) { async function observeTrackedHostPaint(page, timeoutMs = 5000) { return page.evaluate((boundedTimeoutMs) => new Promise((resolve) => { - const viewer = (window.__headingView ?? window.__godsEyeView?.viewer); + const viewer = (window.__headingView ?? window.__godsEyeView?.engine); const scene = viewer?.scene; const history = []; const startedAt = performance.now(); @@ -455,20 +438,9 @@ async function main() { fs.mkdirSync(SHOT_DIR, { recursive: true }); fs.mkdirSync(SHOT_DIR_V2, { recursive: true }); - const chromeExecutable = findChromeExecutable(); - const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', - ...(chromeExecutable ? { executablePath: chromeExecutable } : {}), - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - ...(HEADFUL ? [] : ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`]), - '--disable-dev-shm-usage', - '--disable-web-security', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--window-size=1280,800', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', ...(HEADFUL ? [] : []), '--disable-dev-shm-usage', '--disable-web-security', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1280,800'], }); const consoleErrors = []; @@ -588,8 +560,9 @@ async function main() { () => window.__godsEyeView?.layers && window.__godsEyeView.dataManager, { timeout: 60000, polling: 200 } ); + await whenSettled(page); await page.evaluate(() => window.__godsEyeView.styleManager.initialRestorePromise); - await page.waitForFunction(() => document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 180000 }); + await whenSettled(page); await page.evaluate(() => window.__godsEyeView.styleManager.orbitController.stop()); console.log(' App globals ready.'); @@ -730,7 +703,7 @@ async function main() { }, { timeout: 40000, polling: 250 }, TURN.flights[0].icao).then(() => true).catch(() => false); record('flights: tracked 3D model rendered (ready+shown)', flModelUp, flModelUp ? 'model up' : 'model never became ready'); if (!flModelUp) { finish(); return; } - await sleep(1500); // let the follow camera + reconciliation settle + await whenFramesDrawn(page); // let the follow camera + reconciliation settle const readoutObservation = await observeTrackedHostPaint(page); const readoutHost = await page.evaluate(() => ({ @@ -780,7 +753,7 @@ async function main() { await page.evaluate(() => { window.__godsEyeView.styleManager._setDetectionMode('DENSE'); }); - await sleep(800); + await whenFramesDrawn(page); const shots = [ ['flights-far-orbitA.png', async () => { /* default follow framing */ }], ['flights-far-orbitB.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.rotateRight(1.9); })], @@ -794,7 +767,7 @@ async function main() { ]; for (const [name, move] of shots) { try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); } - await sleep(1200); // let a few frames render at the new pose + await whenFramesDrawn(page); // let a few frames render at the new pose await page.screenshot({ path: path.join(SHOT_DIR, name) }); console.log(` saved ${name}`); } @@ -819,7 +792,7 @@ async function main() { }, { timeout: 40000, polling: 250 }, TURN.military[0].hex).then(() => true).catch(() => false); record('military: tracked 3D model rendered (ready+shown)', milModelUp, milModelUp ? 'model up' : 'model never became ready'); if (milModelUp) { - await sleep(1500); + await whenFramesDrawn(page); const milSamples = await page.evaluate( (icao) => window.__sampleModelCourse(icao, 180, 40000), TURN.military[0].hex, @@ -841,7 +814,7 @@ async function main() { await page.evaluate(() => { window.__godsEyeView.styleManager._setDetectionMode('DENSE'); }); - await sleep(800); + await whenFramesDrawn(page); for (const [name, move] of [ ['military-orbitA.png', async () => { }], ['military-orbitB.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.rotateRight(1.6); })], @@ -855,7 +828,7 @@ async function main() { })], ]) { try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); } - await sleep(1200); + await whenFramesDrawn(page); await page.screenshot({ path: path.join(SHOT_DIR, name) }); console.log(` saved ${name}`); } @@ -892,7 +865,7 @@ async function main() { // ---- Phase 3 — hovering helicopter -------------------------------------- console.log('\nPhase 3 — hovering helicopter (klass=helicopter, drift ~4 m, track flips ±45°)'); if (await trackFlightsAndWaitModel(TURN.hover.icao, 'hover-heli')) { - await sleep(1500); + await whenFramesDrawn(page); const heliSamples = await sampleCourse(180, 35000, TURN.hover.icao); analyzeHover('hover-heli', heliSamples, {}); for (const [name, waitMs] of [['hover-heli-T0.png', 0], ['hover-heli-T8s.png', 8000]]) { @@ -905,7 +878,7 @@ async function main() { // ---- Phase 4 — 25 kt plane in a continuous 4°/s right turn -------------- console.log('\nPhase 4 — slow plane in a tight turn (25 kt, 4°/s, R=184 m)'); if (await trackFlightsAndWaitModel(TURN.slow.icao, 'slow-turn')) { - await sleep(1500); + await whenFramesDrawn(page); const slowSamples = await sampleCourse(180, 40000, TURN.slow.icao); analyzeSlowTurn('slow-turn', slowSamples, { scoreProgress: false }); // Exercise the production dt clamp with render gaps above @@ -923,7 +896,7 @@ async function main() { ['slowturn-near.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.zoomIn(2500); })], ]) { try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); } - await sleep(1200); + await whenFramesDrawn(page); await page.screenshot({ path: path.join(SHOT_DIR_V2, name) }); console.log(` saved ${name}`); } @@ -1097,7 +1070,7 @@ async function main() { finish(); } finally { - await browser.close(); + await closeBrowser(browser); } function finish() { diff --git a/scripts/qa-alpr-journey.mjs b/scripts/qa-alpr-journey.mjs index bc1c58855..b005e7254 100644 --- a/scripts/qa-alpr-journey.mjs +++ b/scripts/qa-alpr-journey.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// Cesium-only probes: requestRender instrumentation measures Cesium request-mode cost. /** * qa-alpr-journey.mjs — ALPR cameras on a realistic Austin camera journey. * @@ -36,9 +37,10 @@ * Writes /result.json, /clip.mp4 (CDP screencast with real frame * timing) and per-stop screenshots. Needs ffmpeg on PATH. */ +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; import { blockPaidQaMaps } from './qa-map-policy.mjs'; import sharp from 'sharp'; import { installAlprFixture } from './fixtures/layers/alpr.mjs'; @@ -163,14 +165,12 @@ const STOPS = [ }, ]; -const browser = await puppeteer.launch({ - headless: !argv.includes('--headful'), +const browser = await launchBrowser({ + headless: true, protocolTimeout: 300_000, args: [ '--no-sandbox', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', @@ -233,12 +233,10 @@ try { const url = new URL(appUrl); url.searchParams.set('welcome', '0'); await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 90_000 }); - await page.waitForFunction( - () => - window.__godsEyeView?.dataManager && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 120_000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.dataManager, { + timeout: 120_000, + }); + await whenSettled(page); await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, ); @@ -286,22 +284,7 @@ try { await recorder.start(); async function waitTiles(maxMs = 20_000) { - await page - .waitForFunction( - () => { - if (window.__godsEyeView.engine.kind === 'navara') - return window.__godsEyeView.engine.surface.tilesLoaded(); - const { scene } = window.__godsEyeView.viewer; - if (scene.globe.show) return scene.globe.tilesLoaded; - for (let i = 0; i < scene.primitives.length; i++) { - const p = scene.primitives.get(i); - if (p.show && p.tilesLoaded === false) return false; - } - return true; - }, - { timeout: maxMs, polling: 250 }, - ) - .catch(() => {}); + await whenTilesLoaded(page, { timeoutMs: maxMs }).catch(() => {}); } for (const [passIndex, mode] of passes.entries()) { @@ -653,7 +636,7 @@ try { path.join(outDir, 'result.json'), JSON.stringify(result, null, 1), ); - await browser.close(); + await closeBrowser(browser); } console.log( `${result.failures.length ? 'FAIL' : 'PASS'} — ${path.join(outDir, 'result.json')}`, diff --git a/scripts/qa-anime-sky.mjs b/scripts/qa-anime-sky.mjs index a0a0c9935..922cd1371 100644 --- a/scripts/qa-anime-sky.mjs +++ b/scripts/qa-anime-sky.mjs @@ -1,4 +1,7 @@ #!/usr/bin/env node +// Cesium-only probes: sky commands, the Julian clock and post-process stages validate the Cesium sky. +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; /** * qa-anime-sky — the anime style must leave the sky without bands. * @@ -13,14 +16,13 @@ * Usage: node scripts/qa-anime-sky.mjs [--url http://localhost:4333/] * Exit: 0 pass, 1 fail, 2 the anime style was not reached. */ -import puppeteer from 'puppeteer'; const i = process.argv.indexOf('--url'); const URL_ = i > 0 && process.argv[i + 1] ? process.argv[i + 1] : 'http://localhost:4333/'; -const browser = await puppeteer.launch({ - headless: 'new', +const browser = await launchBrowser({ + headless: true, args: ['--no-sandbox', '--ignore-gpu-blocklist'], }); try { @@ -41,17 +43,15 @@ try { await page.waitForFunction(() => window.__godsEyeView?.viewer?.scene, { timeout: 120000, }); + await whenSettled(page); await page.waitForFunction( () => { const sky = window.__godsEyeView.viewer.scene.skyBox; - return ( - sky?._milkyWayCubeMap && - sky?._starCommand && - document.getElementById('loading-screen')?.classList.contains('hidden') - ); + return sky?._milkyWayCubeMap && sky?._starCommand; }, { timeout: 60000 }, ); + await whenSettled(page); const r = await page.evaluate(async () => { const entry = performance .getEntriesByType('resource') @@ -94,22 +94,22 @@ try { C.Cartesian3.cross(look, north, new C.Cartesian3()), new C.Cartesian3(), ); - s.camera.cancelFlight(); + window.__godsEyeView.engine.camera.cancelFlight(); s.tweens.removeAll(); - s.camera.setView({ + window.__godsEyeView.engine.camera.setView({ destination: C.Cartesian3.multiplyByScalar(look, 3e7, new C.Cartesian3()), orientation: { direction: look, up: C.Cartesian3.cross(right, look, new C.Cartesian3()), }, }); - const gl = s.context._gl, + const gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2'), w = gl.drawingBufferWidth, h = gl.drawingBufferHeight, px = new Uint8Array(w * h * 4); const measure = async () => { await new Promise((res) => setTimeout(res, 2500)); - s.requestRender(); + window.__godsEyeView.engine.render.request(); s.render(); gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); let banded = 0; @@ -156,5 +156,5 @@ try { console.error(`UNREACHED: ${error.message}`); process.exitCode = 2; } finally { - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-application.mjs b/scripts/qa-application.mjs index 41b77c8dc..23ae2ea77 100644 --- a/scripts/qa-application.mjs +++ b/scripts/qa-application.mjs @@ -1,16 +1,15 @@ #!/usr/bin/env node +import { whenSettled } from './qa/ready.mjs'; /** Prove standalone startup and terminal resource ownership in a real browser. */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; -import puppeteer from 'puppeteer'; const args = process.argv.slice(2); const urlIndex = args.indexOf('--url'); const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173'; -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, - executablePath: - process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()), - args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'], + args: ['--no-sandbox'], }); try { const page = await browser.newPage(); @@ -28,6 +27,7 @@ try { await page.waitForFunction(() => window.__godsEyeView?.voiceCommands, { timeout: 60_000, }); + await whenSettled(page); const before = await page.evaluate(async () => { const entry = [...document.scripts].find((script) => /\/src\/main\.js(?:\?|$)/.test(script.src), @@ -52,7 +52,7 @@ try { enabled, layers: app.dataManager.layers.size, annotationCount: annotation.drawn, - viewerAlive: !app.viewer.isDestroyed(), + viewerAlive: !window.__godsEyeView.engine.isDestroyed(), credits: Boolean(document.querySelector('#cesium-credits')), }; }); @@ -83,7 +83,7 @@ try { return { status: application.getState().status, samePromise, - viewerDestroyed: app.viewer.isDestroyed(), + viewerDestroyed: app.engine.isDestroyed(), layers: app.dataManager.layers.size, annotations: app.annotations.count(), governor: app.getRenderGovernorDiagnostics(), @@ -112,5 +112,5 @@ try { 'PASS: terminal teardown releases runtime owners without late browser errors', ); } finally { - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-attribution-b12.mjs b/scripts/qa-attribution-b12.mjs index 8875f1e19..97ae042cc 100644 --- a/scripts/qa-attribution-b12.mjs +++ b/scripts/qa-attribution-b12.mjs @@ -1,3 +1,5 @@ +// Cesium-only probes: the native credit registry and lightbox are the attribution oracle. +import { whenSettled, whenUiSettled } from './qa/ready.mjs'; /** * qa-attribution-b12.mjs — visual + state proof for Batch 12 (data attribution). * @@ -16,7 +18,7 @@ * * Run: node scripts/qa-attribution-b12.mjs --url http://localhost:4300 */ -import puppeteer from 'puppeteer'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import { existsSync, mkdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; @@ -32,19 +34,7 @@ const APP_ORIGIN = new URL(APP_URL).origin; const SHOT_DIR = resolve(__dirname, '..', 'qa-shots', 'b12'); mkdirSync(SHOT_DIR, { recursive: true }); -const CHROME_EXECUTABLE_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -function findChromeExecutable() { - return CHROME_EXECUTABLE_CANDIDATES.find((candidate) => { - try { return existsSync(candidate); } catch { return false; } - }) || null; -} let passed = 0; let failed = 0; @@ -70,10 +60,8 @@ const REQUIRED_CREDIT_SUBSTRINGS = [ ]; async function main() { - const chromeExecutable = findChromeExecutable(); - const browser = await puppeteer.launch({ - headless: 'new', - ...(chromeExecutable ? { executablePath: chromeExecutable } : {}), + const browser = await launchBrowser({ + headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'], }); const page = await browser.newPage(); @@ -122,8 +110,9 @@ async function main() { () => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.viewer.creditDisplay, { timeout: 60000 }, ); + await whenSettled(page); // Give Cesium a few frames to render the on-screen credit line (logo + link). - await new Promise((r) => setTimeout(r, 2500)); + await whenUiSettled(page); // ── (i) H11: static credits registered ──────────────────────────── console.log('H11 — per-layer credits registered in viewer.creditDisplay'); @@ -147,11 +136,11 @@ async function main() { // Force a render frame so the credit display flushes, then open the lightbox. const lightboxState = await page.evaluate(async () => { const viewer = window.__godsEyeView.viewer; - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); const cd = viewer.creditDisplay; cd.showLightbox(); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); const box = document.querySelector('.cesium-credit-lightbox'); const list = box?.querySelector(':scope > ul'); @@ -263,7 +252,7 @@ async function main() { await page.evaluate(async () => { const viewer = window.__godsEyeView.viewer; viewer.resize(); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); }); const mobileLightbox = await page.evaluate(() => { @@ -311,7 +300,7 @@ async function main() { check('found datacenter + cable layer ids', !!dcId && !!cableId, `dc=${dcId} cable=${cableId}`); if (dcId) await page.evaluate((id) => window.__godsEyeView.dataManager.setEnabled(id, true), dcId); if (cableId) await page.evaluate((id) => window.__godsEyeView.dataManager.setEnabled(id, true), cableId); - await new Promise((r) => setTimeout(r, 800)); + await whenUiSettled(page); const afterEnableHtml = await page.evaluate(() => (window.__godsEyeView.viewer.creditDisplay._staticCredits || []).map((c) => c.html), ); @@ -348,7 +337,7 @@ async function main() { // ── (iii) clean-view: credit line STILL visible ─────────────────── console.log('\nH10 — clean-view keeps the credit line visible'); await page.evaluate(() => document.body.classList.add('ui-clean-view')); - await new Promise((r) => setTimeout(r, 400)); + await whenUiSettled(page); const cleanVis = await creditVisibility(); await page.screenshot({ path: resolve(SHOT_DIR, 'clean-view.png') }); check( @@ -366,7 +355,7 @@ async function main() { // ── (iv) recording-mode: credit line STILL visible ──────────────── console.log('\nH10 — recording-mode keeps the credit line visible'); await page.evaluate(() => document.body.classList.add('recording-mode')); - await new Promise((r) => setTimeout(r, 400)); + await whenUiSettled(page); const recVis = await creditVisibility(); await page.screenshot({ path: resolve(SHOT_DIR, 'recording-mode.png') }); check( @@ -377,7 +366,7 @@ async function main() { await page.evaluate(() => document.body.classList.remove('recording-mode')); // baseline (normal) screenshot for comparison - await new Promise((r) => setTimeout(r, 300)); + await whenUiSettled(page); await page.screenshot({ path: resolve(SHOT_DIR, 'normal.png') }); check( @@ -391,7 +380,7 @@ async function main() { failedResponses.slice(0, 3).join(' | ') || 'clean', ); - await browser.close(); + await closeBrowser(browser); console.log('\n────────────────────────────────────────────────────────────'); console.log(` RESULT: ${passed} passed, ${failed} failed`); diff --git a/scripts/qa-browserEvidence.mjs b/scripts/qa-browserEvidence.mjs index e2d9677b9..af1ca0217 100644 --- a/scripts/qa-browserEvidence.mjs +++ b/scripts/qa-browserEvidence.mjs @@ -105,6 +105,8 @@ export function hookRenderErrors(page) { return page.evaluateOnNewDocument(() => { window.__qaRenderErrors = []; const hook = () => { + // Cesium-only: its render loop catches errors before pageerror sees them. + if (window.__godsEyeView?.engine?.kind === 'navara') return true; const scene = window.__godsEyeView?.viewer?.scene; if (!scene?.renderError) return false; scene.renderError.addEventListener((_, error) => diff --git a/scripts/qa-cables-overlay.mjs b/scripts/qa-cables-overlay.mjs index e5f0cf834..59a65c6b1 100644 --- a/scripts/qa-cables-overlay.mjs +++ b/scripts/qa-cables-overlay.mjs @@ -1,4 +1,8 @@ #!/usr/bin/env node +// Cesium-only probes: native DataSource counts and scene-render timing measure the Cesium cable adapter. +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; /** * qa-cables-overlay — submarine-cable label path measurement + contract gate. * @@ -20,7 +24,6 @@ * Requires a running dev server. Headless; rAF throttling disabled so the * frame clock is honest (hidden-pane gotcha). */ -import puppeteer from 'puppeteer'; const argv = process.argv; const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214'; @@ -40,35 +43,26 @@ function report(name, detail) { console.log(` [MEAS] ${name} — ${JSON.stringify(detail)}`); } -const browser = await puppeteer.launch({ - headless: 'new', +const browser = await launchBrowser({ + headless: true, protocolTimeout: 300_000, - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--window-size=1440,900', - '--disable-backgrounding-occluded-windows', - '--disable-renderer-backgrounding', - '--disable-background-timer-throttling', - ], + args: ['--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-background-timer-throttling'], }); try { const page = await browser.newPage(); await page.setViewport({ width: 1440, height: 860 }); await page.goto(url, { waitUntil: 'domcontentloaded' }); - await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 }); - await new Promise((r) => setTimeout(r, 12_000)); // boot flyTo + deferred init + await page.waitForFunction(() => !!window.__godsEyeView?.engine, { timeout: 90_000 }); + await whenSettled(page); // boot flyTo + deferred init // Park mid-Atlantic (many cables + both coasts' landings in range) and // disable every layer so the cables layer is measured in isolation. - await page.evaluate(async () => { + await evaluateWithGeo(page, async (qaGeo) => { const gev = window.__godsEyeView; - const v = gev.viewer; - v.camera.cancelFlight(); - const ell = v.scene.globe.ellipsoid; - v.camera.setView({ - destination: ell.cartographicToCartesian({ + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: -40 * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_000, }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, @@ -77,7 +71,7 @@ try { if (entry.enabled) { try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* measured via counts */ } } } }); - await new Promise((r) => setTimeout(r, 6_000)); // tiles + fades settle + await whenSettled(page); // tiles + fades settle // ── b. toggle-ON → labels visible ───────────────────────────────────── if (!control) { @@ -96,14 +90,14 @@ try { }; while (performance.now() < deadline) { if (labelsUp()) return { ms: Math.round(performance.now() - t0), timedOut: false }; - gev.viewer.scene.requestRender?.(); + window.__godsEyeView.engine.render.request?.(); await new Promise((r) => setTimeout(r, 100)); } return { ms: 60_000, timedOut: true }; }, LAYER_ID, legacy); report('toggle-ON -> labels visible (ms)', labelLatency); check('labels became visible after enable', labelLatency.timedOut === false, labelLatency); - await new Promise((r) => setTimeout(r, 3_000)); // let load/labels settle + await whenSettled(page); // let load/labels settle } // ── c. drawn-set / label / host counts ──────────────────────────────── @@ -190,11 +184,10 @@ try { : 'scene.render cost, cables ON, 10s orbit', frameCost); // ── d. parked idle honesty (labels must not force continuous render) ── - await new Promise((r) => setTimeout(r, 4_000)); // orbit stop + fades settle + await whenSettled(page); // orbit stop + fades settle const idle = await page.evaluate(() => new Promise((resolve) => { - const scene = window.__godsEyeView.viewer.scene; let renders = 0; - const remove = scene.postRender.addEventListener(() => { renders += 1; }); + const remove = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { renders += 1; }); setTimeout(() => { remove(); resolve({ renders }); }, 5_000); })); report(control @@ -224,7 +217,7 @@ try { const cycle = await page.evaluate(async (layerId) => { const gev = window.__godsEyeView; await gev.dataManager.setEnabled(layerId, false, { origin: 'user' }); - gev.viewer.scene.requestRender?.(); + window.__godsEyeView.engine.render.request?.(); await new Promise((r) => setTimeout(r, 1_200)); const offDiag = window.__gevWorldOverlay?.getDiagnostics?.() || {}; const offEntries = offDiag.entriesBySource?.[layerId] || 0; @@ -236,7 +229,7 @@ try { const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {}; onPainted = diag.paintedBySource?.[layerId] || 0; if (onPainted > 0) break; - gev.viewer.scene.requestRender?.(); + window.__godsEyeView.engine.render.request?.(); await new Promise((r) => setTimeout(r, 100)); } return { offEntries, offPainted, onPainted }; @@ -245,7 +238,7 @@ try { check('re-enable restores painted labels', cycle.onPainted > 0, cycle); } } finally { - await browser.close(); + await closeBrowser(browser); } const passed = results.filter((r) => r.pass).length; diff --git a/scripts/qa-cables-render-probe.mjs b/scripts/qa-cables-render-probe.mjs index babef945d..c174e5080 100644 --- a/scripts/qa-cables-render-probe.mjs +++ b/scripts/qa-cables-render-probe.mjs @@ -1,4 +1,8 @@ #!/usr/bin/env node +// Cesium-only probes: manual native renders isolate Cesium cable render cost. +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; /** * qa-cables-render-probe — replicates the owner-side perf probe methodology * for the cables layer (relative numbers; SwiftShader when run headless): @@ -11,35 +15,29 @@ * * Usage: node scripts/qa-cables-render-probe.mjs [--url http://localhost:4216] */ -import puppeteer from 'puppeteer'; const argv = process.argv; const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4216'; const LAYER_ID = 'telegeography-submarine-cables'; -const browser = await puppeteer.launch({ - headless: 'new', +const browser = await launchBrowser({ + headless: true, protocolTimeout: 300_000, - args: [ - '--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900', - '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', - '--disable-background-timer-throttling', - ], + args: ['--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-background-timer-throttling'], }); try { const page = await browser.newPage(); await page.setViewport({ width: 1440, height: 860 }); await page.goto(url, { waitUntil: 'domcontentloaded' }); - await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 }); - await new Promise((r) => setTimeout(r, 12_000)); + await page.waitForFunction(() => !!window.__godsEyeView?.engine, { timeout: 90_000 }); + await whenSettled(page); - await page.evaluate(async () => { + await evaluateWithGeo(page, async (qaGeo) => { const gev = window.__godsEyeView; - gev.viewer.camera.cancelFlight(); - const ell = gev.viewer.scene.globe.ellipsoid; - gev.viewer.camera.setView({ - destination: ell.cartographicToCartesian({ + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: -40 * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_000, }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, @@ -48,7 +46,7 @@ try { if (entry.enabled) { try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* probe */ } } } }); - await new Promise((r) => setTimeout(r, 5_000)); + await whenSettled(page); const stats = (values) => ({ mean: +(values.reduce((s, v) => s + v, 0) / Math.max(1, values.length)).toFixed(2), @@ -60,7 +58,7 @@ try { const gev = window.__godsEyeView; await gev.dataManager.setEnabled(id, on, { origin: 'user' }); }, LAYER_ID, enabled); - await new Promise((r) => setTimeout(r, enabled ? 6_000 : 3_000)); // load/teardown + settle + await whenSettled(page); // load/teardown + settle // 1. PARKED timed renders. const parked = await page.evaluate(() => { @@ -77,18 +75,17 @@ try { // 2. PAN-STOP cycles: move, raise moveEnd, wait 650 ms, 20 timed renders. const cycles = []; for (let cycle = 0; cycle < 3; cycle++) { - const frames = await page.evaluate(async (step) => { + const frames = await evaluateWithGeo(page, async (qaGeo, step) => { const v = window.__godsEyeView.viewer; - const ell = v.scene.globe.ellipsoid; - v.camera.setView({ - destination: ell.cartographicToCartesian({ + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: (-40 + step * 3) * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_000, }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); - v.camera.moveEnd.raiseEvent(); + window.__godsEyeView.engine.camera.moveEnd.raiseEvent(); await new Promise((resolve) => setTimeout(resolve, 650)); const durations = []; for (let i = 0; i < 20; i++) { @@ -112,5 +109,5 @@ try { console.log(`\nparked delta (ON-OFF mean): ${(on.parked.mean - off.parked.mean).toFixed(2)}ms`); console.log(`pan-stop worst: ON ${on.worst}ms vs OFF ${off.worst}ms`); } finally { - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-cables-shot.mjs b/scripts/qa-cables-shot.mjs index 3e3008e03..a9b0cbbd8 100644 --- a/scripts/qa-cables-shot.mjs +++ b/scripts/qa-cables-shot.mjs @@ -1,10 +1,12 @@ #!/usr/bin/env node +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled } from './qa/ready.mjs'; /** * qa-cables-shot — capture the cables layer at two fixed cameras for * before/after visual-identity comparison. Writes to gitignored qa-shots/. * Usage: node scripts/qa-cables-shot.mjs [--url http://localhost:4214] [--tag before] */ -import puppeteer from 'puppeteer'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import { mkdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; @@ -14,24 +16,20 @@ const tag = argv.includes('--tag') ? argv[argv.indexOf('--tag') + 1] : 'shot'; const LAYER_ID = 'telegeography-submarine-cables'; mkdirSync(new URL('../qa-shots', import.meta.url), { recursive: true }); -const browser = await puppeteer.launch({ - headless: 'new', +const browser = await launchBrowser({ + headless: true, protocolTimeout: 300_000, - args: [ - '--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900', - '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', - '--disable-background-timer-throttling', - ], + args: ['--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-background-timer-throttling'], }); try { const page = await browser.newPage(); await page.setViewport({ width: 1440, height: 860 }); await page.goto(url, { waitUntil: 'domcontentloaded' }); - await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 }); - await new Promise((r) => setTimeout(r, 12_000)); + await page.waitForFunction(() => !!window.__godsEyeView?.engine, { timeout: 90_000 }); + await whenSettled(page); await page.evaluate(async (layerId) => { const gev = window.__godsEyeView; - gev.viewer.camera.cancelFlight(); + window.__godsEyeView.engine.camera.cancelFlight(); for (const [id, entry] of gev.dataManager.layers) { if (entry.enabled && id !== layerId) { try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* shot only */ } @@ -45,11 +43,9 @@ try { { name: 'ny-coast', lon: -73.5, lat: 40.4, height: 500_000 }, ]; for (const view of views) { - await page.evaluate((v) => { - const viewer = window.__godsEyeView.viewer; - const ell = viewer.scene.globe.ellipsoid; - viewer.camera.setView({ - destination: ell.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo, v) => { + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: v.lon * Math.PI / 180, latitude: v.lat * Math.PI / 180, height: v.height, }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, @@ -57,15 +53,14 @@ try { }, view); // Let the sweep + labels settle with frames flowing. await page.evaluate(() => new Promise((resolve) => { - const v = window.__godsEyeView.viewer; let ticks = 0; const tick = () => { - v.scene.requestRender?.(); + window.__godsEyeView.engine.render.request?.(); if (++ticks < 240) requestAnimationFrame(tick); else resolve(); }; requestAnimationFrame(tick); })); - await new Promise((r) => setTimeout(r, 1_000)); + await whenSettled(page); const path = fileURLToPath( new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url), ); @@ -73,5 +68,5 @@ try { console.log(`saved ${path}`); } } finally { - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-camera-controls.mjs b/scripts/qa-camera-controls.mjs index c2d2b5646..7dbe8111f 100644 --- a/scripts/qa-camera-controls.mjs +++ b/scripts/qa-camera-controls.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node /** Camera-panel browser acceptance with controlled sources, images and terrain. */ +import { whenSettled, whenUiSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; -import puppeteer from 'puppeteer'; const origin = process.env.QA_BASE_URL || 'http://localhost:4173'; const selectAllModifier = process.platform === 'darwin' ? 'Meta' : 'Control'; const sources = [ @@ -31,13 +32,11 @@ const sources = [ rangeM: 300, mountHeightM: 8, })); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: [ '--no-sandbox', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), ], }); const page = await browser.newPage(); @@ -108,10 +107,10 @@ try { await page.waitForFunction( () => window.__godsEyeView?.styleManager?._cctvControls && - window.__godsEyeView?.dataManager?.layers?.has('cctv') && - document.getElementById('loading-screen')?.classList.contains('hidden'), + window.__godsEyeView?.dataManager?.layers?.has('cctv'), { timeout: 60000 }, ); + await whenSettled(page); await page.$eval('[data-collapse-target="cctv-panel"]', (button) => button.click(), ); @@ -357,22 +356,23 @@ try { fs.mkdirSync('qa-shots/camera-controls', { recursive: true }); await page.screenshot({ path: 'qa-shots/camera-controls/desktop.png' }); await page.evaluate(() => { - const camera = window.__godsEyeView.viewer.camera; - camera.setView({ + window.__godsEyeView.engine.camera.setView({ + destination: window.__godsEyeView.engine.camera.pose().position, orientation: { - heading: camera.heading + Math.PI / 3, + heading: + window.__godsEyeView.engine.camera.pose().heading + Math.PI / 3, pitch: -0.6, roll: 0, }, }); }); - await new Promise((resolve) => setTimeout(resolve, 700)); + await whenUiSettled(page); await page.screenshot({ path: 'qa-shots/camera-controls/angle.png' }); await page.setViewport({ width: 390, height: 844 }); - await new Promise((resolve) => setTimeout(resolve, 700)); + await whenUiSettled(page); // Phones present CCTV in the Cameras tab's sheet. await page.click('#mobile-tab-bar [data-mobile-sheet="cameras"]'); - await new Promise((resolve) => setTimeout(resolve, 400)); + await whenUiSettled(page); await page.screenshot({ path: 'qa-shots/camera-controls/narrow.png' }); check( 'narrow camera panel remains within the viewport', @@ -411,6 +411,6 @@ try { ); } finally { await Promise.all([...delayedFrames].map((respond) => respond())); - await browser.close(); + await closeBrowser(browser); } if (failures) process.exitCode = 1; diff --git a/scripts/qa-cctv-v2.mjs b/scripts/qa-cctv-v2.mjs index 712d8c865..21a95c841 100644 --- a/scripts/qa-cctv-v2.mjs +++ b/scripts/qa-cctv-v2.mjs @@ -1,3 +1,6 @@ +// Cesium-only probes: native raycast fault injection, camera transforms and entity sentinels test the Cesium adapter. +import { withGeo } from './qa/geometry.mjs'; +import { whenFramesDrawn, whenSettled, pollPage, whenTilesLoaded } from './qa/ready.mjs'; /** * qa-cctv-v2.mjs — CCTV v2 subsystem proof harness * @@ -76,7 +79,7 @@ * Exit 0 = all asserts passed. Non-zero = a hard failure. */ -import puppeteer from 'puppeteer'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import { watchBrowserPage, redact } from './qa-browserEvidence.mjs'; import { blockPaidQaMaps } from './qa-map-policy.mjs'; import { installCctvFixture } from './fixtures/layers/cctv.mjs'; @@ -126,30 +129,7 @@ const saveEvidence = () => { ); }; -const CHROME_EXECUTABLE_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - // Prefer puppeteer's version-pinned Chrome-for-Testing over the system - // Chrome: /Applications auto-updates underneath the harnesses, and its - // software-GL behavior shifts across majors (system Chrome 150 blew the - // tile-gated drain budget under SwiftShader on 2026-07-30 — six - // false-negative qa-cctv-v2 runs against a healthy build). A deterministic - // pinned browser beats the newest one for regression harnesses. - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -function findChromeExecutable() { - for (const candidate of CHROME_EXECUTABLE_CANDIDATES) { - try { - if (fs.existsSync(candidate)) return candidate; - } catch { - /* ignore */ - } - } - return null; -} const results = evidence.checks; function record(name, ok, detail) { @@ -189,32 +169,6 @@ const PROTOCOL_TIMEOUT_MS = 300_000; * The predicate must return a boolean: its result crosses CDP by value. * @returns {Promise} True if the predicate held within the budget. */ -async function waitForPredicate( - page, - predicate, - { timeoutMs, intervalMs = 250, args = [] }, -) { - // performance.now() is monotonic and doesn't advance while the machine - // sleeps, so neither a clock change nor a laptop sleep eats the budget. - const deadline = performance.now() + timeoutMs; - for (;;) { - try { - if (await page.evaluate(predicate, ...args)) return true; - } catch (err) { - // A navigation or Vite reload replaces the execution context: poll again. - if ( - !/Execution context was destroyed|Cannot find context/.test( - err?.message ?? '', - ) - ) { - throw err; - } - } - const remaining = deadline - performance.now(); - if (remaining <= 0) return false; - await sleep(Math.min(intervalMs, remaining)); - } -} // SwiftShader can spend tens of seconds servicing the hover drillPick emitted // by page.mouse.move(), before the LEFT_DOWN under test even runs. Position the @@ -266,22 +220,21 @@ function installRenderFramesHelper(page) { return page.evaluate(() => { window.__qaRenderFrames = (count, capMs) => new Promise((resolve) => { - const scene = window.__godsEyeView.viewer.scene; let frames = 0; const finish = () => { clearTimeout(timer); remove(); resolve(frames); }; - const remove = scene.postRender.addEventListener(() => { + const remove = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { if (++frames >= count) { finish(); return; } - scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); const timer = setTimeout(finish, capMs); - scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); }); } @@ -462,23 +415,6 @@ const BEARING_SHIFT_SRC = ` * @param {number} [timeoutMs=15000] * @returns {Promise} True if tilesLoaded was observed within the timeout. */ -function waitForTilesLoaded(page, timeoutMs = 15000) { - return waitForPredicate( - page, - () => { - const scene = window.__godsEyeView.viewer.scene; - const prims = scene.primitives; - for (let i = 0; i < prims.length; i++) { - const p = prims.get(i); - if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') { - return p.tilesLoaded === true; - } - } - return true; // no tileset in the scene — nothing to wait for - }, - { timeoutMs }, - ); -} /** * Searches for a calibration patch (pitchDeg/fovDeg/rangeScale offsets) that @@ -542,24 +478,13 @@ async function main() { fs.mkdirSync(SHOTS_DIR, { recursive: true }); - const chromeExecutable = findChromeExecutable(); - const browser = await puppeteer.launch({ + const browser = await launchBrowser({ headless: true, protocolTimeout: PROTOCOL_TIMEOUT_MS, // Talk CDP over a pipe, so no debugging port stays open on localhost for // the length of a headful run. pipe: true, - ...(chromeExecutable ? { executablePath: chromeExecutable } : {}), - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--use-angle=metal', - '--enable-gpu', - '--disable-dev-shm-usage', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--window-size=1280,800', - ], + args: ['--no-sandbox', '--disable-setuid-sandbox', '--enable-gpu', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1280,800'], }); const consoleErrors = evidence.browserErrors; @@ -596,7 +521,7 @@ async function main() { }); console.log('Loading app...'); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); - const appReady = await waitForPredicate( + const appReady = await pollPage( page, () => Boolean( @@ -638,7 +563,7 @@ async function main() { if (!keyless) throw new Error('Expected a keyless globe before CCTV QA'); } // Let the initial fly-to Austin and first tiles settle. - await sleep(4000); + await whenSettled(page); // ----------------------------------------------------------------------- // Install raycast counters BEFORE the layer is enabled (§9.1-amended: the @@ -653,7 +578,7 @@ async function main() { if ( !scene || typeof scene.pickFromRay !== 'function' || - typeof scene.sampleHeight !== 'function' + typeof window.__godsEyeView.engine.surface.sampleHeight !== 'function' ) { return false; } @@ -663,7 +588,7 @@ async function main() { window.__qaCounters.pickFromRay += 1; return origPick(...args); }; - const origSample = scene.sampleHeight.bind(scene); + const origSample = window.__godsEyeView.engine.surface.sampleHeight.bind(scene); scene.sampleHeight = (...args) => { window.__qaCounters.sampleHeight += 1; return origSample(...args); @@ -719,7 +644,7 @@ async function main() { console.log( `Waiting for geometry-load queue to drain (N=${camCount}, budget ${Math.round(drainBudgetMs / 1000)}s)...`, ); - const drained = await waitForPredicate( + const drained = await pollPage( page, () => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; @@ -763,7 +688,7 @@ async function main() { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.focusNearest({ durationSec: 0.1 }); }); - await sleep(500); // let the activation's synchronous work (probe + geometry rewrite) land + await whenFramesDrawn(page); // let the activation's synchronous work (probe + geometry rewrite) land const cAfterActivate = await readCounters(); const activeId = await page.evaluate( () => @@ -793,7 +718,7 @@ async function main() { .get('cctv') .module.setParams({ selectedCameraId: id }); }, activeId); - await sleep(400); + await whenFramesDrawn(page); const cAfterReselect = await readCounters(); const geomAfterReselect = await page.evaluate(`(() => { ${SERIALIZE_GEOM_SRC} @@ -816,7 +741,7 @@ async function main() { // latency of the completion pass (update interval is 10s). // ----------------------------------------------------------------------- console.log('Waiting for tiles + the one-shot completion pass...'); - const tilesSeenLoaded = await waitForTilesLoaded(page, 45000); + const tilesSeenLoaded = await whenTilesLoaded(page, { timeoutMs: 45000 }).then(() => true).catch(() => false); let sampleFloorOk = false; if (tilesSeenLoaded && !terrainGlobe) { sampleFloorOk = await page @@ -866,10 +791,10 @@ async function main() { terrain.ok && heights?.length === 2 && heights.every(Number.isFinite); const mountResolved = terrainReady && - (await waitForPredicate( + (await pollPage( page, - (expected, base) => { - const { viewer, dataManager, engine } = window.__godsEyeView; + await withGeo((qaGeo, expected, base) => { + const { dataManager, engine } = window.__godsEyeView; const module = dataManager.layers.get('cctv').module; const camera = module.getUIState().activeCamera; // The corner ray is an engine line; its first point (the mount) @@ -883,7 +808,7 @@ async function main() { const mount = drawn ? ray?.polyline?.positions?.[0] : undefined; const height = mount && - viewer.scene.globe.ellipsoid.cartesianToCartographic(mount) + qaGeo.cartographic.fromCartesian(mount) .height; return ( Number.isFinite(camera.groundPriorM) && @@ -895,8 +820,8 @@ async function main() { ) && window.__qaCounters.sampleHeight === base ); - }, - { timeoutMs: 30000, args: [heights, c0.sampleHeight] }, + }, heights, c0.sampleHeight), + { timeoutMs: 30000 }, )); record( 'terrain-globe completion uses a real DEM height at the rendered mount without mesh samples', @@ -905,10 +830,10 @@ async function main() { ); } else { console.log('Waiting for tiles + the one-shot completion pass...'); - const tilesSeenLoaded = await waitForTilesLoaded(page, 45000); + const tilesSeenLoaded = await whenTilesLoaded(page, { timeoutMs: 45000 }).then(() => true).catch(() => false); let sampleFloorOk = false; if (tilesSeenLoaded) { - sampleFloorOk = await waitForPredicate( + sampleFloorOk = await pollPage( page, (base) => window.__qaCounters.sampleHeight - base >= 1, { timeoutMs: 30000, args: [c0.sampleHeight] }, @@ -993,7 +918,7 @@ async function main() { calibration: { cameraId: camId, patch: { headingDeg: 30 } }, }); }, activeId); - await sleep(500); + await whenFramesDrawn(page); const cAfterCal = await readCounters(); const sampleDeltaCal = cAfterCal.sampleHeight - cBeforeCal.sampleHeight; const pickDeltaCal = cAfterCal.pickFromRay - cBeforeCal.pickFromRay; @@ -1016,7 +941,7 @@ async function main() { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ calibration: { cameraId: camId, reset: true } }); }, activeId); - await sleep(300); + await whenFramesDrawn(page); // ========================================================================= // Group 2: geometry contract @@ -1098,7 +1023,7 @@ async function main() { ? JSON.stringify(geomInfo.plane.dimensions) : null; for (let attempt = 0; attempt < 3 && !patchGeometryApplied; attempt++) { - await waitForTilesLoaded(page, 15000); + await whenTilesLoaded(page, { timeoutMs: 15000 }).then(() => true).catch(() => false); await page.evaluate( ({ camId, patch }) => { const mod = @@ -1107,7 +1032,7 @@ async function main() { }, { camId: activeId, patch: safePatch }, ); - await sleep(400); + await whenFramesDrawn(page); geomForCorners = await page.evaluate( `(function(camId){ ${SERIALIZE_GEOM_SRC} return serializeGeom(camId); })(${JSON.stringify(activeId)})`, ); @@ -1204,7 +1129,7 @@ async function main() { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ calibration: { cameraId: camId, reset: true } }); }, activeId); - await sleep(300); + await whenFramesDrawn(page); } // Byte-stable over 30s idle: serialize + compare after 30s. Nothing @@ -1283,7 +1208,7 @@ async function main() { calibration: { cameraId: camId, patch: { headingDeg: 30 } }, }); }, activeId); - await sleep(300); + await whenFramesDrawn(page); // Save-gated persistence (v3 design §3e): a PATCH edits the live pose // only — the store must stay untouched and the camera must read as @@ -1321,7 +1246,7 @@ async function main() { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ calibration: { cameraId: camId, save: true } }); }, activeId); - await sleep(200); + await whenFramesDrawn(page); const storeEntry = await page.evaluate((camId) => { const raw = localStorage.getItem('godsEyeView.cctv.calibration.v2'); @@ -1371,7 +1296,7 @@ async function main() { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ calibration: { cameraId: camId, reset: true } }); }, activeId); - await sleep(300); + await whenFramesDrawn(page); const storeAfterReset = await page.evaluate((camId) => { const raw = localStorage.getItem('godsEyeView.cctv.calibration.v2'); @@ -1483,7 +1408,7 @@ async function main() { const gev = window.__godsEyeView; const viewer = gev.viewer; const scene = viewer.scene; - const canvas = scene.canvas; + const canvas = window.__godsEyeView.engine.host.canvas; const rect = canvas.getBoundingClientRect(); const { hitTest } = ( await import('/src/overlays/worldOverlay.js') @@ -1535,28 +1460,28 @@ async function main() { const mod = gev.dataManager.layers.get('cctv').module; const snapshotPose = () => ({ position: [ - viewer.camera.positionWC.x, - viewer.camera.positionWC.y, - viewer.camera.positionWC.z, + window.__godsEyeView.engine.camera.pose().position.x, + window.__godsEyeView.engine.camera.pose().position.y, + window.__godsEyeView.engine.camera.pose().position.z, ], direction: [ - viewer.camera.directionWC.x, - viewer.camera.directionWC.y, - viewer.camera.directionWC.z, + window.__godsEyeView.engine.camera.pose().direction.x, + window.__godsEyeView.engine.camera.pose().direction.y, + window.__godsEyeView.engine.camera.pose().direction.z, ], - up: [viewer.camera.upWC.x, viewer.camera.upWC.y, viewer.camera.upWC.z], + up: [window.__godsEyeView.engine.camera.pose().up.x, window.__godsEyeView.engine.camera.pose().up.y, window.__godsEyeView.engine.camera.pose().up.z], right: [ - viewer.camera.rightWC.x, - viewer.camera.rightWC.y, - viewer.camera.rightWC.z, + window.__godsEyeView.engine.camera.pose().right.x, + window.__godsEyeView.engine.camera.pose().right.y, + window.__godsEyeView.engine.camera.pose().right.z, ], transform: Array.from( { length: 16 }, (_, i) => viewer.camera.transform[i], ), - heading: viewer.camera.heading, - pitch: viewer.camera.pitch, - roll: viewer.camera.roll, + heading: window.__godsEyeView.engine.camera.pose().heading, + pitch: window.__godsEyeView.engine.camera.pose().pitch, + roll: window.__godsEyeView.engine.camera.pose().roll, trackedId: viewer.trackedEntity?.id ?? null, }); const cameraPoseMatches = (a, b, epsilon = 1e-5) => { @@ -1626,7 +1551,7 @@ async function main() { .module.setParams({ calibrationMode: true }); }); await page.mouse.click(emptyClickPoint.x, emptyClickPoint.y); - await sleep(250); + await whenFramesDrawn(page); const adjustClick = await page.evaluate(() => { const evidence = window.__qaCctvClickEvidence; const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; @@ -1673,7 +1598,7 @@ async function main() { distance, new ray.origin.constructor(), ); - const screen = scene.cartesianToCanvasCoordinates(candidate); + const screen = window.__godsEyeView.engine.projector.worldToWindow(candidate); if ( screen && Math.hypot(screen.x - canvasPoint.x, screen.y - canvasPoint.y) < 2 @@ -1695,7 +1620,7 @@ async function main() { }); window.__qaCctvSiblingOwner = owner; const framesRendered = await window.__qaRenderFrames(3, 10000); - const rect = scene.canvas.getBoundingClientRect(); + const rect = window.__godsEyeView.engine.host.canvas.getBoundingClientRect(); const picked = scene.pick(canvasPoint); const pickedId = picked?.id?.id ?? picked?.id ?? null; window.__qaCctvClickEvidence.siblingBaselineTransitions = @@ -1735,7 +1660,7 @@ async function main() { ); if (siblingOwnsPick) { await page.mouse.click(siblingTarget.x, siblingTarget.y); - await sleep(250); + await whenFramesDrawn(page); } const siblingClick = await page.evaluate(() => { const evidence = window.__qaCctvClickEvidence; @@ -1769,7 +1694,7 @@ async function main() { if (emptyClickPoint) { await page.mouse.click(emptyClickPoint.x, emptyClickPoint.y); - await sleep(250); + await whenFramesDrawn(page); } const firstEmptyClick = await page.evaluate(() => { const evidence = window.__qaCctvClickEvidence; @@ -1810,7 +1735,7 @@ async function main() { if (emptyClickPoint) { await page.mouse.click(emptyClickPoint.x, emptyClickPoint.y); - await sleep(250); + await whenFramesDrawn(page); } const repeatEmptyClick = await page.evaluate(() => { const evidence = window.__qaCctvClickEvidence; @@ -1894,7 +1819,7 @@ async function main() { mod.setParams({ coverageMode: 'viewshed' }); return mod.getUIState().coverageMode; }); - await sleep(400); + await whenFramesDrawn(page); record( 'coverageMode setParams round-trip (viewshed)', modeAfterSet === 'viewshed', @@ -1918,7 +1843,7 @@ async function main() { const on = mod.getUIState().coverageMode; return { off, on }; }); - await sleep(200); + await whenFramesDrawn(page); const volumesInOnMode = await countVolumes(); record( 'boolean showCoverage compat (false→off, true→on)', @@ -1938,7 +1863,7 @@ async function main() { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ coverageMode: 'viewshed' }); }); - await sleep(400); + await whenFramesDrawn(page); // NEXT starts an asynchronous footprint-height lookup. Its completion is // allowed to update geometry; the unchanged 8s idle window starts after it. const terrainDeadline = Date.now() + 35000; @@ -1998,7 +1923,7 @@ async function main() { // Frame that actual camera before requiring an on-screen physical drag. await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv') .module.setParams({ focusSelected: true, focusDurationSec: 0.2 })); - await sleep(800); + await whenFramesDrawn(page); const GIZMO_PARTS = [ 'ring-heading', 'ring-pitch', @@ -2113,7 +2038,7 @@ async function main() { grab.x + eastDrag.ux * i * 12, grab.y + eastDrag.uy * i * 12, ); - await sleep(40); + await whenFramesDrawn(page); transientDragStates.push( await page.evaluate(() => { const mod = @@ -2132,7 +2057,7 @@ async function main() { } const candidateCountersBeforeRelease = await readCounters(); await page.mouse.up(); - await sleep(400); + await whenFramesDrawn(page); const candidateEast = await page.evaluate(() => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; @@ -2328,7 +2253,7 @@ async function main() { // Screenshots supplement this state/geometry oracle. Positive rendered // media pixels are a separate mandatory pixel gate. // ----------------------------------------------------------------------- - await sleep(1000); + await whenFramesDrawn(page); const shot = path.join(SHOTS_DIR, 'cctv-v2-active.png'); await saveReviewShot(page, shot, 'visual review only, not a gate'); @@ -2344,7 +2269,7 @@ async function main() { } } finally { await fixture?.dispose(); - await browser.close(); + await closeBrowser(browser); evidence.pass = results.length > 0 && results.every((r) => r.ok === true) && diff --git a/scripts/qa-cockpit-plates.mjs b/scripts/qa-cockpit-plates.mjs index 3d3aece03..adf7861e9 100644 --- a/scripts/qa-cockpit-plates.mjs +++ b/scripts/qa-cockpit-plates.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { evaluateWithGeo } from './qa/geometry.mjs'; /** * Rendered proof for backdrop-selective callout plates at GROUND LEVEL. * @@ -46,13 +47,14 @@ * node scripts/qa-cockpit-plates.mjs --url http://localhost:4268 --tag before * node scripts/qa-cockpit-plates.mjs --url http://localhost:4268 --teeth * - * Defaults to ANGLE/Metal on macOS and software GL elsewhere. `--swiftshader` selects software GL, + * QA_ANGLE selects the renderer (Metal by default); use swiftshader for software GL, * which is deterministic but is NOT real-GPU evidence; the banner says which * backend actually ran and the run records it alongside the results. */ +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; const argv = process.argv.slice(2); const getOpt = (name, fallback) => { @@ -64,7 +66,7 @@ const APP_ORIGIN = new URL(APP_URL).origin; const TAG = getOpt('--tag', 'after'); const HEADFUL = argv.includes('--headful'); const TEETH = argv.includes('--teeth'); -const SWIFTSHADER = argv.includes('--swiftshader') || process.platform !== 'darwin'; +const SWIFTSHADER = /swiftshader|software/i.test(process.env.QA_ANGLE || 'metal'); const SHOT_DIR = path.resolve('qa-shots/cockpitplates'); /** Mirrors `SKY_PLATE_SCALE` in src/overlays/worldOverlayTokens.js. */ @@ -134,14 +136,6 @@ const POSES = [ }, ]; -const CHROME_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - // Version-pinned Chrome-for-Testing over the auto-updating system Chrome: - // its software-GL behavior shifts across majors and has produced false - // negatives in this repo's harnesses before. - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', -].filter(Boolean); const results = []; function record(name, ok, detail) { @@ -213,25 +207,9 @@ async function main() { } fs.mkdirSync(SHOT_DIR, { recursive: true }); - const executablePath = CHROME_CANDIDATES.find((candidate) => { - try { return fs.existsSync(candidate); } catch { return false; } - }); - const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', - ...(executablePath ? { executablePath } : {}), - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - // Metal is a macOS-only ANGLE backend; off-Mac the GPU branch has to fall - // back to the software rasterizer or WebGL init fails outright. - ...(SWIFTSHADER - ? ['--use-gl=angle', '--use-angle=swiftshader'] - : ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist']), - '--disable-dev-shm-usage', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--window-size=1280,800', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', ...(SWIFTSHADER ? [] : ['--enable-gpu', '--ignore-gpu-blocklist']), '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1280,800'], }); const consoleErrors = []; @@ -276,10 +254,7 @@ async function main() { await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90_000 }); await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 90_000 }); - await page.waitForFunction( - () => document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 90_000 }, - ); + await whenSettled(page); renderer = await page.evaluate(() => { const gl = document.createElement('canvas').getContext('webgl2') @@ -302,10 +277,10 @@ async function main() { // Inject the field. The layers keep their real pipelines; only the source // of observations is replaced, exactly as scripts/qa-labels.mjs does. - await page.evaluate((payload) => { - const { viewer, dataManager, styleManager } = window.__godsEyeView; - viewer.camera.cancelFlight(); - const Cartesian3 = viewer.camera.position.constructor; + await evaluateWithGeo(page, async (qaGeo, payload) => { + const { dataManager, styleManager } = window.__godsEyeView; + window.__godsEyeView.engine.camera.cancelFlight(); + const Cartesian3 = qaGeo.cartesian3; window.__PLATE_QA_FIELD = payload.contacts.map((contact) => ({ sourceId: contact.id.toLowerCase(), id: contact.id, @@ -330,17 +305,16 @@ async function main() { for (const pose of POSES) { console.log(`\n — pose "${pose.name}" (${pose.note})`); - await page.evaluate((payload) => { - const { viewer } = window.__godsEyeView; - const Cartesian3 = viewer.camera.position.constructor; - viewer.camera.setView({ + await evaluateWithGeo(page, async (qaGeo, payload) => { + const Cartesian3 = qaGeo.cartesian3; + window.__godsEyeView.engine.camera.setView({ destination: Cartesian3.fromDegrees(payload.lon, payload.lat, payload.height), orientation: { heading: 0, pitch: payload.pitchDeg * Math.PI / 180, roll: 0 }, }); }, { lon: JFK_LON, lat: JFK_LAT, height: pose.height, pitchDeg: pose.pitchDeg }); // Let the tiles settle and the label solve run before the tap window. - await new Promise((resolve) => setTimeout(resolve, 6_000)); + await whenTilesLoaded(page); await page.evaluate(() => { window.__PLATE_TAP.events = []; window.__PLATE_TAP.on = true; @@ -356,10 +330,9 @@ async function main() { // OUTPUT has to ask for its own renders. Production is unaffected: real // Cockpit holds, and a parked map keeps showing the last correct frame // precisely because nothing changed. - const scene = window.__godsEyeView.viewer.scene; const pump = () => { if (!window.__PLATE_TAP.on) return; - scene.requestRender(); + window.__godsEyeView.engine.render.request(); requestAnimationFrame(pump); }; pump(); @@ -522,7 +495,7 @@ async function main() { consoleErrors.slice(0, 3).join(' | ') || 'clean', ); } finally { - await browser.close(); + await closeBrowser(browser); } fs.writeFileSync( diff --git a/scripts/qa-cockpit-utility.mjs b/scripts/qa-cockpit-utility.mjs index 8aa712c01..d6571af42 100644 --- a/scripts/qa-cockpit-utility.mjs +++ b/scripts/qa-cockpit-utility.mjs @@ -1,9 +1,11 @@ #!/usr/bin/env node +// Cesium-only probes: tracked-entity and local-frame coordinates verify the native cockpit handoff. /** Focused rendered proof for adaptive Cockpit Display/Radio layout. */ +import { whenSettled, whenUiSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import puppeteer from 'puppeteer'; import { blockPaidQaMaps } from './qa-map-policy.mjs'; import { stubPlanespotters } from './qa-photo-stub.mjs'; @@ -13,22 +15,9 @@ const appUrl = process.env.QA_BASE_URL || 'http://localhost:4173'; const headful = process.argv.includes('--headful'); fs.mkdirSync(shotsDir, { recursive: true }); -const chromeCandidates = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', -].filter(Boolean); -const executablePath = chromeCandidates.find((candidate) => fs.existsSync(candidate)); -const browser = await puppeteer.launch({ - headless: headful ? false : 'new', - ...(executablePath ? { executablePath } : {}), - // Metal is a macOS-only ANGLE backend; anywhere else it fails WebGL init. - args: [ - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), - '--no-sandbox', - ], +const browser = await launchBrowser({ + headless: true, + args: [...(process.platform === 'darwin' ? ['--enable-gpu'] : []), '--no-sandbox'], }); const page = await browser.newPage(); await blockPaidQaMaps(page); @@ -171,10 +160,7 @@ try { page.on('request', routeQaRequest); await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 60_000 }); - await page.waitForFunction( - () => document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 60_000 }, - ); + await whenSettled(page); await page.waitForFunction(() => ( typeof window.__gevQaRegisterLayer === 'function' && typeof window.__gevQaUnregisterLayer === 'function' @@ -675,6 +661,7 @@ try { () => Boolean(window.__godsEyeView.viewer.trackedEntity?.position), { timeout: 10_000 }, ); + await whenSettled(page); const preselectedContactAdoption = await page.evaluate(async () => { const { styleManager, dataManager, viewer } = window.__godsEyeView; const flights = dataManager.layers.get('flights')?.module; @@ -767,7 +754,7 @@ try { } : null, navigation: snapshot?.navigation || null, afterId: trackedInfo?.icao24 || null, - viewerTrackedId: viewer.trackedEntity?.gevTrackedId || null, + viewerTrackedId: window.__godsEyeView.engine.camera.following()?.label?.id || null, contextVisible: !document.getElementById('military-awareness-panel')?.hidden, entryAvailableAfterSettlement: Boolean(entry && !entry.hidden), blockerStillRegistered: dataManager.layers.has(blockerId), @@ -854,8 +841,8 @@ try { const readinessUrl = new URL(appUrl); readinessUrl.searchParams.set('welcome', '0'); await readinessPage.goto(readinessUrl.href, { waitUntil: 'domcontentloaded' }); - await readinessPage.waitForFunction(() => window.__godsEyeView?.dataManager - && document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 60000 }); + await readinessPage.waitForFunction(() => window.__godsEyeView?.dataManager, { timeout: 60000 }); + await whenSettled(readinessPage); await readinessPage.evaluate(async () => { const manager = window.__godsEyeView.dataManager; await manager.setEnabled('flights', true, { origin: 'user' }); @@ -1100,7 +1087,7 @@ try { const awareness = dataManager.layers.get('military-awareness')?.module; const before = awareness?.getContextSnapshot?.()?.subject || null; const entityBefore = viewer.trackedEntity; - viewer.camera.zoomOut(1_500_000); + window.__godsEyeView.engine.camera.zoomOut(1_500_000); const cameraRange = () => Math.hypot( viewer.camera.position.x, viewer.camera.position.y, @@ -1465,7 +1452,7 @@ try { remove(); reject(new Error('Cockpit camera ownership check did not render a frame')); }, 5000); - const remove = viewer.scene.postRender.addEventListener(() => { + const remove = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { remove(); clearTimeout(timer); resolve(); @@ -1473,10 +1460,10 @@ try { }); // Let revoked listeners retire before comparing ownership across exit. await nextFrame(); - const listenersBeforeExit = viewer.scene.preUpdate.numberOfListeners; + const listenersBeforeExit = window.__godsEyeView.engine.frames.preUpdate.numberOfListeners; const exited = styleManager.cockpitView.exit() === true; await nextFrame(); - const listenersAfterExit = viewer.scene.preUpdate.numberOfListeners; + const listenersAfterExit = window.__godsEyeView.engine.frames.preUpdate.numberOfListeners; const refocused = awareness?.focusCurrent?.() === true; await nextFrame(); return { @@ -1485,7 +1472,7 @@ try { sameEntity: viewer.trackedEntity === entity, listenersBeforeExit, listenersAfterExit, - listenersAfterFocus: viewer.scene.preUpdate.numberOfListeners, + listenersAfterFocus: window.__godsEyeView.engine.frames.preUpdate.numberOfListeners, }; }); check( @@ -2088,7 +2075,7 @@ try { JSON.stringify(cyberStyleStates)); for (const height of [987, 720, 640, 900]) { await page.setViewport({ width: 1440, height, deviceScaleFactor: 1 }); - await new Promise((resolve) => setTimeout(resolve, 400)); + await whenUiSettled(page); const geometry = await page.evaluate(() => { const signal = document.getElementById('cockpit-signal-stream'); const utility = document.getElementById('cockpit-utility-controls'); @@ -2379,7 +2366,7 @@ try { await page.waitForFunction(() => { const viewer = window.__godsEyeView.viewer; if (!viewer) return false; - const height = viewer.camera.positionCartographic?.height; + const height = window.__godsEyeView.engine.camera.pose().cartographic?.height; return Math.abs(height - 18_000_000) < 150_000; }, { timeout: 6_000 }); const resetState = await page.evaluate(() => { @@ -2387,7 +2374,7 @@ try { const qa = window.__qaCockpitReset; const awareness = gev.dataManager.layers.get('military-awareness')?.module; const subjectId = awareness?.getContextSnapshot?.()?.subject?.id || null; - const height = gev.viewer.camera.positionCartographic?.height; + const height = window.__godsEyeView.engine.camera.pose().cartographic?.height; gev.styleManager._locationNavigation.resetToGlobeView = qa.original; delete window.__qaCockpitReset; return { @@ -2440,7 +2427,7 @@ try { else hud.style.removeProperty('--cockpit-utility-top'); if (!prior.cockpit) document.body.classList.remove('cockpit-mode'); }).catch(() => {}); - await browser.close(); + await closeBrowser(browser); } console.log(`RESULT: ${failures.length ? 'NOT_READY' : 'READY'} (${failures.length} failures)`); diff --git a/scripts/qa-context-arrivals.mjs b/scripts/qa-context-arrivals.mjs index 85be4ce9c..30a98ae13 100644 --- a/scripts/qa-context-arrivals.mjs +++ b/scripts/qa-context-arrivals.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { evaluateWithGeo } from './qa/geometry.mjs'; /** * qa-context-arrivals.mjs — search arrivals above the surface, and Contacts * listing mapped installations around a moving tracked aircraft. @@ -25,9 +26,10 @@ * Writes /result.json, screenshots and /clip.mp4 (CDP screencast * with real frame timing; needs ffmpeg). */ +import { whenSettled, whenFramesDrawn, whenTilesLoaded } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; import { stubPlanespotters } from './qa-photo-stub.mjs'; import { createScreencast, moveCamera } from './qa-journey-recorder.mjs'; @@ -62,14 +64,12 @@ const check = (name, ok, detail = '') => { if (!ok) result.failures.push(`${name}${detail ? ` — ${detail}` : ''}`); }; -const browser = await puppeteer.launch({ - headless: !argv.includes('--headful'), +const browser = await launchBrowser({ + headless: true, protocolTimeout: 300_000, args: [ '--no-sandbox', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', @@ -171,12 +171,10 @@ async function openPage({ aircraft = false } = {}) { const url = new URL(appUrl); url.searchParams.set('welcome', '0'); await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 90_000 }); - await page.waitForFunction( - () => - window.__godsEyeView?.dataManager && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 120_000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.dataManager, { + timeout: 120_000, + }); + await whenSettled(page); await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, ); @@ -185,31 +183,16 @@ async function openPage({ aircraft = false } = {}) { } async function waitTiles(page, maxMs = 30_000) { - await page - .waitForFunction( - () => { - const { scene } = window.__godsEyeView.viewer; - if (scene.globe.show) return scene.globe.tilesLoaded; - for (let i = 0; i < scene.primitives.length; i++) { - const p = scene.primitives.get(i); - if (p.show && p.tilesLoaded === false) return false; - } - return true; - }, - { timeout: maxMs, polling: 250 }, - ) - .catch(() => {}); + await whenTilesLoaded(page, { timeoutMs: maxMs }).catch(() => {}); } /** Camera height above the rendered surface under it and under the view centre. */ function clearance(page) { - return page.evaluate(() => { - const { viewer } = window.__godsEyeView; - const { scene, camera } = viewer; - const carto = camera.positionCartographic.clone(); + return evaluateWithGeo(page, async (qaGeo) => { + const carto = window.__godsEyeView.engine.camera.pose().cartographic; const sample = (c) => { try { - const h = scene.sampleHeight(c); + const h = window.__godsEyeView.engine.surface.sampleHeight(c); return Number.isFinite(h) ? h : null; } catch { return null; @@ -217,12 +200,12 @@ function clearance(page) { }; const under = sample(carto); let centre = null; - const canvas = scene.canvas; - const hit = scene.pickPosition?.({ + const canvas = window.__godsEyeView.engine.host.canvas; + const hit = window.__godsEyeView.engine.picker.pickPosition?.({ x: canvas.clientWidth / 2, y: canvas.clientHeight / 2, }); - if (hit) centre = scene.globe.ellipsoid.cartesianToCartographic(hit); + if (hit) centre = qaGeo.cartographic.fromCartesian(hit); const centreSurface = centre ? sample(centre) : null; const surface = Math.max(under ?? -Infinity, centreSurface ?? -Infinity); return { @@ -234,7 +217,9 @@ function clearance(page) { clearanceM: Number.isFinite(surface) ? Math.round(carto.height - surface) : null, - pitchDeg: Math.round((camera.pitch * 180) / Math.PI), + pitchDeg: Math.round( + (window.__godsEyeView.engine.camera.pose().pitch * 180) / Math.PI, + ), }; }); } @@ -255,47 +240,32 @@ async function arrivals() { }); await page.type('#location-search', query, { delay: 40 }); await page.keyboard.press('Enter'); - // Wait for the flight to land: the camera still for 1.5 s. - const landed = Date.now(); - let last = null; - let stillSince = null; - while (Date.now() - landed < 30_000) { - const now = await page.evaluate(() => { - const c = window.__godsEyeView.viewer.camera.positionWC; - return [c.x, c.y, c.z]; - }); - const moved = - !last || - Math.hypot(now[0] - last[0], now[1] - last[1], now[2] - last[2]) > 0.5; - last = now; - if (moved) stillSince = null; - else stillSince ??= Date.now(); - if ( - Date.now() - landed > 4000 && - stillSince && - Date.now() - stillSince > 1500 - ) - break; - await sleep(250); - } + await whenSettled(page); await waitTiles(page); - await sleep(2500); // the ground guard measures after tiles stream in + await whenSettled(page); // the ground guard measures after tiles stream in const arrived = await clearance(page); const slug = query.replace(/\W+/g, '-').replace(/^-|-$/g, '').toLowerCase(); await page.screenshot({ path: path.join(dir, `arrival-${slug}.png`) }); // Look around: a gentle orbit, then check the eye still clears the ground. const view = await page.evaluate(() => { - const c = window.__godsEyeView.viewer.camera; return { - lat: (c.positionCartographic.latitude * 180) / Math.PI, - lon: (c.positionCartographic.longitude * 180) / Math.PI, - height: c.positionCartographic.height, - heading: (c.heading * 180) / Math.PI, - pitch: (c.pitch * 180) / Math.PI, + lat: + (window.__godsEyeView.engine.camera.pose().cartographic.latitude * + 180) / + Math.PI, + lon: + (window.__godsEyeView.engine.camera.pose().cartographic.longitude * + 180) / + Math.PI, + height: window.__godsEyeView.engine.camera.pose().cartographic.height, + heading: + (window.__godsEyeView.engine.camera.pose().heading * 180) / Math.PI, + pitch: + (window.__godsEyeView.engine.camera.pose().pitch * 180) / Math.PI, }; }); await moveCamera(page, { ...view, heading: view.heading + 25 }, 2.5); - await sleep(1500); + await whenFramesDrawn(page); const lookedAround = await clearance(page); result.arrivals.push({ query, arrived, lookedAround }); check( @@ -323,7 +293,7 @@ async function contacts() { { lat: 30.35, lon: -97.76, height: 900_000, pitch: -90 }, 0, ); - await sleep(3000); + await whenFramesDrawn(page); // In-page probe: row text and installation entity churn every 250 ms. await page.evaluate(() => { const samples = []; @@ -368,7 +338,7 @@ async function contacts() { entities: sites.length, created, tracked: - window.__godsEyeView.viewer.trackedEntity?.gevTrackedId ?? null, + window.__godsEyeView.engine.camera.following()?.label?.id ?? null, cockpit: document.body.classList.contains('cockpit-mode'), }); if (samples.length > 5000) samples.splice(0, 1000); @@ -383,7 +353,7 @@ async function contacts() { await page .waitForFunction( () => - window.__godsEyeView.viewer.trackedEntity?.gevTrackedId === + window.__godsEyeView.engine.camera.following()?.label?.id === 'flights:aaa051', { timeout: 60_000 }, ) @@ -478,7 +448,7 @@ async function contacts() { await page.evaluate(() => window.__godsEyeView.styleManager.cockpitView.exit?.(), ); - await sleep(2000); + await whenFramesDrawn(page); // SEARCH NEARBY SITES, as the user presses it. await page.evaluate(() => { @@ -504,7 +474,7 @@ async function contacts() { const toast = await page.evaluate(() => document.getElementById('toast').textContent.trim(), ); - await sleep(1500); + await whenFramesDrawn(page); const afterSearch = ( await readSamples((await page.evaluate(() => performance.now())) - 1000) ).at(-1); @@ -519,13 +489,13 @@ async function contacts() { await page.evaluate(() => document.getElementById('global-context-flights-btn').click(), ); - await sleep(2000); + await whenFramesDrawn(page); await page.evaluate(() => window.__godsEyeView.dataManager.layers .get('flights') .module.trackById('aaa052'), ); - await sleep(3000); + await whenFramesDrawn(page); const t1 = await page.evaluate(() => performance.now()); await page.evaluate(() => document.getElementById('global-context-flights-btn').click(), @@ -554,7 +524,7 @@ async function contacts() { } await sleep(500); } - await sleep(2000); + await whenFramesDrawn(page); await page.screenshot({ path: path.join(dir, 'contacts-fort-cavazos.png') }); result.contacts.cavazos = cavazos; check( @@ -600,7 +570,7 @@ try { path.join(outDir, 'result.json'), JSON.stringify(result, null, 1), ); - await browser.close(); + await closeBrowser(browser); } console.log( `${result.failures.length ? 'FAIL' : 'PASS'} — ${path.join(outDir, 'result.json')}`, diff --git a/scripts/qa-directions.mjs b/scripts/qa-directions.mjs index 1bc9886c3..17e3db98d 100644 --- a/scripts/qa-directions.mjs +++ b/scripts/qa-directions.mjs @@ -1,4 +1,7 @@ #!/usr/bin/env node +// Cesium-only probes: native primitive inspection independently verifies the Cesium route geometry. +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled, whenUiSettled, whenTilesLoaded } from './qa/ready.mjs'; /** * Browser proof of the Directions layer: real chip clicks, real globe clicks, * a real route from the real proxy, a real camera flight. @@ -6,10 +9,10 @@ * Usage: node scripts/qa-directions.mjs [--url http://localhost:4173] * [--shots qa-shots/directions] [--label keyed] [--skip denver] */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import puppeteer from 'puppeteer'; import { chromeExecutablePath } from './qa-chrome.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -34,9 +37,8 @@ const check = (name, passed, detail = '') => { if (!passed) failures += 1; }; -const browser = await puppeteer.launch({ - headless: process.env.QA_HEADFUL === '1' ? false : true, - executablePath: chromeExecutablePath(), +const browser = await launchBrowser({ + headless: true, args: [ '--no-sandbox', '--disable-background-timer-throttling', @@ -62,12 +64,12 @@ const shot = async (name) => { /** Put the camera over a place, instantly, and let the scene settle. */ async function lookAt(lat, lon, height) { - await page.evaluate( - (la, lo, h) => { - const viewer = window.__godsEyeView.viewer; - const Cartesian3 = viewer.camera.positionWC.constructor; - viewer.camera.cancelFlight(); - viewer.camera.setView({ + await evaluateWithGeo( + page, + async (qaGeo, la, lo, h) => { + const Cartesian3 = qaGeo.cartesian3; + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ destination: Cartesian3.fromDegrees(lo, la, h), orientation: { heading: 0, pitch: -Math.PI / 2.2, roll: 0 }, }); @@ -80,17 +82,16 @@ async function lookAt(lat, lon, height) { /** Canvas position of a lon/lat on the rendered surface, or null. */ const screenAt = (lat, lon) => - page.evaluate( - (la, lo) => { - const viewer = window.__godsEyeView.viewer; - const scene = viewer.scene; - const Cartesian3 = viewer.camera.positionWC.constructor; - const Cartographic = viewer.camera.positionCartographic.constructor; + evaluateWithGeo( + page, + async (qaGeo, la, lo) => { + const Cartesian3 = qaGeo.cartesian3; + const Cartographic = qaGeo.cartographic; const carto = Cartographic.fromDegrees(lo, la); - let ground = scene.globe?.getHeight?.(carto); + let ground = window.__godsEyeView.engine.surface.terrainHeight?.(carto); if (!Number.isFinite(ground)) ground = 0; const world = Cartesian3.fromDegrees(lo, la, ground); - const win = scene.cartesianToCanvasCoordinates(world); + const win = window.__godsEyeView.engine.projector.worldToWindow(world); if (!win || !Number.isFinite(win.x)) return null; const x = Math.round(win.x); const y = Math.round(win.y); @@ -150,7 +151,7 @@ const clickChip = async (chipId) => { button.click(); return true; }, chipId); - await sleep(150); + await whenUiSettled(page); return clicked; }; @@ -180,7 +181,7 @@ const waitForMode = (word) => const cameraPose = () => page.evaluate(() => { - const c = window.__godsEyeView.viewer.camera.positionWC; + const c = window.__godsEyeView.engine.camera.pose().position; return { x: c.x, y: c.y, z: c.z }; }); const poseDelta = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z); @@ -191,7 +192,7 @@ async function placeEndpoints(a, b) { const atA = await screenAt(a.lat, a.lon); if (!atA) return false; await page.mouse.click(atA.x, atA.y); - await sleep(700); + await whenUiSettled(page); await clickChip('set-b'); const atB = await screenAt(b.lat, b.lon); if (!atB) return false; @@ -207,6 +208,7 @@ try { await page.waitForFunction(() => window.__godsEyeView?.dataManager, { timeout: 90000, }); + await whenSettled(page); // Two module reads the assertions need. Both are the app's OWN module // instances (Vite serves one copy), so this observes rather than simulates. await page.evaluate(async () => { @@ -311,7 +313,7 @@ try { }, }; }); - await sleep(3000); + await whenSettled(page); // ── 1. Open the UTILITIES panel, then toggle the row on. ──────────────── const opened = await page.evaluate(() => { @@ -328,7 +330,7 @@ try { .getElementById('utilities-panel') .classList.contains('collapsed'); }); - await sleep(700); + await whenUiSettled(page); check('the UTILITIES panel opens from its own header control', opened); const toggled = await page.evaluate(() => { const row = document.querySelector( @@ -340,7 +342,7 @@ try { button.click(); return true; }); - await sleep(1200); + await whenUiSettled(page); check( 'the Directions row is in the Data Layers panel and toggles on', toggled, @@ -388,13 +390,13 @@ try { ); close?.click(); }); - await sleep(500); + await whenUiSettled(page); // ── 2. Place A on the Ferry Building, B in Golden Gate Park. ───────────── const FERRY = { lat: 37.7955, lon: -122.3937 }; const GGPARK = { lat: 37.7694, lon: -122.4862 }; await lookAt(37.782, -122.44, 16000); - await sleep(5000); + await whenSettled(page); await clickChip('set-a'); state = await layerState(); @@ -411,7 +413,7 @@ try { let at = await screenAt(FERRY.lat, FERRY.lon); check('the Ferry Building is on screen', !!at, JSON.stringify(at)); await page.mouse.click(at.x, at.y); - await sleep(800); + await whenUiSettled(page); state = await layerState(); check( 'placing A releases the pointer claim', @@ -425,7 +427,7 @@ try { // used to place B and deselect whatever was under it in the same gesture. await page.evaluate(() => { window.__gevQaLateHandler = []; - const canvas = window.__godsEyeView.viewer.scene.canvas; + const canvas = window.__godsEyeView.engine.host.canvas; canvas.addEventListener('pointerup', () => { window.__gevQaLateHandler.push(window.__gevQa.pointerOwner()); }); @@ -443,7 +445,7 @@ try { `saw ${JSON.stringify(lateOwners)}`, ); await waitForRoute(); - await sleep(2000); + await whenSettled(page); state = await layerState(); check( 'once that click is over, the pointer claim is returned', @@ -488,7 +490,7 @@ try { items[2]?.click(); return { active, tabIndex: items[2]?.tabIndex }; }); - await sleep(600); + await whenUiSettled(page); const afterListClick = await layerState(); check( 'a list row takes keyboard focus and selects its maneuver', @@ -525,7 +527,7 @@ try { // ── 4. Swap endpoints. ────────────────────────────────────────────────── const beforeSwap = await layerState(); await clickChip('swap'); - await sleep(3500); + await waitForRoute(); const afterSwap = await layerState(); check( 'SWAP reverses the route', @@ -534,7 +536,7 @@ try { `"${beforeSwap.listText[0]}" -> "${afterSwap.listText[0]}"`, ); await clickChip('swap'); - await sleep(3500); + await waitForRoute(); // ── 5. Three rapid reroutes leave exactly one route. ───────────────────── await page.evaluate(() => { @@ -543,7 +545,7 @@ try { manager.setLayerParams('directions', { mode: 'bike' }, { origin: 'user' }); manager.setLayerParams('directions', { mode: 'car' }, { origin: 'user' }); }); - await sleep(6000); + await waitForRoute(); const rapid = await layerState(); check( 'three rapid reroutes leave exactly one route', @@ -656,7 +658,7 @@ try { ), ); await waitForRoute(); - await sleep(1200); + await whenUiSettled(page); } await cdp.send('Fetch.disable').catch(() => {}); @@ -686,7 +688,7 @@ try { // A reroute replaces the route the dolly is flying; the dolly has to land // first rather than keep flying a route that no longer exists. await setMode('foot'); - await sleep(1200); + await whenUiSettled(page); const afterReroute = await layerState(); check( 'changing profile mid-flight lands the flight it replaced', @@ -696,7 +698,7 @@ try { await waitForMode('Walk'); await setMode('car'); await waitForMode('Drive'); - await sleep(800); + await whenUiSettled(page); check('FLY is offered again after the reroute', await clickChip('fly')); await sleep(2000); @@ -768,7 +770,7 @@ try { // ── 9. Denver: maneuver dots sit on the ground, not at sea level. ──────── if (!SKIP.has('denver')) { await lookAt(39.744, -104.999, 7000); - await sleep(6000); + await whenSettled(page); const placed = await placeEndpoints( { lat: 39.7392, lon: -104.9903 }, { lat: 39.7487, lon: -105.0077 }, @@ -777,10 +779,9 @@ try { if (placed) { await waitForRoute(); // Let the shared ground-floor cells warm and the dots re-anchor. - await sleep(9000); - const denver = await page.evaluate(() => { - const viewer = window.__godsEyeView.viewer; - const Cartographic = viewer.camera.positionCartographic.constructor; + await whenTilesLoaded(page); + const denver = await evaluateWithGeo(page, async (qaGeo) => { + const Cartographic = qaGeo.cartographic; const { stepCount, stepsShown } = window.__gevQaDirections.drawn(); const heights = stepsShown.map( (position) => Cartographic.fromCartesian(position).height, @@ -817,7 +818,7 @@ try { origin: 'user', }), ); - await sleep(1500); + await whenSettled(page); const off = await page.evaluate(() => { const drawn = window.__gevQaDirections.drawn(); const row = document.querySelector( @@ -877,7 +878,7 @@ try { check('harness completed', false, error?.stack || String(error)); await shot(`99-${LABEL}-failure.jpg`).catch(() => {}); } finally { - await browser.close(); + await closeBrowser(browser); await fs.writeFile( path.join(SHOTS, `qa-directions-${LABEL}.log`), `${lines.join('\n')}\n${failures} failure(s)\n`, diff --git a/scripts/qa-director-camera.mjs b/scripts/qa-director-camera.mjs index 62f0af5ff..40ef764e8 100644 --- a/scripts/qa-director-camera.mjs +++ b/scripts/qa-director-camera.mjs @@ -1,11 +1,12 @@ #!/usr/bin/env node /** Verify imported camera directions through real rendering and camera authority handoffs. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; const output = path.resolve('qa-shots/director-camera'); fs.mkdirSync(output, { recursive: true }); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'], }); @@ -23,12 +24,10 @@ try { `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, { waitUntil: 'domcontentloaded' }, ); - await page.waitForFunction( - () => - window.__godsEyeView?.sceneDirector && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 60000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.sceneDirector, { + timeout: 60000, + }); + await whenSettled(page); const result = await page.evaluate(async () => { const { sceneDirector: d, styleManager: style } = window.__godsEyeView; const file = { @@ -137,7 +136,7 @@ try { }; }); for (const [name, passed] of Object.entries(result)) check(name, passed); - await new Promise((r) => setTimeout(r, 6000)); + await whenSettled(page); await page.screenshot({ path: path.join(output, 'austin.png') }); await page.evaluate(() => { const d = window.__godsEyeView.sceneDirector; @@ -145,7 +144,7 @@ try { d._setCameraView({ ...pose, heading: pose.heading + 25, pitch: -60 }); d.viewer.scene.requestRender(); }); - await new Promise((r) => setTimeout(r, 3000)); + await whenSettled(page); await page.screenshot({ path: path.join(output, 'angle.png') }); const inputStarted = await page.evaluate(async () => { const d = window.__godsEyeView.sceneDirector; @@ -205,6 +204,6 @@ try { ); check('no uncaught browser errors', errors.length === 0); } finally { - await browser.close(); + await closeBrowser(browser); } if (failed) process.exitCode = 1; diff --git a/scripts/qa-director-interactions.mjs b/scripts/qa-director-interactions.mjs index a6d3cb83a..0e41d6a4e 100644 --- a/scripts/qa-director-interactions.mjs +++ b/scripts/qa-director-interactions.mjs @@ -1,10 +1,11 @@ #!/usr/bin/env node /** Real pointer/keyboard acceptance using a synthetic, self-authored feature pack. */ +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; -import puppeteer from 'puppeteer'; const output = 'qa-shots/director-interactions'; fs.mkdirSync(output, { recursive: true }); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'], }); @@ -52,12 +53,10 @@ try { `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0#v=2&lat=30.2672&lon=-97.742&alt=4000&heading=0&pitch=-90&l=`, { waitUntil: 'domcontentloaded' }, ); - await page.waitForFunction( - () => - window.__godsEyeView?.sceneDirector && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 60000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.sceneDirector, { + timeout: 60000, + }); + await whenSettled(page); await page.evaluate(async () => { const d = window.__godsEyeView.sceneDirector; const camera = { @@ -163,11 +162,8 @@ try { window.__godsEyeView.sceneDirector.getInteractionState().count === 4, ), ); - await page.waitForFunction( - () => window.__godsEyeView.sceneDirector.engine.surface.tilesLoaded(), - { timeout: 60000 }, - ); - await new Promise((r) => setTimeout(r, 2000)); + await whenTilesLoaded(page, { timeoutMs: 60000 }); + await whenSettled(page); const point = await page.evaluate(async () => { const d = window.__godsEyeView.sceneDirector; const { cartesian3 } = await import('/src/geo/index.js'); @@ -366,6 +362,6 @@ try { ); check('no uncaught browser errors', errors.length === 0); } finally { - await browser.close(); + await closeBrowser(browser); } process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-director-packs.mjs b/scripts/qa-director-packs.mjs index 9ef68d420..45fe50a43 100644 --- a/scripts/qa-director-packs.mjs +++ b/scripts/qa-director-packs.mjs @@ -1,8 +1,10 @@ #!/usr/bin/env node +// Cesium-only probes: native primitive readiness and entity counts detect Cesium resource leaks. /** Synthetic, self-authored assets exercise import, rendering and pack resource lifetime. */ +import { whenSettled, whenTilesLoaded, until } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; import sharp from 'sharp'; const output = path.resolve('qa-shots/director-packs'); fs.mkdirSync(output, { recursive: true }); @@ -65,7 +67,7 @@ const geojson = { }, ], }; -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'], }); @@ -105,12 +107,10 @@ try { `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, { waitUntil: 'domcontentloaded' }, ); - await page.waitForFunction( - () => - window.__godsEyeView?.sceneDirector && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 60000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.sceneDirector, { + timeout: 60000, + }); + await whenSettled(page); await page.evaluate(async () => { const d = window.__godsEyeView.sceneDirector; const manifest = (id, format, file, placement) => ({ @@ -230,14 +230,11 @@ try { () => window.__godsEyeView.mapStackController.getActiveId() === 'photoreal', { timeout: 15000 }, ); - await new Promise((r) => setTimeout(r, 5000)); - await page.waitForFunction( - () => window.__godsEyeView.tileset?.tilesLoaded === true, - { timeout: 60000 }, - ); + await whenSettled(page); + await whenTilesLoaded(page, { timeoutMs: 60000 }); const evidence = await page.evaluate(() => ({ mapStack: window.__godsEyeView.mapStackController.getActiveId(), - tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true, + tilesSettled: window.__godsEyeView.engine.surface.tilesLoaded() === true, })); fs.writeFileSync( path.join(output, 'report.json'), @@ -262,11 +259,8 @@ try { pitch: -85, }); }); - await page.waitForFunction( - () => window.__godsEyeView.tileset?.tilesLoaded === true, - { timeout: 60000 }, - ); - await new Promise((r) => setTimeout(r, 2000)); + await whenTilesLoaded(page, { timeoutMs: 60000 }); + await whenSettled(page); await page.screenshot({ path: path.join(output, 'angle.png') }); const life = await page.evaluate(async () => { const d = window.__godsEyeView.sceneDirector; @@ -306,9 +300,10 @@ try { 'slow', ); }); - const deadline = Date.now() + 15000; - while (!pending && Date.now() < deadline) - await new Promise((r) => setTimeout(r, 50)); + await until(() => pending, { + timeoutMs: 15000, + label: 'pending pack transport', + }); check('pending transport reached', !!pending); const cancelled = await page.evaluate(async () => { const d = window.__godsEyeView.sceneDirector; @@ -354,6 +349,6 @@ try { if (errors.length) console.error(errors.map((e) => e.replace(/https?:\/\/\S+/g, '[URL]'))); } finally { - await browser.close(); + await closeBrowser(browser); } if (failures) process.exitCode = 1; diff --git a/scripts/qa-director-sharing.mjs b/scripts/qa-director-sharing.mjs index 4a4a93d48..c9979b1ac 100644 --- a/scripts/qa-director-sharing.mjs +++ b/scripts/qa-director-sharing.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node /** Sharing/authoring acceptance with local synthetic assets and real installed controls. */ +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; const output = path.resolve('qa-shots/director-sharing'); fs.mkdirSync(output, { recursive: true }); const geometry = { @@ -66,7 +67,7 @@ const project = { }; const file = path.join(output, 'project.json'); fs.writeFileSync(file, JSON.stringify(project)); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'], }); @@ -110,12 +111,10 @@ try { `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, { waitUntil: 'domcontentloaded' }, ); - await page.waitForFunction( - () => - window.__godsEyeView?.sceneDirector && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 60000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.sceneDirector, { + timeout: 60000, + }); + await whenSettled(page); await page.click('[data-collapse-target="scene-panel"]'); const before = await page.evaluate(() => JSON.stringify(window.__godsEyeView.sceneDirector._project), @@ -304,16 +303,14 @@ try { window.__godsEyeView.sceneDirector.getInteractionState().count === 1, )), ); - await page.waitForFunction( - () => window.__godsEyeView.tileset?.tilesLoaded === true, - { timeout: 60000 }, - ); + await whenTilesLoaded(page, { timeoutMs: 60000 }); await page.screenshot({ path: path.join(output, 'rendered.png') }); fs.writeFileSync( path.join(output, 'report.json'), JSON.stringify( await page.evaluate(() => ({ - tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true, + tilesSettled: + window.__godsEyeView.engine.surface.tilesLoaded() === true, })), ), ); @@ -480,6 +477,6 @@ try { ); check('no uncaught browser errors', errors.length === 0); } finally { - await browser.close(); + await closeBrowser(browser); } process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-director-timing.mjs b/scripts/qa-director-timing.mjs index 27f5705ff..aae5129d4 100644 --- a/scripts/qa-director-timing.mjs +++ b/scripts/qa-director-timing.mjs @@ -1,11 +1,12 @@ #!/usr/bin/env node /** Exercise scene-clock ownership and authored seek behavior in the composed browser app. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; const shots = path.resolve('qa-shots/director-timing'); fs.mkdirSync(shots, { recursive: true }); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'], }); @@ -23,12 +24,10 @@ try { `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, { waitUntil: 'domcontentloaded' }, ); - await page.waitForFunction( - () => - window.__godsEyeView?.sceneDirector && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 60000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.sceneDirector, { + timeout: 60000, + }); + await whenSettled(page); const result = await page.evaluate(async () => { const director = window.__godsEyeView.sceneDirector; const camera = director.styleManager.getCameraState(); @@ -172,7 +171,7 @@ try { 'existing Nepal scene and authored shots load', content.exists && content.shots >= 25 && content.loaded, ); - await new Promise((resolve) => setTimeout(resolve, 2500)); + await whenSettled(page); await page.screenshot({ path: path.join(shots, 'nepal-regional.png') }); await page.evaluate(() => { const g = window.__godsEyeView; @@ -184,7 +183,7 @@ try { pitch: -55, }); }); - await new Promise((resolve) => setTimeout(resolve, 1000)); + await whenSettled(page); await page.screenshot({ path: path.join(shots, 'nepal-angle.png') }); check( 'no pending clocks after Stop', @@ -196,6 +195,6 @@ try { ); check('no browser exceptions', errors.length === 0); } finally { - await browser.close(); + await closeBrowser(browser); } if (failures) process.exitCode = 1; diff --git a/scripts/qa-draw-tool.mjs b/scripts/qa-draw-tool.mjs index 135ca035a..fef6ebc46 100644 --- a/scripts/qa-draw-tool.mjs +++ b/scripts/qa-draw-tool.mjs @@ -1,8 +1,11 @@ #!/usr/bin/env node +// Cesium-only probes: the stock input action and DataSource counts verify Cesium input restoration. +import { evaluateWithGeo } from './qa/geometry.mjs'; /** Rendered acceptance for UTILITIES ▸ Draw: real clicks, real keys, real teardown. */ +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; import { chromeExecutablePath } from './qa-chrome.mjs'; const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; @@ -19,21 +22,14 @@ const VIEW = { pitch: -85, }; -const browser = await puppeteer.launch({ - headless: !HEADFUL, - ...(HEADFUL - ? { - executablePath: chromeExecutablePath(), - } - : {}), +const browser = await launchBrowser({ + headless: true, args: [ '--no-sandbox', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', ...(HEADFUL ? ['--window-size=1280,860'] : []), - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), ], }); const page = await browser.newPage(); @@ -68,7 +64,6 @@ const shot = (name) => type: 'jpeg', quality: 82, }); -const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); /** A clip of the globe canvas, clear of the panels on every side. */ const CANVAS_CLIP = { x: 340, y: 200, width: 560, height: 380 }; @@ -163,7 +158,7 @@ async function pressFinish() { /** One click on the globe canvas, the way a person makes one. */ async function clickWorld(x, y, options = {}) { await page.mouse.click(x, y, options); - await wait(options.settle ?? 140); + await whenSettled(page); } const drawState = () => @@ -211,50 +206,34 @@ try { { waitUntil: 'domcontentloaded' }, ); await page.waitForFunction( - () => - window.__gevDrawTool && - window.__gevAnnotations && - document.getElementById('loading-screen')?.classList.contains('hidden'), + () => window.__gevDrawTool && window.__gevAnnotations, { timeout: 90_000 }, ); - await page.evaluate((view) => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.cancelFlight?.(); - viewer.scene.tweens?.removeAll?.(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ - longitude: (view.lon * Math.PI) / 180, - latitude: (view.lat * Math.PI) / 180, - height: view.height, - }), - orientation: { - heading: (view.heading * Math.PI) / 180, - pitch: (view.pitch * Math.PI) / 180, - roll: 0, - }, - }); - viewer.scene.requestRender(); - }, VIEW); - const settled = await page - .waitForFunction( - () => { - const primitives = window.__godsEyeView.viewer.scene.primitives; - for (let index = 0; index < primitives.length; index++) { - const primitive = primitives.get(index); - if ( - typeof primitive.tilesLoaded === 'boolean' && - !primitive.tilesLoaded - ) - return false; - } - return true; - }, - { timeout: 90_000 }, - ) - .then( - () => true, - () => false, - ); + await whenSettled(page); + await evaluateWithGeo( + page, + async (qaGeo, view) => { + window.__godsEyeView.engine.camera.cancelFlight?.(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ + longitude: (view.lon * Math.PI) / 180, + latitude: (view.lat * Math.PI) / 180, + height: view.height, + }), + orientation: { + heading: (view.heading * Math.PI) / 180, + pitch: (view.pitch * Math.PI) / 180, + roll: 0, + }, + }); + window.__godsEyeView.engine.render.request(); + }, + VIEW, + ); + const settled = await whenTilesLoaded(page, { timeoutMs: 90_000 }).then( + () => true, + () => false, + ); check('visible tile content settles before drawing', settled); // UTILITIES starts collapsed on a first run: open it the way a user does. @@ -321,9 +300,9 @@ try { const before = await drawState(); for (let cycle = 0; cycle < 3; cycle += 1) { await page.click('#draw-toggle'); - await wait(120); + await whenSettled(page); await page.click('#draw-toggle'); - await wait(120); + await whenSettled(page); } const after = await drawState(); check( @@ -379,7 +358,7 @@ try { await clickWorld(600, 470); const beforeBackspace = (await drawState()).vertices; await page.keyboard.press('Backspace'); - await wait(120); + await whenSettled(page); const afterBackspace = (await drawState()).vertices; check( 'Backspace removes the last vertex only', @@ -415,7 +394,7 @@ try { ); await page.mouse.click(410, 500, { count: 2 }); const areaLanded = await waitForMarks(3); - await wait(900); + await whenSettled(page); const afterDoubleClick = await page.evaluate(() => window.__gevAnnotations.count(), ); @@ -440,7 +419,7 @@ try { await page.click(rowControl(areaId, 'name')); await page.keyboard.type('Civic Center block'); await page.keyboard.press('Enter'); - await wait(300); + await whenSettled(page); const areaMark = (await marks()).find( (mark) => mark.label === 'Civic Center block', ); @@ -453,7 +432,7 @@ try { 'naming a shape keeps Draw on and the shape in progress untouched', (await drawState()).active === true, ); - await wait(1200); + await whenSettled(page); const amberAfter = await amberPixels(); check( 'the finished area actually PAINTS — amber pixels appear on this surface', @@ -512,32 +491,31 @@ try { // Fly-to frames the line by its whole path: once the flight settles, the // middle of the line sits near the middle of the view. await page.click(rowControl(ids[1], 'fly')); - await page - .waitForFunction(() => !window.__godsEyeView.viewer.camera._currentFlight, { - timeout: 15_000, - }) - .catch(() => {}); - await wait(300); - const framed = await page.evaluate((id) => { - const { viewer } = window.__godsEyeView; - // No Cesium global in the page: borrow Cartesian3 from a live object. - const Cartesian3 = viewer.camera.positionWC.constructor; - const mark = window.__gevAnnotations.list().find((m) => m.id === id); - const lons = mark.path.map((p) => p.lon); - const lats = mark.path.map((p) => p.lat); - const middle = Cartesian3.fromDegrees( - (Math.min(...lons) + Math.max(...lons)) / 2, - (Math.min(...lats) + Math.max(...lats)) / 2, - ); - const at = viewer.scene.cartesianToCanvasCoordinates(middle); - const canvas = viewer.scene.canvas; - return ( - at && { - dx: Math.abs(at.x - canvas.clientWidth / 2) / canvas.clientWidth, - dy: Math.abs(at.y - canvas.clientHeight / 2) / canvas.clientHeight, - } - ); - }, ids[1]); + await whenSettled(page).catch(() => {}); + await whenSettled(page); + const framed = await evaluateWithGeo( + page, + async (qaGeo, id) => { + // No Cesium global in the page: borrow Cartesian3 from a live object. + const Cartesian3 = qaGeo.cartesian3; + const mark = window.__gevAnnotations.list().find((m) => m.id === id); + const lons = mark.path.map((p) => p.lon); + const lats = mark.path.map((p) => p.lat); + const middle = Cartesian3.fromDegrees( + (Math.min(...lons) + Math.max(...lons)) / 2, + (Math.min(...lats) + Math.max(...lats)) / 2, + ); + const at = window.__godsEyeView.engine.projector.worldToWindow(middle); + const canvas = window.__godsEyeView.engine.host.canvas; + return ( + at && { + dx: Math.abs(at.x - canvas.clientWidth / 2) / canvas.clientWidth, + dy: Math.abs(at.y - canvas.clientHeight / 2) / canvas.clientHeight, + } + ); + }, + ids[1], + ); check( 'fly to frames that shape: its middle is near the middle of the view', Boolean(framed) && framed.dx < 0.2 && framed.dy < 0.2, @@ -550,11 +528,11 @@ try { await page.click(rowControl(lineId, 'swatch')); await page.click(rowControl(lineId, 'color-amber')); await markReaches(lineId, { color: 'amber', alpha: 1 }); - await wait(800); + await whenSettled(page); const shown = await canvasShot(); await page.click(rowControl(lineId, 'toggle')); await markReaches(lineId, { hidden: true, alpha: 0 }); - await wait(800); + await whenSettled(page); const hiddenShot = await canvasShot(); // Amber the shown line had and the hidden one lost; amber that appears // only once it is hidden is noise, and must stay small beside it. @@ -566,7 +544,7 @@ try { `${gone} amber pixels went with the line, ${noise} appeared`, ); await page.click(rowControl(lineId, 'toggle')); - await wait(1200); + await whenSettled(page); const back = await amberOnlyIn(await canvasShot(), hiddenShot); check( 'and paints again when shown', @@ -581,7 +559,7 @@ try { await clickWorld(560, 340); check('a new shape is in progress', (await drawState()).vertices === 2); await page.keyboard.press('Escape'); - await wait(150); + await whenSettled(page); const afterFirstEscape = await drawState(); check( 'Escape cancels the shape in progress and stays in draw mode', @@ -589,7 +567,7 @@ try { `vertices ${afterFirstEscape.vertices}, active ${afterFirstEscape.active}`, ); await page.keyboard.press('Escape'); - await wait(150); + await whenSettled(page); const afterSecondEscape = await drawState(); check( 'a second Escape leaves draw mode and gives the pointer back', @@ -667,7 +645,7 @@ try { ); await page.keyboard.press('Escape'); await page.keyboard.press('Escape'); - await wait(150); + await whenSettled(page); } // ── what a reload actually does ───────────────────────────────────────── @@ -675,12 +653,10 @@ try { window.__gevAnnotations.count(), ); await page.reload({ waitUntil: 'domcontentloaded' }); - await page.waitForFunction( - () => - window.__gevAnnotations && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 90_000 }, - ); + await page.waitForFunction(() => window.__gevAnnotations, { + timeout: 90_000, + }); + await whenSettled(page); const afterReload = await page.evaluate(() => window.__gevAnnotations.count(), ); @@ -722,25 +698,28 @@ try { // flight left a low, near-horizontal view there) with a boot flight that can // land after a setView: set the view until it holds. for (let attempt = 0; attempt < 6; attempt += 1) { - await page.evaluate((view) => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.cancelFlight?.(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ - longitude: (view.lon * Math.PI) / 180, - latitude: (view.lat * Math.PI) / 180, - height: view.height, - }), - orientation: { - heading: 0, - pitch: (view.pitch * Math.PI) / 180, - roll: 0, - }, - }); - }, VIEW); - await wait(2500); + await evaluateWithGeo( + page, + async (qaGeo, view) => { + window.__godsEyeView.engine.camera.cancelFlight?.(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ + longitude: (view.lon * Math.PI) / 180, + latitude: (view.lat * Math.PI) / 180, + height: view.height, + }), + orientation: { + heading: 0, + pitch: (view.pitch * Math.PI) / 180, + roll: 0, + }, + }); + }, + VIEW, + ); + await whenSettled(page); const held = await page.evaluate((view) => { - const carto = window.__godsEyeView.viewer.camera.positionCartographic; + const carto = window.__godsEyeView.engine.camera.pose().cartographic; return ( Math.abs((carto.latitude * 180) / Math.PI - view.lat) < 0.01 && Math.abs((carto.longitude * 180) / Math.PI - view.lon) < 0.01 && @@ -767,7 +746,7 @@ try { const beforeButtonEnter = await drawState(); await page.focus('#draw-clear'); await page.keyboard.press('Enter'); - await wait(600); + await whenSettled(page); const afterButtonEnter = await drawState(); check( 'Enter on a keyboard-focused button activates the button, it does not finish the shape', @@ -790,7 +769,7 @@ try { await waitForMarks(1), ); await page.click('#draw-clear'); - await wait(400); + await whenSettled(page); const cleared = await drawState(); check( 'Clear removes every mark from the board', @@ -815,7 +794,7 @@ try { `${degenerateVertices} vertices`, ); await pressFinish(); - await wait(600); + await whenSettled(page); const degenerate = await drawState(); check( 'three points in a line are refused with a reason, not placed', @@ -824,20 +803,19 @@ try { ); // ── a shape drawn across the antimeridian lands where it was drawn ────── - await page.evaluate(() => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.cancelFlight?.(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo) => { + window.__godsEyeView.engine.camera.cancelFlight?.(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: (179.9995 * Math.PI) / 180, latitude: 0, height: 3000, }), orientation: { heading: 0, pitch: (-88 * Math.PI) / 180, roll: 0 }, }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); - await wait(2500); + await whenSettled(page); await page.evaluate(() => { const tool = window.__gevDrawTool; tool.cancel(); @@ -865,7 +843,7 @@ try { await page.evaluate(() => window.__gevAnnotations.clear()); await page.click('#draw-toggle'); - await wait(200); + await whenSettled(page); const finalState = await drawState(); check( 'leaving draw mode gives back the pointer, the handler and the double-click', @@ -880,7 +858,7 @@ try { () => window.__godsEyeView.viewer.dataSources.length, ); await page.click('#draw-toggle'); // destroy from an ACTIVE session: the harder case - await wait(200); + await whenSettled(page); const destroyed = await page.evaluate(async () => { const tool = window.__gevDrawTool; const wasActive = tool.active; @@ -936,7 +914,7 @@ try { errors.join(' | '), ); } finally { - await browser.close(); + await closeBrowser(browser); } console.log(`RESULT: ${failures} failures`); process.exitCode = failures ? 1 : 0; @@ -975,20 +953,23 @@ async function findClickableTarget() { .catch(() => null); if (camera) { - await page.evaluate((spot) => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.cancelFlight?.(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ - longitude: (spot.lon * Math.PI) / 180, - latitude: (spot.lat * Math.PI) / 180, - height: 600, - }), - orientation: { heading: 0, pitch: (-80 * Math.PI) / 180, roll: 0 }, - }); - viewer.scene.requestRender(); - }, camera); - await wait(4000); + await evaluateWithGeo( + page, + async (qaGeo, spot) => { + window.__godsEyeView.engine.camera.cancelFlight?.(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ + longitude: (spot.lon * Math.PI) / 180, + latitude: (spot.lat * Math.PI) / 180, + height: 600, + }), + orientation: { heading: 0, pitch: (-80 * Math.PI) / 180, roll: 0 }, + }); + window.__godsEyeView.engine.render.request(); + }, + camera, + ); + await whenSettled(page); const spot = await onScreen(['cctv']); if (spot) return { ...spot, kind: 'cctv', layer: 'CCTV camera' }; } @@ -1021,20 +1002,23 @@ async function findClickableTarget() { }) .catch(() => null); if (!site) return null; - await page.evaluate((spot) => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.cancelFlight?.(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ - longitude: (spot.lon * Math.PI) / 180, - latitude: (spot.lat * Math.PI) / 180, - height: 2500, - }), - orientation: { heading: 0, pitch: (-88 * Math.PI) / 180, roll: 0 }, - }); - viewer.scene.requestRender(); - }, site); - await wait(4000); + await evaluateWithGeo( + page, + async (qaGeo, spot) => { + window.__godsEyeView.engine.camera.cancelFlight?.(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ + longitude: (spot.lon * Math.PI) / 180, + latitude: (spot.lat * Math.PI) / 180, + height: 2500, + }), + orientation: { heading: 0, pitch: (-88 * Math.PI) / 180, roll: 0 }, + }); + window.__godsEyeView.engine.render.request(); + }, + site, + ); + await whenSettled(page); const spot = await onScreen([ 'local-datacenters-tips', 'local-datacenters-pins', diff --git a/scripts/qa-journey-recorder.mjs b/scripts/qa-journey-recorder.mjs index cefb2c713..b63432a93 100644 --- a/scripts/qa-journey-recorder.mjs +++ b/scripts/qa-journey-recorder.mjs @@ -16,6 +16,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; +import { evaluateWithGeo } from './qa/geometry.mjs'; const execFileAsync = promisify(execFile); @@ -52,8 +53,8 @@ export function frameStats(frames) { export async function installFrameProbe(page) { await page.evaluate(() => { if (window.__gevFrameProbe) return; - const { viewer, engine } = window.__godsEyeView; - const events = viewer?.scene ?? engine.frames; + const { engine } = window.__godsEyeView; + const events = engine.frames; const probe = { frames: [], started: 0 }; window.__gevFrameProbe = probe; let last = performance.now(); @@ -102,58 +103,25 @@ export function pageNow(page) { * @param {number} seconds Flight duration; 0 jumps. */ export async function moveCamera(page, view, seconds) { - if ( - await page.evaluate(() => window.__godsEyeView.engine?.kind === 'navara') - ) { - await page.evaluate( - async (v, duration) => { - const geo = await import('/src/geo/index.js'); - const camera = window.__godsEyeView.engine.camera; - camera.cancelFlight(); - await new Promise((resolve) => - camera.flyTo({ - destination: geo.cartesian3.fromDegrees(v.lon, v.lat, v.height), - orientation: { - heading: ((v.heading ?? 0) * Math.PI) / 180, - pitch: ((v.pitch ?? -45) * Math.PI) / 180, - roll: 0, - }, - duration, - maximumHeight: Math.max(v.height, camera.height()), - complete: resolve, - cancel: resolve, - }), - ); - }, - view, - seconds, - ); - return; - } - await page.evaluate( - (v, duration) => - new Promise((resolve) => { - const { viewer } = window.__godsEyeView; - const C = viewer.camera; - C.cancelFlight(); - C.flyTo({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ - latitude: (v.lat * Math.PI) / 180, - longitude: (v.lon * Math.PI) / 180, - height: v.height, - }), + await evaluateWithGeo( + page, + async (geo, v, duration) => { + const camera = window.__godsEyeView.engine.camera; + camera.cancelFlight(); + await new Promise((resolve) => + camera.flyTo({ + destination: geo.cartesian3.fromDegrees(v.lon, v.lat, v.height), orientation: { heading: ((v.heading ?? 0) * Math.PI) / 180, pitch: ((v.pitch ?? -45) * Math.PI) / 180, roll: 0, }, duration, - // Straight, eased motion like a drag or wheel zoom, not an arc. - maximumHeight: Math.max(v.height, C.positionCartographic.height), complete: resolve, cancel: resolve, - }); - }), + }), + ); + }, view, seconds, );