From 53b07ba91fb5b1009217c07daaca450f0376f2a9 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 16:43:58 +0400 Subject: [PATCH] Move QA scripts to the shared harness, batch 3 of 4 Each script launches and closes Chrome through scripts/qa/browser.mjs, waits on the application through scripts/qa/ready.mjs instead of the loading screen and fixed pauses, and reads the contract instead of Cesium where the check is not about Cesium itself. --- scripts/qa-layer-visibility.mjs | 9 +- scripts/qa-location-controls.mjs | 55 ++-- scripts/qa-map-source-controls.mjs | 60 ++--- scripts/qa-map-source-tray.mjs | 61 ++--- scripts/qa-military-names.mjs | 47 ++-- scripts/qa-nepal-media-playback.mjs | 21 +- scripts/qa-openfreemap-same-tile.mjs | 57 ++--- scripts/qa-overlay-baseline.mjs | 62 ++--- scripts/qa-overpass-offload.mjs | 165 ++++++------ scripts/qa-radio.mjs | 353 ++++++++++++-------------- scripts/qa-recent-imagery.mjs | 101 ++++---- scripts/qa-scene-controls.mjs | 19 +- scripts/qa-scene-dialog-file-drop.mjs | 6 +- scripts/qa-scope-hud.mjs | 22 +- scripts/qa-segmented-controls.mjs | 17 +- scripts/qa-sky-model-lighting.mjs | 7 +- scripts/qa-status-badges.mjs | 14 +- scripts/qa-street-level.mjs | 63 ++--- scripts/qa-terrain-429.mjs | 92 +++---- scripts/qa-traffic-baseline.mjs | 81 ++---- scripts/qa-traffic-jamviz-ab.mjs | 65 ++--- scripts/qa-traffic-motion.mjs | 51 ++-- scripts/qa-traffic-navigation.mjs | 33 ++- scripts/qa-traffic-oblique.mjs | 42 +-- 24 files changed, 628 insertions(+), 875 deletions(-) diff --git a/scripts/qa-layer-visibility.mjs b/scripts/qa-layer-visibility.mjs index 8a903918e..e098f9e2a 100644 --- a/scripts/qa-layer-visibility.mjs +++ b/scripts/qa-layer-visibility.mjs @@ -1,12 +1,13 @@ #!/usr/bin/env node +import { whenSettled } from './qa/ready.mjs'; /** * Real-browser proof for manager-owned periodic work and document visibility. * Uses the app's development-only layer registration seam; no provider keys. * Run: node scripts/qa-layer-visibility.mjs --url http://localhost:4173 * Hiding uses a real background tab, never a patched document.hidden getter. */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; -import puppeteer from 'puppeteer'; import { chromeExecutablePath } from './qa-chrome.mjs'; const args = process.argv.slice(2); @@ -17,8 +18,7 @@ const url = new URL( 'http://localhost:4173', ); url.searchParams.set('welcome', '0'); -const browser = await puppeteer.launch({ - executablePath: chromeExecutablePath(), +const browser = await launchBrowser({ headless: true, args: [ '--no-sandbox', @@ -39,6 +39,7 @@ try { await page.waitForFunction( () => window.__gevQaRegisterLayer && window.__godsEyeView?.dataManager, ); + await whenSettled(page); await page.evaluate(async () => { const manager = window.__godsEyeView.dataManager; window.__qaVisibilityUpdates = 0; @@ -120,5 +121,5 @@ try { ); console.log('RESULT: 7 passed'); } finally { - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-location-controls.mjs b/scripts/qa-location-controls.mjs index dc80bd279..f862355ea 100644 --- a/scripts/qa-location-controls.mjs +++ b/scripts/qa-location-controls.mjs @@ -1,14 +1,13 @@ #!/usr/bin/env node /** Location UI acceptance; controlled search results isolate UI races from geocoder availability. */ +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; -import puppeteer from 'puppeteer'; -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: [ '--no-sandbox', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), ], }); const page = await browser.newPage(); @@ -26,11 +25,10 @@ try { { waitUntil: 'domcontentloaded' }, ); await page.waitForFunction( - () => - window.__godsEyeView?.styleManager?._locationControls && - document.getElementById('loading-screen')?.classList.contains('hidden'), + () => window.__godsEyeView?.styleManager?._locationControls, { timeout: 60000 }, ); + await whenSettled(page); await page.click('#location-bar-toggle'); await page.click('[data-pin-target="location-bar"]'); const cityId = await page.$eval( @@ -114,10 +112,7 @@ try { }, { timeout: 30000 }, ); - await page.waitForFunction( - () => !window.__godsEyeView.viewer.camera._currentFlight, - { timeout: 10000 }, - ); + await whenSettled(page); check( 'native geo URI search shows the decimal coordinate readout', await page.evaluate(() => { @@ -135,7 +130,7 @@ try { check( 'geo URI navigation settles near the requested coordinate', await page.evaluate(() => { - const position = window.__godsEyeView.viewer.camera.positionCartographic; + const position = window.__godsEyeView.engine.camera.pose().cartographic; const degrees = 180 / Math.PI; return ( Math.abs(position.latitude * degrees - 39.7392) < 0.05 && @@ -252,36 +247,17 @@ try { document.getElementById('poi-row').classList.contains('expanded'), ); // Finish the camera's existing city flight before inspecting both layouts. - await page.waitForFunction( - () => !window.__godsEyeView.viewer.camera._currentFlight, - { timeout: 10000 }, - ); + await whenSettled(page); await page.evaluate( () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)), ), ); - const settled = await page - .waitForFunction( - () => { - const scene = window.__godsEyeView.viewer.scene; - for (let index = 0; index < scene.primitives.length; index++) { - const primitive = scene.primitives.get(index); - if ( - typeof primitive.tilesLoaded === 'boolean' && - !primitive.tilesLoaded - ) - return false; - } - return true; - }, - { timeout: 60000 }, - ) - .then( - () => true, - () => false, - ); + const settled = await whenTilesLoaded(page, { timeoutMs: 60000 }).then( + () => true, + () => false, + ); check('visible city tiles settle before screenshots', settled); fs.mkdirSync('qa-shots/location-controls', { recursive: true }); await page.screenshot({ path: 'qa-shots/location-controls/desktop.png' }); @@ -301,7 +277,8 @@ try { if (!pending) return false; await pending; return ( - !ui.orbitController.active && !window.__godsEyeView.viewer.trackedEntity + !ui.orbitController.active && + !window.__godsEyeView.engine.camera.following() ); }), ); @@ -329,7 +306,7 @@ try { ); check('no uncaught browser errors', errors.length === 0); } finally { - await browser.close(); + await closeBrowser(browser); } console.log(`RESULT: ${failures} failures`); process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-map-source-controls.mjs b/scripts/qa-map-source-controls.mjs index d72961ca0..4c2452838 100644 --- a/scripts/qa-map-source-controls.mjs +++ b/scripts/qa-map-source-controls.mjs @@ -1,15 +1,15 @@ #!/usr/bin/env node +import { evaluateWithGeo } from './qa/geometry.mjs'; /** Browser acceptance for Map Source control lifecycle and truthful selection. */ +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; -import puppeteer from 'puppeteer'; -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: [ '--no-sandbox', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), ], }); const page = await browser.newPage(); @@ -25,17 +25,7 @@ try { const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173'); url.searchParams.set('welcome', '0'); await page.goto(url.href, { waitUntil: 'domcontentloaded' }); - await page.waitForFunction( - () => { - const cover = document.getElementById('loading-screen'); - return ( - window.__godsEyeView?.styleManager?._mapSourceControls && - cover?.classList.contains('hidden') && - Number(getComputedStyle(cover).opacity) === 0 - ); - }, - { timeout: 60_000 }, - ); + await whenSettled(page); await page.evaluate(() => { const manager = window.__godsEyeView.styleManager; const controller = manager.mapStackController; @@ -132,19 +122,17 @@ try { }); document.querySelector('.map-stack-chip.active')?.focus(); }); - await page.evaluate(() => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.cancelFlight?.(); - viewer.scene.tweens?.removeAll?.(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo) => { + window.__godsEyeView.engine.camera.cancelFlight?.(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: (-97.7431 * Math.PI) / 180, latitude: (30.2568 * Math.PI) / 180, height: 1200, }), orientation: { heading: 0, pitch: -0.85, roll: 0 }, }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); await page.evaluate( () => @@ -152,26 +140,10 @@ try { requestAnimationFrame(() => requestAnimationFrame(resolve)), ), ); - const settled = await page - .waitForFunction( - () => { - const primitives = window.__godsEyeView.viewer.scene.primitives; - for (let i = 0; i < primitives.length; i++) { - const primitive = primitives.get(i); - if ( - typeof primitive.tilesLoaded === 'boolean' && - !primitive.tilesLoaded - ) - return false; - } - return true; - }, - { timeout: 60_000 }, - ) - .then( - () => true, - () => false, - ); + const settled = await whenTilesLoaded(page, { timeoutMs: 60_000 }).then( + () => true, + () => false, + ); check('visible tile content settles before visual captures', settled); fs.mkdirSync('qa-shots/map-source-controls', { recursive: true }); await page.screenshot({ path: 'qa-shots/map-source-controls/desktop.png' }); @@ -206,7 +178,7 @@ try { check('no uncaught browser errors', errors.length === 0); if (errors.length) console.log(JSON.stringify(errors)); } finally { - await browser.close(); + await closeBrowser(browser); } console.log(`RESULT: ${failures} failures`); process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-map-source-tray.mjs b/scripts/qa-map-source-tray.mjs index 87a756ed4..cd78c0dd8 100644 --- a/scripts/qa-map-source-tray.mjs +++ b/scripts/qa-map-source-tray.mjs @@ -1,9 +1,10 @@ #!/usr/bin/env node /** Focused browser proof for the responsive, accessible Map Source tray. */ +import { whenSettled, whenUiSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import puppeteer from 'puppeteer'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const shotsDir = process.env.QA_SHOTS_DIR || path.join(repoRoot, 'qa-shots', 'map-source-tray'); @@ -18,26 +19,12 @@ const headful = process.argv.includes('--headful'); // branch. const forceKeyless = process.argv.includes('--keyless') || process.env.QA_MAP_SOURCE_TRAY_KEYLESS === '1'; -const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH - || await puppeteer.executablePath().catch(() => null); -if (!executablePath || !fs.existsSync(executablePath)) { - throw new Error('Puppeteer Chrome for Testing is unavailable'); -} fs.mkdirSync(shotsDir, { recursive: true }); -const browser = await puppeteer.launch({ - headless: headful ? false : 'new', - executablePath, - // Metal is a macOS-only ANGLE backend: passing it on Windows or Linux makes - // WebGL initialization fail outright, so Cesium never constructs and this - // harness dies at the boot wait before a single assertion runs. - args: [ - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), - '--no-sandbox', - ], +const browser = await launchBrowser({ + headless: true, + args: [...(process.platform === 'darwin' ? ['--enable-gpu'] : []), '--no-sandbox'], }); const page = await browser.newPage(); const failures = []; @@ -154,7 +141,7 @@ const pressTabs = async (count, backwards = false) => { if (backwards) await page.keyboard.up('Shift'); } // The row scrolls smoothly when native Tab brings an edge button into view. - await new Promise((resolve) => setTimeout(resolve, 250)); + await whenUiSettled(page); }; // Phones (≤720px) present the dock's Presets inline in the More tab's sheet. // It opens from the keyboard, as a keyboard user would, so the focus it moves @@ -218,7 +205,7 @@ const tabTo = async (selector, { backwards = false, limit = 160 } = {}) => { for (let step = 1; step <= limit; step += 1) { await page.keyboard.press('Tab'); if (await page.evaluate((match) => document.activeElement?.matches(match), selector)) { - await new Promise((resolve) => setTimeout(resolve, 250)); + await whenUiSettled(page); return { reached: true, steps: step }; } } @@ -304,10 +291,7 @@ try { // tray assertion into a mission or voice action in a pristine browser. await page.goto(`${appUrl}/?welcome=0`, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 60_000 }); - await page.waitForFunction( - () => document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 60_000 }, - ); + await whenSettled(page); const presentation = await page.evaluate(() => ({ ids: [...document.querySelectorAll('.map-stack-chip')].map((chip) => chip.dataset.stackId), @@ -1103,9 +1087,9 @@ try { const clickTileThenLeave = async (stackId) => { await page.evaluate(() => window.__godsEyeView.styleManager .setPanelCollapsed('control-panel', false, { explicit: true })); - await new Promise((resolve) => setTimeout(resolve, 240)); + await whenUiSettled(page); await page.click(`[data-stack-id="${stackId}"]`); - await new Promise((resolve) => setTimeout(resolve, 120)); + await whenUiSettled(page); const afterClick = await readTrayState(); await page.mouse.move(20, 20); // leave the dock entirely → pointerleave // Past the 420 ms unpinned close delay with room for a slow frame. @@ -1121,7 +1105,7 @@ try { await setControlPanelPinned(false); await page.evaluate(() => window.__godsEyeView.styleManager .setPanelCollapsed('control-panel', true, { explicit: true })); - await new Promise((resolve) => setTimeout(resolve, 200)); + await whenUiSettled(page); await page.focus('#control-panel-toggle'); await page.keyboard.press('Enter'); // opens and hands focus to the active tile const selectedForHold = await page.evaluate(() => window.__godsEyeView.styleManager.mapStackController.getActiveId()); @@ -1136,7 +1120,7 @@ try { await page.keyboard.press('Tab'); if (activeIsLastTile) await page.keyboard.up('Shift'); await page.keyboard.press('Enter'); // activate it from the keyboard - await new Promise((resolve) => setTimeout(resolve, 200)); + await whenUiSettled(page); const keyboardAfterActivate = await page.evaluate(() => ({ focusedStack: document.activeElement?.dataset?.stackId || null, isChip: !!document.activeElement?.classList?.contains('map-stack-chip'), @@ -1148,7 +1132,7 @@ try { return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; }); await page.mouse.move(chipPoint.x, chipPoint.y); - await new Promise((resolve) => setTimeout(resolve, 120)); + await whenUiSettled(page); await page.mouse.move(20, 20); await new Promise((resolve) => setTimeout(resolve, 1000)); const keyboardHold = await readTrayState(); @@ -1179,7 +1163,7 @@ try { await page.mouse.move(pinApproach.x, pinApproach.y); await page.evaluate(() => window.__godsEyeView.styleManager .setPanelCollapsed('control-panel', false, { explicit: true })); - await new Promise((resolve) => setTimeout(resolve, 240)); + await whenUiSettled(page); check( 'a tile click does not exempt the unpinned tray from mouse-away auto-dismiss', dismissAfterTileClick.afterClick.collapsed === false @@ -1205,7 +1189,7 @@ try { await checkResponsiveStyleFocus(1000); await page.setViewport({ width: 620, height: 900, deviceScaleFactor: 1 }); - await new Promise((resolve) => setTimeout(resolve, 180)); + await whenUiSettled(page); await waitForShellSettled(); await openPhoneMoreSheet(); const at620 = await trayMetrics(); @@ -1222,7 +1206,7 @@ try { await checkResponsiveStyleFocus(620); await page.setViewport({ width: 480, height: 900, deviceScaleFactor: 1 }); - await new Promise((resolve) => setTimeout(resolve, 180)); + await whenUiSettled(page); const at480 = await trayMetrics(); const allRectsInside = at480.chips.every((chip) => ( chip.left >= 0 && chip.right <= at480.viewport.width @@ -1294,10 +1278,7 @@ try { timeout: 60_000, }); await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 60_000 }); - await page.waitForFunction( - () => document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 60_000 }, - ); + await whenSettled(page); } await page.waitForFunction( () => window.__godsEyeView.styleManager.mapStackController.getState()?.status !== 'switching', @@ -1429,7 +1410,7 @@ try { await page.keyboard.press('Enter'); await page.focus('#control-panel-toggle'); await page.keyboard.press('Enter'); - await new Promise((resolve) => setTimeout(resolve, 400)); + await whenUiSettled(page); const pinnedSibling = await locationState(); check('Location: pinned tray survives sibling opening', pinnedSibling.pinned && pinnedSibling.locationExpanded === 'true' @@ -1449,14 +1430,14 @@ try { const searchTab = '#mobile-tab-bar [data-mobile-sheet="search"]'; await page.focus(searchTab); await page.keyboard.press('Enter'); - await new Promise((resolve) => setTimeout(resolve, 250)); + await whenUiSettled(page); const field = await focusMetrics('#location-search'); check(`Search: ${width} px keyboard opens the sheet on the location field`, field.focusedId === 'location-search' && field.visible && field.insideViewport, JSON.stringify(field)); await page.screenshot({ path: path.join(shotsDir, `${width}-search-open.png`) }); await page.keyboard.press('Escape'); - await new Promise((resolve) => setTimeout(resolve, 250)); + await whenUiSettled(page); const tab = await focusMetrics(searchTab); const closedSheet = await page.evaluate(() => !document.body.dataset.mobileSheet); check(`Search: ${width} px Escape closes the sheet with a visible ring on its tab`, @@ -1747,7 +1728,7 @@ try { check('no new page or console errors', consoleErrors.length === 0, consoleErrors.join(' | ')); } finally { - await browser.close(); + await closeBrowser(browser); } if (failures.length) { diff --git a/scripts/qa-military-names.mjs b/scripts/qa-military-names.mjs index fa84bbdf7..aec7d0d87 100644 --- a/scripts/qa-military-names.mjs +++ b/scripts/qa-military-names.mjs @@ -1,8 +1,10 @@ #!/usr/bin/env node +import { evaluateWithGeo } from './qa/geometry.mjs'; /** Named military areas: continent performance, close identities and a recorded zoom. */ +import { whenSettled, whenFramesDrawn } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; -import puppeteer from 'puppeteer'; import { checkInstallationPan, checkInstallationSelection, @@ -34,14 +36,10 @@ const result = { video: null, }; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, protocolTimeout: 180000, - args: [ - '--use-angle=metal', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - ], + args: ['--disable-background-timer-throttling', '--disable-renderer-backgrounding'], }); let recorder; try { @@ -69,10 +67,10 @@ try { }); await page.waitForFunction( () => - window.__godsEyeView?.dataManager && - document.getElementById('loading-screen')?.classList.contains('hidden'), + window.__godsEyeView?.dataManager, { timeout: 120000 }, ); + await whenSettled(page); await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, ); @@ -92,11 +90,10 @@ try { for (const [id] of g.dataManager.layers) await g.dataManager.setEnabled(id, false); g.styleManager.setDetection({ enabled: false }); - g.viewer.scene.screenSpaceCameraController.enableCollisionDetection = false; }); assert.equal(result.packs.length, 0, 'names pack stays unloaded at startup'); result.renderer = await page.evaluate(() => { - const gl = window.__godsEyeView.viewer.scene.context._gl; + const gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2'); const ext = gl.getExtension('WEBGL_debug_renderer_info'); return ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) @@ -111,7 +108,7 @@ try { .module.getStats(), ); const settle = async () => { - await sleep(500); + await whenFramesDrawn(page); await page.waitForFunction( () => { const s = window.__godsEyeView.dataManager.layers @@ -121,7 +118,7 @@ try { }, { timeout: 60000 }, ); - await sleep(1200); + await whenFramesDrawn(page); }; const us = { lon: -100, lat: 39, height: 6500000, pitch: -90 }; await moveCamera(page, us, 0); @@ -213,7 +210,7 @@ try { await page.evaluate(() => window.__godsEyeView.styleManager.setCleanView(true), ); - await sleep(300); + await whenFramesDrawn(page); result.cleanUi = await page.evaluate(async () => { const host = (await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine); const root = document.getElementById('world-overlay-root'); @@ -278,7 +275,7 @@ try { const screen = new C.Cartesian2(); window.__installationFrames = []; window.__stopInstallationFrames = - g.viewer.scene.postRender.addEventListener(() => { + window.__godsEyeView.engine.frames.postRender.addEventListener(() => { const stats = layer.getStats(); const markers = [], labels = [], @@ -286,11 +283,7 @@ try { layer.visitNamedMarkers((id, point) => { const rect = getPaintRect('military-installations', id); if (rect) rects.push(rect); - const p = C.SceneTransforms.worldToWindowCoordinates( - g.viewer.scene, - point.position, - screen, - ); + const p = window.__godsEyeView.engine.projector.worldToWindow(point.position, screen); if ( point.show && p && @@ -316,7 +309,7 @@ try { ) overlaps++; } - const altitude = g.viewer.camera.positionCartographic.height; + const altitude = window.__godsEyeView.engine.camera.pose().cartographic.height; window.__installationFrames.push({ t: performance.now(), altitude, @@ -331,7 +324,7 @@ try { }); }); const clipStart = await pageNow(page); - await sleep(1200); + await whenFramesDrawn(page); for (const view of [ { lon: -116, lat: 35, height: 1500000, pitch: -85 }, { lon: -117.2, lat: 33, height: 200000, pitch: -80 }, @@ -381,11 +374,11 @@ try { for (const [key, expected, lon, lat, height] of sites) { await moveCamera(page, { lon, lat, height, pitch: -80 }, 1.5); await settle(); - const snapshot = await page.evaluate( - (lat, lon) => { + const snapshot = await evaluateWithGeo(page, + async (qaGeo, lat, lon) => { const g = window.__godsEyeView; const layer = g.dataManager.layers.get('military-installations').module; - const position = g.viewer.scene.globe.ellipsoid.cartographicToCartesian( + const position = qaGeo.cartographic.toCartesian( { longitude: (lon * Math.PI) / 180, latitude: (lat * Math.PI) / 180, @@ -466,7 +459,7 @@ try { ); assert.match(result.cardText, /Fort Hood/); assert.match(result.cardText, /TA-25/); - await sleep(1800); + await whenFramesDrawn(page); await page.$eval( '#military-awareness-panel .military-awareness-names', (el) => { @@ -481,5 +474,5 @@ try { if (recorder) result.video = await recorder.stop().catch((e) => ({ error: e.message })); await fs.writeFile(`${dir}/result.json`, JSON.stringify(result, null, 2)); - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-nepal-media-playback.mjs b/scripts/qa-nepal-media-playback.mjs index 87dc5d64f..c4d24bf56 100644 --- a/scripts/qa-nepal-media-playback.mjs +++ b/scripts/qa-nepal-media-playback.mjs @@ -1,18 +1,19 @@ #!/usr/bin/env node // Composed-app media policy/lifecycle checks. Pinokio UA is a policy fixture, // not native-host acceptance; the YouTube SDK clock below is deterministic. +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; const output = path.resolve( process.env.QA_OUTPUT_DIR || 'qa-shots/nepal-media', ); fs.mkdirSync(output, { recursive: true }); -const browser = await puppeteer.launch({ - headless: process.env.QA_HEADFUL !== '1', - args: process.env.QA_HEADFUL === '1' ? ['--use-angle=metal'] : [], +const browser = await launchBrowser({ + headless: true, + args: process.env.QA_HEADFUL === '1' ? [] : [], }); let assertions = 0; const check = (name, value) => { @@ -84,12 +85,10 @@ try { `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, { waitUntil: 'domcontentloaded' }, ); - await page.waitForFunction( - () => - window.__godsEyeView?.sceneDirector && - document.querySelector('#loading-screen')?.classList.contains('hidden'), - { timeout: 60000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.sceneDirector, { + timeout: 60000, + }); + await whenSettled(page); const result = await page.evaluate(async (pinokio) => { const d = window.__godsEyeView.sceneDirector; const scene = d._project.scenes.find((s) => /Nepal/.test(s.title)); @@ -200,5 +199,5 @@ try { } console.log(`PASS: ${assertions} assertions`); } finally { - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-openfreemap-same-tile.mjs b/scripts/qa-openfreemap-same-tile.mjs index 1ce44cc95..617408a11 100644 --- a/scripts/qa-openfreemap-same-tile.mjs +++ b/scripts/qa-openfreemap-same-tile.mjs @@ -17,10 +17,11 @@ * projection must fit one frame; Traffic does no outline projection and the * warm ask reuses it. Traffic activation Long Tasks are reported separately. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; -import puppeteer from 'puppeteer'; const args = process.argv.slice(2); const valueAfter = (flag, fallback) => { @@ -155,19 +156,13 @@ function workEvidence(entries, label) { await fs.mkdir(path.dirname(OUTPUT), { recursive: true }); -const browser = await puppeteer.launch({ - headless: args.includes('--headful') ? false : 'new', +const browser = await launchBrowser({ + headless: true, protocolTimeout: 240_000, - ...(process.env.PUPPETEER_EXECUTABLE_PATH - ? { executablePath: process.env.PUPPETEER_EXECUTABLE_PATH } - : {}), args: [ '--no-sandbox', '--disable-setuid-sandbox', - '--use-gl=angle', - ...(args.includes('--headful') - ? [] - : ['--use-angle=swiftshader', '--enable-unsafe-swiftshader']), + ...(args.includes('--headful') ? [] : ['--enable-unsafe-swiftshader']), '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', @@ -251,15 +246,15 @@ async function settleOutline(page, label) { } if (!annotation || annotation.pendingOutline) throw new Error(`${label}: outline did not settle`); - const { scene } = window.__godsEyeView.viewer; await new Promise((resolve) => { const timer = setTimeout(resolve, 1500); - const remove = scene.postRender.addEventListener(() => { - clearTimeout(timer); - remove(); - resolve(); - }); - scene.requestRender(); + const remove = + window.__godsEyeView.engine.frames.postRender.addEventListener(() => { + clearTimeout(timer); + remove(); + resolve(); + }); + window.__godsEyeView.engine.render.request(); }); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)), @@ -414,13 +409,13 @@ try { }); await page.waitForFunction( () => - window.__godsEyeView?.viewer && + window.__godsEyeView?.engine && window.__godsEyeView?.dataManager && window.__gevVoiceCommands?.runner && - window.__gevAnnotations && - document.getElementById('loading-screen')?.classList.contains('hidden'), + window.__gevAnnotations, { timeout: 120_000, polling: 250 }, ); + await whenSettled(page); await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, ); @@ -428,10 +423,10 @@ try { window.__godsEyeView.styleManager.setDetection({ enabled: false }), ); await page.evaluate(async (target) => { - const { viewer, dataManager } = window.__godsEyeView; + const { dataManager } = window.__godsEyeView; await dataManager.setEnabled('traffic', false); - viewer.camera.cancelFlight(); - viewer.camera.setView({ + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ destination: window.__CESIUM__.Cartesian3.fromDegrees( target.longitude, target.latitude, @@ -443,11 +438,11 @@ try { roll: 0, }, }); - viewer.camera.moveEnd.raiseEvent(); + window.__godsEyeView.engine.camera.moveEnd.raiseEvent(); window.__gevAnnotations.clear(); performance.clearMeasures(); }, TARGET); - await sleep(750); + await whenSettled(page); if (run === 0) { phase = 'overlap'; @@ -469,7 +464,7 @@ try { await sleep(100); await heldTargetRequest.continue(); const overlap = await settleOutline(page, 'QA Capitol overlap'); - await sleep(750); + await whenSettled(page); const trafficStats = await page.evaluate(() => window.__godsEyeView.dataManager.layers .get('traffic') @@ -538,7 +533,7 @@ try { }, { timeout: 180_000, polling: 250 }, ); - await sleep(1500); + await whenSettled(page); const trafficMeasures = relevantMeasures( await page.evaluate(() => performance.getEntriesByType('measure').map((entry) => ({ @@ -582,7 +577,7 @@ try { phase = 'outline-cold'; const cold = await driveOutline(page, `QA Capitol cold ${run}`); - await sleep(750); + await whenSettled(page); cold.measures = relevantMeasures(cold.measures); cold.work = workEvidence(cold.measures, 'cold'); cold.network = network.filter( @@ -596,7 +591,7 @@ try { await page.evaluate(() => window.__gevAnnotations.clear()); phase = 'outline-warm'; const warm = await driveOutline(page, `QA Capitol warm ${run}`); - await sleep(750); + await whenSettled(page); warm.measures = relevantMeasures(warm.measures); warm.work = workEvidence(warm.measures, 'warm'); warm.network = network.filter( @@ -674,7 +669,7 @@ try { assert.equal(warmDecodes.length, 0, 'decoded-cache hit performs no decode'); report.renderer ||= await page.evaluate(() => { - const gl = window.__godsEyeView.viewer.scene.context._gl; + const gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2'); const debug = gl.getExtension('WEBGL_debug_renderer_info'); return debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) @@ -849,5 +844,5 @@ try { process.exitCode = 1; } finally { await fs.writeFile(OUTPUT, `${JSON.stringify(report, null, 2)}\n`); - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-overlay-baseline.mjs b/scripts/qa-overlay-baseline.mjs index 719bfb09c..f26839306 100644 --- a/scripts/qa-overlay-baseline.mjs +++ b/scripts/qa-overlay-baseline.mjs @@ -1,4 +1,7 @@ #!/usr/bin/env node +import { chromeExecutablePath } from './qa-chrome.mjs'; +// Cesium-only probes: native entity, DataSource and primitive counts independently audit Cesium ownership. +import { whenSettled, whenFramesDrawn } from './qa/ready.mjs'; /** * Phase 0 world-overlay baseline harness. * @@ -25,13 +28,13 @@ * and primitives (none of them a layer's own any more). */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import puppeteer from 'puppeteer'; import { fromDegrees } from '../src/geo/cartesian3.js'; -const CHROME_EXECUTABLE = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const CHROME_EXECUTABLE = chromeExecutablePath(); const DEFAULT_URL = 'http://localhost:4176'; const VIEWPORT = Object.freeze({ width: 1440, height: 900 }); const SAMPLE_MS = 5_000; @@ -366,20 +369,19 @@ async function waitForApp(page) { () => Boolean(window.__godsEyeView?.engine && window.__godsEyeView?.dataManager), { timeout: 60_000 }, ); + await whenSettled(page); await page.evaluate(() => { - const { engine, viewer } = window.__godsEyeView; + const { engine } = window.__godsEyeView; try { engine.camera.cancelFlight(); } catch { /* no flight */ } - try { viewer?.scene.tweens?.removeAll?.(); } catch { /* no tween collection */ } }); - await sleep(1_250); + await whenFramesDrawn(page); } async function setCamera(page, camera) { if (!camera) return; await page.evaluate(([destination, heading, pitch]) => { - const { engine, viewer } = window.__godsEyeView; + const { engine } = window.__godsEyeView; engine.camera.cancelFlight(); - viewer?.scene.tweens?.removeAll?.(); engine.camera.setView({ destination, orientation: { heading, pitch, roll: 0 }, @@ -587,7 +589,7 @@ async function prepareCctv(page, heightM) { }, heightM); if (!camera) return 'CCTV catalog did not expose a geolocated camera'; await setCamera(page, camera); - await sleep(7_000); + await whenFramesDrawn(page); return null; } @@ -600,14 +602,14 @@ async function prepareFirms(page) { }); if (!result.fire) return `FIRMS unavailable (${result.stats.error || 'no detections'})`; await setCamera(page, [result.fire.longitude, result.fire.latitude, 60_000, 0, -1.45]); - await sleep(4_000); + await whenFramesDrawn(page); return null; } async function prepareVessels(page) { const state = await readLayerState(page, 'ais-live-vessels'); if (!(state?.stats?.count > 0)) return `AIS unavailable (${state?.stats?.error || 'no live vessels'})`; - await sleep(2_500); + await whenFramesDrawn(page); return null; } @@ -621,7 +623,7 @@ async function prepareDetection(page, densityPct) { densityPct: density, }), densityPct); if (!control?.ok) return `Detection control failed (${control?.error || 'unknown error'})`; - await sleep(2_000); + await whenFramesDrawn(page); return null; } @@ -650,7 +652,7 @@ async function prepareTrackedFlight(page, cockpit) { } catch { return 'Civil tracking did not create a tracked entity'; } - await sleep(2_000); + await whenFramesDrawn(page); if (!cockpit) return null; const entryAvailable = await page.evaluate(() => { const button = document.getElementById('cockpit-entry'); @@ -664,7 +666,7 @@ async function prepareTrackedFlight(page, cockpit) { } catch { return 'Cockpit entry control did not enter cockpit mode'; } - await sleep(2_000); + await whenFramesDrawn(page); return null; } @@ -683,7 +685,7 @@ async function prepareMission(page) { } catch { return 'Mission selection did not reach the layer probe'; } - await sleep(2_000); + await whenFramesDrawn(page); return null; } @@ -703,7 +705,7 @@ async function prepareScene(page, scene) { if (!skipReason && scene.detectionDensity) skipReason = await prepareDetection(page, scene.detectionDensity); if (!skipReason && scene.trackedFlight) skipReason = await prepareTrackedFlight(page, scene.cockpit); if (!skipReason && scene.missionSelected) skipReason = await prepareMission(page); - await sleep(DEFAULT_SETTLE_MS); + await whenFramesDrawn(page); return { layerActivations, skipReason }; } @@ -868,7 +870,7 @@ async function runScene(browser, scene, shotsDir) { const motion = await samplePhase(page, true); const afterMotionShot = await captureShot(page, shotsDir, scene.id, 'after-motion'); if (afterMotionShot) result.screenshots.push(afterMotionShot); - await sleep(BETWEEN_PHASE_SETTLE_MS); + await whenFramesDrawn(page); const rest = await samplePhase(page, false); result.samples = { motion, rest }; result.entityInventoryAfter = await readEntityInventory(page); @@ -904,9 +906,6 @@ function unavailableRenderer(renderer) { } async function main() { - if (!fs.existsSync(CHROME_EXECUTABLE)) { - throw new Error(`Required Chrome executable not found: ${CHROME_EXECUTABLE}`); - } if (!Number.isFinite(DPR) || DPR <= 0) throw new Error(`Invalid --dpr value: ${DPR}`); const scenes = selectedScenes(); if (DIST_DIR && (!fs.existsSync(DIST_DIR) || !fs.statSync(DIST_DIR).isDirectory())) { @@ -917,18 +916,9 @@ async function main() { if (!response.ok) throw new Error(`Dev server unavailable at ${APP_URL}: ${response.status || response.statusText}`); } - const browser = await puppeteer.launch({ - executablePath: CHROME_EXECUTABLE, - headless: HEADFUL ? false : 'new', - args: [ - ...(process.env.QA_ANGLE ? ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE}`] : HARDWARE_GPU ? [] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']), - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - `--window-size=${VIEWPORT.width},${VIEWPORT.height}`, - ], + const browser = await launchBrowser({ + headless: true, + args: [...(process.env.QA_ANGLE ? [] : HARDWARE_GPU ? [] : ['--enable-unsafe-swiftshader']), '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', `--window-size=${VIEWPORT.width},${VIEWPORT.height}`], }); const run = { generatedAt: new Date().toISOString(), context: null, scenes: [] }; @@ -974,13 +964,9 @@ async function main() { webglRenderer: browserContext.webglRenderer, }, viewport: { ...VIEWPORT, dpr: browserContext.dpr }, - gpuMode: process.env.QA_ANGLE || (HARDWARE_GPU ? 'hardware' : 'swiftshader'), + gpuMode: process.env.QA_ANGLE || 'metal', chromeExecutable: CHROME_EXECUTABLE, - chromeArgs: process.env.QA_ANGLE - ? ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE}`, '--no-sandbox'] - : HARDWARE_GPU - ? ['--no-sandbox'] - : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader', '--no-sandbox'], + chromeArgs: browser.process()?.spawnargs.slice(1), sampleMs: SAMPLE_MS, captureMode: DIST_DIR ? `static-dist:${path.resolve(DIST_DIR)}` : 'dev-server', }; @@ -992,7 +978,7 @@ async function main() { printScene(result); } } finally { - await browser.close(); + await closeBrowser(browser); } run.summary = { diff --git a/scripts/qa-overpass-offload.mjs b/scripts/qa-overpass-offload.mjs index b7181c0f5..8b9b3447b 100644 --- a/scripts/qa-overpass-offload.mjs +++ b/scripts/qa-overpass-offload.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +// Cesium-only probes: native primitives and globe instrumentation isolate Cesium paint and offload cost. +import { evaluateWithGeo } from './qa/geometry.mjs'; /** * qa-overpass-offload.mjs — keyless/keyed source replacement acceptance gate. * @@ -25,10 +27,11 @@ * Screenshots and a JSON result go to qa-shots/ (gitignored). Exits nonzero on * a failed assertion. Works against keyed or keyless dev servers. */ +import { whenSettled, whenTilesLoaded } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; -import puppeteer from 'puppeteer'; import { tilesForBounds } from '../src/data/tomtomTiles.js'; import { trafficDetailBounds } from '../src/layers/traffic/source.js'; @@ -55,19 +58,13 @@ const profile = args.includes('--profile'); const coverage = args.includes('--coverage'); const shots = path.resolve('qa-shots'); await fs.mkdir(shots, { recursive: true }); -const browser = await puppeteer.launch({ - headless: !args.includes('--headful'), +const browser = await launchBrowser({ + headless: true, protocolTimeout: 300_000, - ...(process.env.PUPPETEER_EXECUTABLE_PATH - ? { executablePath: process.env.PUPPETEER_EXECUTABLE_PATH } - : {}), args: [ '--no-sandbox', '--disable-setuid-sandbox', - '--use-gl=angle', - ...(args.includes('--software-gl') - ? ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'] - : []), + ...(args.includes('--software-gl') ? ['--enable-unsafe-swiftshader'] : []), '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', @@ -346,14 +343,10 @@ try { timeout: 60_000, }); await page.waitForFunction( - () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + () => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager, { timeout: 60_000, polling: 500 }, ); - await page.waitForFunction( - () => - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 60_000, polling: 250 }, - ); + await whenSettled(page); // Startup restoration owns its camera flight until the loading cover is gone. await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, @@ -362,7 +355,7 @@ try { window.__godsEyeView.styleManager.setDetection({ enabled: false }), ); result.renderer = await page.evaluate(() => { - const gl = window.__godsEyeView.viewer.scene.context._gl; + const gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2'); const debug = gl.getExtension('WEBGL_debug_renderer_info'); return debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) @@ -374,13 +367,13 @@ try { { timeout: 10_000 }, ); async function fly(lat, lon, height = 2000, heading = 0, pitch = -75) { - await page.evaluate( - async (view) => { - const { viewer } = window.__godsEyeView; - viewer.camera.cancelFlight(); + await evaluateWithGeo( + page, + async (qaGeo, view) => { + window.__godsEyeView.engine.camera.cancelFlight(); await new Promise((resolve) => - viewer.camera.flyTo({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + window.__godsEyeView.engine.camera.flyTo({ + destination: qaGeo.cartographic.toCartesian({ latitude: (view.lat * Math.PI) / 180, longitude: (view.lon * Math.PI) / 180, height: view.height, @@ -400,22 +393,7 @@ try { ); } async function settleTiles() { - await page.waitForFunction( - () => { - const { scene } = window.__godsEyeView.viewer; - if (scene.globe.show) return scene.globe.tilesLoaded; - let found = false; - for (let i = 0; i < scene.primitives.length; i++) { - const primitive = scene.primitives.get(i); - if (!primitive.show || typeof primitive.tilesLoaded !== 'boolean') - continue; - found = true; - if (!primitive.tilesLoaded) return false; - } - return found; - }, - { timeout: 90_000, polling: 500 }, - ); + await whenTilesLoaded(page, { timeoutMs: 90_000 }); } async function shot(name) { await settleTiles(); @@ -440,30 +418,35 @@ try { const milliseconds = await page.evaluate( (allowEmpty) => new Promise((resolve, reject) => { - const { viewer, dataManager } = window.__godsEyeView; + const { dataManager } = window.__godsEyeView; const start = performance.now(); const cleanup = () => { remove(); clearTimeout(timer); }; - const remove = viewer.scene.postRender.addEventListener(() => { - const stats = dataManager.layers.get('traffic').module.getStats(); - if (stats.count <= 0) { - if ( - allowEmpty && - !stats.loading && - stats.lastUpdate && - performance.now() - start > 500 - ) { + const remove = + window.__godsEyeView.engine.frames.postRender.addEventListener( + () => { + const stats = dataManager.layers + .get('traffic') + .module.getStats(); + if (stats.count <= 0) { + if ( + allowEmpty && + !stats.loading && + stats.lastUpdate && + performance.now() - start > 500 + ) { + cleanup(); + resolve(null); + } + return; + } + const elapsed = performance.now() - start; cleanup(); - resolve(null); - } - return; - } - const elapsed = performance.now() - start; - cleanup(); - resolve(elapsed); - }); + resolve(elapsed); + }, + ); const timer = setTimeout(() => { cleanup(); reject(new Error('No traffic dots within 180 seconds')); @@ -551,23 +534,24 @@ try { .map((p) => C.Cartesian3.clone(p.position)), ); for (const position of positions) { - const screen = C.SceneTransforms.worldToWindowCoordinates( - scene, - position, - ); + const screen = + window.__godsEyeView.engine.projector.worldToWindow(position); if ( !screen || screen.x < 0 || - screen.x >= scene.canvas.clientWidth || + screen.x >= window.__godsEyeView.engine.host.canvas.clientWidth || screen.y < 0 || - screen.y >= scene.canvas.clientHeight + screen.y >= window.__godsEyeView.engine.host.canvas.clientHeight ) continue; inView++; const carto = C.Cartographic.fromCartesian(position); const height = scene.globe.show - ? scene.globe.getHeight(carto) - : scene.sampleHeight(carto, collections); + ? window.__godsEyeView.engine.surface.terrainHeight(carto) + : window.__godsEyeView.engine.surface.sampleHeight( + carto, + collections, + ); if (Number.isFinite(height) && Math.abs(height) <= 9000) { sampled++; const delta = carto.height - height; @@ -645,13 +629,13 @@ try { ); view.layers = await waitForSources(); view.actualCamera = await page.evaluate(() => { - const camera = window.__godsEyeView.viewer.camera; - const position = camera.positionCartographic; + const position = window.__godsEyeView.engine.camera.pose().cartographic; return { lat: (position.latitude * 180) / Math.PI, lon: (position.longitude * 180) / Math.PI, height: position.height, - pitch: (camera.pitch * 180) / Math.PI, + pitch: + (window.__godsEyeView.engine.camera.pose().pitch * 180) / Math.PI, }; }); assert.ok( @@ -736,14 +720,16 @@ try { surface.stats.mode === 'live' ? mode : 'osm', ); const actualCamera = await page.evaluate(() => { - const camera = window.__godsEyeView.viewer.camera; - const p = camera.positionCartographic; + const p = window.__godsEyeView.engine.camera.pose().cartographic; return { lat: (p.latitude * 180) / Math.PI, lon: (p.longitude * 180) / Math.PI, height: p.height, - pitch: (camera.pitch * 180) / Math.PI, - heading: (camera.heading * 180) / Math.PI, + pitch: + (window.__godsEyeView.engine.camera.pose().pitch * 180) / Math.PI, + heading: + (window.__godsEyeView.engine.camera.pose().heading * 180) / + Math.PI, }; }); for (const [key, expected, tolerance] of [ @@ -868,9 +854,7 @@ try { } } else if (pan) { const sessionBudget = 32; // <= 0.54% of the default 6,000/day upstream budget. - await page.evaluate(() => { - window.__godsEyeView.viewer.scene.screenSpaceCameraController.enableCollisionDetection = false; - }); + await page.evaluate(() => {}); beginView('pan-start'); await fly(30.2672, -97.7431, 450, 0, -35); await enableTrafficTimed('pan-start'); @@ -992,9 +976,7 @@ try { // A continent jump can temporarily use the previous city's terrain height // for collision avoidance (São Paulo -> Austin raises a 450 m target). // Pin the prescribed camera fixtures so the revisit covers identical tiles. - await page.evaluate(() => { - window.__godsEyeView.viewer.scene.screenSpaceCameraController.enableCollisionDetection = false; - }); + await page.evaluate(() => {}); for (const city of [ ['austin', 30.2672, -97.7431], ['london', 51.5074, -0.1278], @@ -1043,7 +1025,7 @@ try { { timeout: 120_000, polling: 500 }, ); const austin = await page.evaluate(() => { - const { viewer, dataManager } = window.__godsEyeView; + const { dataManager } = window.__godsEyeView; const traffic = dataManager.layers.get('traffic').module.getStats(); const alpr = dataManager.layers.get('alpr-cameras').module.getStats(); // The camera badges: the layer's sprite mover sets (normal and @@ -1059,7 +1041,8 @@ try { alpr, cameraEntities, sourceLabel: traffic.loadingLabel, - cameraHeight: viewer.camera.positionCartographic.height, + cameraHeight: + window.__godsEyeView.engine.camera.pose().cartographic.height, }; }); result.traffic = austin.traffic; @@ -1219,14 +1202,10 @@ try { waitUntil: 'domcontentloaded', timeout: 60_000, }); - await page.waitForFunction( - () => - window.__godsEyeView?.dataManager && - document - .getElementById('loading-screen') - ?.classList.contains('hidden'), - { timeout: 90_000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.dataManager, { + timeout: 90_000, + }); + await whenSettled(page); await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, ); @@ -1282,9 +1261,9 @@ try { { timeout: 120_000, polling: 500 }, before, ); - result.military = await page.evaluate(() => { - const { viewer, dataManager } = window.__godsEyeView; - const center = viewer.scene.globe.ellipsoid.cartographicToCartesian({ + result.military = await evaluateWithGeo(page, async (qaGeo) => { + const { dataManager } = window.__godsEyeView; + const center = qaGeo.cartographic.toCartesian({ latitude: (30.314 * Math.PI) / 180, longitude: (-97.763 * Math.PI) / 180, height: 0, @@ -1367,7 +1346,7 @@ try { priorFort, ); result.fortCavazos = await page.evaluate(() => { - const { viewer, dataManager } = window.__godsEyeView; + const { dataManager } = window.__godsEyeView; // Drawn site discs (context handles, see Camp Mabry) plus the named // markers the stats count. let markers = 0, @@ -1533,5 +1512,5 @@ try { path.join(shots, 'overpass-offload-result.json'), JSON.stringify(result, null, 2), ); - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 407847195..ea755aa94 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +// Cesium-only probes: native flight interception and entity sentinels verify Cesium camera ownership. +import { evaluateWithGeo } from './qa/geometry.mjs'; /** * Deterministic browser proof for the Radio companion layer. * @@ -11,10 +13,11 @@ * * Run: node scripts/qa-radio.mjs --url http://localhost:4193 */ +import { whenSettled, whenFramesDrawn } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import puppeteer from 'puppeteer'; import { stations } from './fixtures/layers/radio.mjs'; import { isExpectedPreviewSetupError } from './qa-browserEvidence.mjs'; @@ -36,15 +39,6 @@ const RADIO_URL = new URL(APP_URL); RADIO_URL.searchParams.set('welcome', '0'); const HEADFUL = args.includes('--headful'); -const chromeCandidates = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -const chrome = chromeCandidates.find((candidate) => { - try { return fs.existsSync(candidate); } catch { return false; } -}); const results = []; @@ -86,14 +80,13 @@ async function restoreRadioPanels(page, prior) { // not on wall-clock sleeps, particularly with software rendering. async function settleRadioFrames(page) { await page.evaluate(() => new Promise((resolve, reject) => { - const scene = window.__godsEyeView.viewer.scene; let remaining = 4; const timeout = setTimeout(() => { remove(); reject(new Error('Radio rendered-frame deadline exceeded')); }, 10_000); - const remove = scene.postRender.addEventListener(() => { + const remove = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { if (--remaining === 0) { remove(); clearTimeout(timeout); resolve(); } - else requestAnimationFrame(() => scene.requestRender()); + else requestAnimationFrame(() => window.__godsEyeView.engine.render.request()); }); - scene.requestRender(); + window.__godsEyeView.engine.render.request(); })); } @@ -105,17 +98,9 @@ async function main() { process.exit(2); } fs.mkdirSync(SHOTS_DIR, { recursive: true }); - const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', - ...(chrome ? { executablePath: chrome } : {}), - args: [ - '--no-sandbox', '--disable-setuid-sandbox', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), - '--disable-dev-shm-usage', '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', '--window-size=1440,900', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1440,900'], }); try { @@ -370,17 +355,15 @@ async function main() { await page.goto(RADIO_URL.href, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await page.waitForFunction(() => window.__godsEyeView?.dataManager, { timeout: 60_000 }); + await whenSettled(page); await page.waitForFunction(() => window.__godsEyeView?.styleManager?._dataManager?.layers?.has('radio'), { timeout: 60_000 }); - await page.waitForFunction( - () => document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 60_000 }, - ); + await whenSettled(page); await page.waitForFunction(() => ( typeof window.__gevQaRegisterLayer === 'function' && typeof window.__gevQaUnregisterLayer === 'function' )); await page.evaluate(() => { - window.__godsEyeView.viewer.camera.cancelFlight(); + window.__godsEyeView.engine.camera.cancelFlight(); window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', true); window.__godsEyeView.styleManager.setPanelCollapsed('cctv-panel', true); window.__godsEyeView.styleManager.setPanelCollapsed('radio-panel', true); @@ -470,7 +453,7 @@ async function main() { const selectedBefore = module.getUIState().selected?.id || null; const playsBefore = window.__qaRadioPlayCalls.length; const camera = gev.viewer.camera; - const originalFlyTo = camera.flyTo; + const originalFlyTo = window.__godsEyeView.engine.camera.flyTo; const flyToCalls = []; camera.flyTo = (options) => flyToCalls.push(options); const beganTuning = module.beginTuning(); @@ -643,7 +626,7 @@ async function main() { const scroller = document.querySelector('#global-context-panel .global-context-panel-inner'); const radio = document.getElementById('radio-panel'); scroller.scrollTop = Math.max(0, radio.offsetTop - scroller.clientHeight + 70); - const camera = gev.viewer.camera.positionWC; + const camera = window.__godsEyeView.engine.camera.pose().position; return { scrollTop: scroller.scrollTop, pageY: window.scrollY, @@ -716,7 +699,7 @@ async function main() { const viewport = scroller.getBoundingClientRect(); const directory = document.querySelector('#radio-panel .radio-directory-row').getBoundingClientRect(); const play = document.getElementById('radio-play-btn').getBoundingClientRect(); - const camera = gev.viewer.camera.positionWC; + const camera = window.__godsEyeView.engine.camera.pose().position; return { scrollTop: scroller.scrollTop, pageY: window.scrollY, @@ -762,23 +745,21 @@ async function main() { { name: 'global', height: 24_200_000, limit: 16 }, { name: 'near', height: 500_000, limit: 32 }, ]) { - await page.evaluate(({ lon, height }) => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.cancelFlight(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo, { lon, height }) => { + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: lon * Math.PI / 180, latitude: 6 * Math.PI / 180, height, }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }, { lon: spec.lon, height: view.height }); - await sleep(700); + await whenFramesDrawn(page); await settleRadioFrames(page); const sample = await page.evaluate(async () => { - const viewer = window.__godsEyeView.viewer; const radio = window.__godsEyeView.dataManager.layers.get('radio').module; const drawn = window.__qaRadioDrawn; const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; @@ -806,11 +787,11 @@ async function main() { }); }; const timeout = setTimeout(finish, 500); - removePostRender = viewer.scene.postRender.addEventListener(() => { + removePostRender = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { clearTimeout(timeout); finish(); }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); }); singletonViews.push({ ...spec, ...view, ...sample }); @@ -1362,17 +1343,15 @@ async function main() { && allPalette.music?.[1] > allPalette.music?.[2], JSON.stringify(allPalette), ); - const clusterBadge = await page.evaluate(async () => { - const viewer = window.__godsEyeView.viewer; + const clusterBadge = await evaluateWithGeo(page, async (qaGeo) => { const drawn = window.__qaRadioDrawn; const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; - const ellipsoid = viewer.scene.globe.ellipsoid; // Start from another view, so the clusters at the view below are a new // cohort the host must solve (the old check forced one by switching // Cesium's clustering off and on). - viewer.camera.cancelFlight(); - viewer.camera.setView({ - destination: ellipsoid.cartographicToCartesian({ + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: 68 * Math.PI / 180, latitude: 7 * Math.PI / 180, height: 24_200_000, @@ -1382,15 +1361,15 @@ async function main() { await new Promise((resolve) => setTimeout(resolve, 500)); await drawn.frames(3); const baselineSolveRevision = getWorldOverlayDiagnostics().solveRevision; - viewer.camera.setView({ - destination: ellipsoid.cartographicToCartesian({ + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: -98 * Math.PI / 180, latitude: 38 * Math.PI / 180, height: 18_000_000, }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); const radio = window.__godsEyeView.dataManager.layers.get('radio').module; // The layer clusters on screen itself now (render/display.js, // ../screenClusters.js) into the radio-clusters point set; the cluster @@ -1405,7 +1384,7 @@ async function main() { removePostRender?.(); done({ rect: null, host: getWorldOverlayDiagnostics() }); }, 150); - removePostRender = viewer.scene.postRender.addEventListener(() => { + removePostRender = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { clearTimeout(timeout); removePostRender(); const host = getWorldOverlayDiagnostics(); @@ -1421,7 +1400,7 @@ async function main() { host, }); }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); } const position = typeof painted?.position === 'function' ? painted.position() : painted?.position; @@ -1451,30 +1430,27 @@ async function main() { && clusterBadge?.rect?.h > 0, JSON.stringify(clusterBadge), ); - await page.evaluate(async () => { - const viewer = window.__godsEyeView.viewer; - const ellipsoid = viewer.scene.globe.ellipsoid; - viewer.camera.cancelFlight(); - viewer.camera.setView({ - destination: ellipsoid.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo) => { + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: 68 * Math.PI / 180, latitude: 7 * Math.PI / 180, height: 24_200_000, }), orientation: { heading: 15 * Math.PI / 180, pitch: -Math.PI / 2, roll: 0 }, }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); await new Promise((resolve) => setTimeout(resolve, 500)); }); await settleRadioFrames(page); - const highGlobalClusterLabels = await page.evaluate(async () => { - const viewer = window.__godsEyeView.viewer; + const highGlobalClusterLabels = await evaluateWithGeo(page, async (qaGeo) => { const radio = window.__godsEyeView.dataManager.layers.get('radio').module; - const ellipsoid = viewer.scene.globe.ellipsoid; + const ellipsoid = qaGeo.ellipsoid.WGS84; const { getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js'); const source = radio.getOverlayDiagnostics(); - const cameraPosition = viewer.camera.positionWC; + const cameraPosition = window.__godsEyeView.engine.camera.pose().position; const cameraRadius = Math.hypot(cameraPosition.x, cameraPosition.y, cameraPosition.z); const horizonDistance = Math.sqrt(Math.max( 0, @@ -1486,8 +1462,8 @@ async function main() { const drawn = window.__qaRadioDrawn; const { getPaintRect: getOverlayPaintRect } = window.__gevWorldOverlay; let clusterPoint = null; - const width = viewer.canvas.clientWidth; - const height = viewer.canvas.clientHeight; + const width = window.__godsEyeView.engine.host.canvas.clientWidth; + const height = window.__godsEyeView.engine.host.canvas.clientHeight; const anchors = source.clusterIds .map((entryId) => getOverlayPaintRect('radio', entryId)?.entry?.position) .map((position) => drawn.canvasPoint(typeof position === 'function' ? position() : position)) @@ -1503,7 +1479,7 @@ async function main() { return { source, host: getWorldOverlayDiagnostics(), - altitude: viewer.camera.positionCartographic.height, + altitude: window.__godsEyeView.engine.camera.pose().cartographic.height, horizonDistance, horizonDistanceAlpha: distanceFade(horizonDistance, { maxDistance: 50_000_000 }), clusterPoint, @@ -1521,10 +1497,8 @@ async function main() { JSON.stringify(highGlobalClusterLabels), ); await page.screenshot({ path: path.join(SHOTS_DIR, 'high-global-all-labels.png') }); - const clusterContinuity = await page.evaluate(async () => { - const viewer = window.__godsEyeView.viewer; + const clusterContinuity = await evaluateWithGeo(page, async (qaGeo) => { const radio = window.__godsEyeView.dataManager.layers.get('radio').module; - const ellipsoid = viewer.scene.globe.ellipsoid; const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; const paintedClusters = () => new Promise((resolve) => { let removePostRender = null; @@ -1532,7 +1506,7 @@ async function main() { removePostRender?.(); resolve({ clusters: [], fadingCount: -1, host: null, source: null }); }, 500); - removePostRender = viewer.scene.postRender.addEventListener(() => { + removePostRender = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { clearTimeout(timeout); removePostRender(); const source = radio.getOverlayDiagnostics(); @@ -1553,20 +1527,20 @@ async function main() { source, }); }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); const longitudes = [-25, -24, -23, -22, -23, -24, -25]; const samples = []; for (const longitude of longitudes) { - viewer.camera.setView({ - destination: ellipsoid.cartographicToCartesian({ + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: longitude * Math.PI / 180, latitude: 38 * Math.PI / 180, height: 10_000_000, }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); for (const delay of [50, 100, 200, 350]) { await new Promise((resolve) => setTimeout(resolve, delay)); samples.push({ longitude, delay, ...(await paintedClusters()) }); @@ -1621,11 +1595,10 @@ async function main() { JSON.stringify(clusterContinuity), ); const directoryRefreshContinuity = await page.evaluate(async () => { - const viewer = window.__godsEyeView.viewer; const radio = window.__godsEyeView.dataManager.layers.get('radio').module; const before = radio.getOverlayDiagnostics().clusterMemberships; await radio.update(); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); await new Promise((resolve) => setTimeout(resolve, 900)); const after = radio.getOverlayDiagnostics().clusterMemberships; const beforeByMembership = new Map(before.map((entry) => [entry.membershipId, entry.entryId])); @@ -2451,7 +2424,7 @@ async function main() { await page.evaluate(() => { const camera = window.__godsEyeView.viewer.camera; window.__qaFirstPlayFlyToCalls = []; - window.__qaFirstPlayOriginalFlyTo = camera.flyTo; + window.__qaFirstPlayOriginalFlyTo = window.__godsEyeView.engine.camera.flyTo; camera.flyTo = (options) => window.__qaFirstPlayFlyToCalls.push(options); }); const firstPlayPanels = await revealRadioControls(page, 'radio-play-btn'); @@ -2496,11 +2469,10 @@ async function main() { check('first Play never moves the camera', firstPlay.flyToCalls === 0, `${firstPlay.flyToCalls} flyTo calls`); await page.select('#radio-filter', 'news'); - await page.evaluate(() => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.cancelFlight(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo) => { + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: -98 * Math.PI / 180, latitude: 38 * Math.PI / 180, height: 18_000_000, @@ -2508,7 +2480,7 @@ async function main() { orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); }); - await sleep(700); + await whenFramesDrawn(page); await settleRadioFrames(page); const newsClusterTarget = await page.evaluate(() => { // A drawn cluster: a painted cluster entry whose anchor picks a @@ -2555,7 +2527,7 @@ async function main() { ); await page.select('#radio-filter', 'weather'); - await sleep(700); + await whenFramesDrawn(page); const weatherCluster = await page.evaluate(() => { const radio = window.__godsEyeView.dataManager.layers.get('radio').module; return { texts: radio.getOverlayDiagnostics().clusterTexts }; @@ -2607,19 +2579,18 @@ async function main() { ); const idleHorizonScans = await page.evaluate(async () => { const radio = window.__godsEyeView.dataManager.layers.get('radio').module; - const camera = window.__godsEyeView.viewer.camera; - camera.cancelFlight(); + window.__godsEyeView.engine.camera.cancelFlight(); await new Promise((resolve) => setTimeout(resolve, 350)); const positionBefore = { - x: camera.positionWC.x, - y: camera.positionWC.y, - z: camera.positionWC.z, + x: window.__godsEyeView.engine.camera.pose().position.x, + y: window.__godsEyeView.engine.camera.pose().position.y, + z: window.__godsEyeView.engine.camera.pose().position.z, }; const before = radio.getStats().horizonScans; await new Promise((resolve) => setTimeout(resolve, 850)); - const dx = camera.positionWC.x - positionBefore.x; - const dy = camera.positionWC.y - positionBefore.y; - const dz = camera.positionWC.z - positionBefore.z; + const dx = window.__godsEyeView.engine.camera.pose().position.x - positionBefore.x; + const dy = window.__godsEyeView.engine.camera.pose().position.y - positionBefore.y; + const dz = window.__godsEyeView.engine.camera.pose().position.z - positionBefore.z; return { before, after: radio.getStats().horizonScans, @@ -2729,21 +2700,19 @@ async function main() { && slider.getBoundingClientRect().width > 0; }); - const horizon = await page.evaluate(async () => { - const viewer = window.__godsEyeView.viewer; - const ellipsoid = viewer.scene.globe.ellipsoid; + const horizon = await evaluateWithGeo(page, async (qaGeo) => { const drawn = window.__qaRadioDrawn; // What is drawn, through the engine's picks at every fixture station's // canvas point: a station behind the globe projects onto the canvas // too (the point sets draw without a depth test), so a pick of one // there means it was not culled. const setView = (lon, lat = 0) => { - viewer.camera.cancelFlight(); - viewer.camera.setView({ - destination: ellipsoid.cartographicToCartesian({ longitude: lon * Math.PI / 180, latitude: lat * Math.PI / 180, height: 2_000_000 }), + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: lon * Math.PI / 180, latitude: lat * Math.PI / 180, height: 2_000_000 }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }; setView(-54); await new Promise((resolve) => setTimeout(resolve, 500)); @@ -2894,7 +2863,7 @@ async function main() { buttons: type === 'pointercancel' || type === 'pointerup' ? 0 : 1, })); const camera = gev.viewer.camera; - const originalFlyTo = camera.flyTo; + const originalFlyTo = window.__godsEyeView.engine.camera.flyTo; const flyToCalls = []; camera.flyTo = (options) => flyToCalls.push(options); const before = { @@ -3208,7 +3177,7 @@ async function main() { await page.mouse.down(); await page.mouse.move(tunerCommitTarget.targetX, tunerCommitTarget.y, { steps: 12 }); await page.mouse.up(); - await sleep(650); + await whenFramesDrawn(page); const tunerCommit = await page.evaluate((target) => { const gev = window.__godsEyeView; const slider = document.getElementById('radio-tuner-slider'); @@ -3317,7 +3286,7 @@ async function main() { && sample.ratio >= microDragSamples[index - 1].ratio)), JSON.stringify(microDragSamples), ); - const viewportTuner = await page.evaluate(async (selectedId) => { + const viewportTuner = await evaluateWithGeo(page, async (qaGeo, selectedId) => { const gev = window.__godsEyeView; const style = gev.styleManager; const before = { @@ -3326,11 +3295,11 @@ async function main() { selectedIndex: style._radioControls._radioTunerStations.findIndex((station) => station.id === selectedId), needle: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')), }; - const current = gev.viewer.camera.positionCartographic; + const current = window.__godsEyeView.engine.camera.pose().cartographic; const oppositeLongitude = ((current.longitude + 2 * Math.PI) % (2 * Math.PI)) - Math.PI; - gev.viewer.canvas.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 1 })); - gev.viewer.camera.setView({ - destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({ + window.__godsEyeView.engine.host.canvas.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 1 })); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: oppositeLongitude, latitude: -current.latitude, height: 2_000_000, @@ -3373,7 +3342,7 @@ async function main() { }; }); await page.$eval('#radio-next-btn', (button) => button.click()); - await sleep(1500); + await whenFramesDrawn(page); const nextNeedleAfter = await page.evaluate(() => { const style = window.__godsEyeView.styleManager; const selectedId = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id; @@ -3568,10 +3537,9 @@ async function main() { && fullPoolNavigation.afterNextWrap === fullPoolNavigation.expectedNextWrap, JSON.stringify(fullPoolNavigation), ); - await page.evaluate(() => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo) => { + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: Math.PI, latitude: -2 * Math.PI / 180, height: 2_000_000, @@ -3579,7 +3547,7 @@ async function main() { orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); }); - await sleep(500); + await whenFramesDrawn(page); // Next left the Radio stopped; a release plays only from an active stream. await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' })); await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing'); @@ -3630,8 +3598,7 @@ async function main() { ); await page.select('#radio-filter', 'news'); - const markerTarget = await page.evaluate(async () => { - const viewer = window.__godsEyeView.viewer; + const markerTarget = await evaluateWithGeo(page, async (qaGeo) => { const radio = window.__godsEyeView.dataManager.layers.get('radio').module; const drawn = window.__qaRadioDrawn; // Radio clusters on screen in its own display now (no Cesium @@ -3645,26 +3612,26 @@ async function main() { origin: 'programmatic', }); } - viewer.camera.cancelFlight(); + window.__godsEyeView.engine.camera.cancelFlight(); const markerView = { - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + destination: qaGeo.cartographic.toCartesian({ longitude: -174 * Math.PI / 180, latitude: 6 * Math.PI / 180, height: 500_000, }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }; - viewer.camera.setView(markerView); + window.__godsEyeView.engine.camera.setView(markerView); await new Promise((resolve) => { let removePostRender = null; - removePostRender = viewer.scene.postRender.addEventListener(() => { + removePostRender = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { removePostRender?.(); resolve(); }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); radio.setFilter('news'); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); await new Promise((resolve) => setTimeout(resolve, 500)); const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; return new Promise((resolve) => { @@ -3677,13 +3644,13 @@ async function main() { resolve(result); }; const probe = () => { - const camera = viewer.camera.positionCartographic; + const camera = window.__godsEyeView.engine.camera.pose().cartographic; const longitude = camera.longitude * 180 / Math.PI; const latitude = camera.latitude * 180 / Math.PI; if (Math.abs(longitude + 174) > 0.5 || Math.abs(latitude - 6) > 0.5) { - viewer.camera.cancelFlight(); - viewer.camera.setView(markerView); - viewer.scene.requestRender(); + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView(markerView); + window.__godsEyeView.engine.render.request(); return; } const diagnostics = radio.getOverlayDiagnostics(); @@ -3726,16 +3693,16 @@ async function main() { overlayEntryCount: diagnostics.entryCount, worldOverlay: getWorldOverlayDiagnostics(), camera: { - longitude: viewer.camera.positionCartographic.longitude * 180 / Math.PI, - latitude: viewer.camera.positionCartographic.latitude * 180 / Math.PI, - height: viewer.camera.positionCartographic.height, + longitude: window.__godsEyeView.engine.camera.pose().cartographic.longitude * 180 / Math.PI, + latitude: window.__godsEyeView.engine.camera.pose().cartographic.latitude * 180 / Math.PI, + height: window.__godsEyeView.engine.camera.pose().cartographic.height, }, }; - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }; timeout = setTimeout(() => finish(lastObservation || { found: false, reason: 'no post-render probe' }), 8000); - removePostRender = viewer.scene.postRender.addEventListener(probe); - viewer.scene.requestRender(); + removePostRender = window.__godsEyeView.engine.frames.postRender.addEventListener(probe); + window.__godsEyeView.engine.render.request(); }); }); check('Radio exposes a visible marker for click-tolerance QA', markerTarget?.found === true, JSON.stringify(markerTarget)); @@ -3761,14 +3728,13 @@ async function main() { check('an offset click within 8px reliably selects the intended Radio dot', offsetSelected === markerTarget.id, offsetSelected); } - await page.evaluate(() => { + await evaluateWithGeo(page, async (qaGeo) => { const gev = window.__godsEyeView; - const camera = gev.viewer.camera; const radio = gev.dataManager.layers.get('radio').module; - camera.cancelFlight(); - gev.viewer.canvas.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 1 })); - camera.setView({ - destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({ + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.host.canvas.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 1 })); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: Math.PI, latitude: -2 * Math.PI / 180, height: 2_000_000, @@ -3792,12 +3758,12 @@ async function main() { window.__qaRadioFailedNearest = false; window.__qaRadioDelayNextFailure = true; window.__qaRadioFlyToCalls = []; - window.__qaRadioOriginalFlyTo = camera.flyTo; - window.__qaRadioCameraHeight = camera.positionCartographic.height; + window.__qaRadioOriginalFlyTo = window.__godsEyeView.engine.camera.flyTo; + window.__qaRadioCameraHeight = window.__godsEyeView.engine.camera.pose().cartographic.height; camera.flyTo = (options) => window.__qaRadioFlyToCalls.push(options); gev.dataManager.layers.get('flights').enabled = true; }); - await sleep(500); + await whenFramesDrawn(page); await page.evaluate(() => { const manager = window.__godsEyeView.styleManager; manager.setPanelCollapsed('global-context-panel', false); @@ -3805,7 +3771,7 @@ async function main() { }); await page.waitForSelector('#radio-next-btn', { visible: true }); await page.evaluate(() => { - window.__qaRadioCameraHeight = window.__godsEyeView.viewer.camera.positionCartographic.height; + window.__qaRadioCameraHeight = window.__godsEyeView.engine.camera.pose().cartographic.height; }); const radioOnlyNextDispatched = await page.evaluate(() => { const button = document.getElementById('radio-next-btn'); @@ -3815,10 +3781,9 @@ async function main() { }); if (!radioOnlyNextDispatched) throw new Error('Radio next button unavailable'); await page.waitForFunction(() => typeof window.__qaRejectRadioPlay === 'function'); - await page.evaluate(() => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo) => { + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: Math.PI, latitude: -2 * Math.PI / 180, height: 2_400_000, @@ -3828,12 +3793,11 @@ async function main() { window.__qaRejectRadioPlay(); }); await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing'); - const playing = await page.evaluate(async () => { + const playing = await evaluateWithGeo(page, async (qaGeo) => { const radio = window.__godsEyeView.dataManager.layers.get('radio').module; - const viewer = window.__godsEyeView.viewer; const { getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; const flightTargets = window.__qaRadioFlyToCalls.map(({ destination }) => { - const position = viewer.scene.globe.ellipsoid.cartesianToCartographic(destination); + const position = qaGeo.cartographic.fromCartesian(destination); return { lat: position.latitude * 180 / Math.PI, lon: position.longitude * 180 / Math.PI, @@ -3887,7 +3851,7 @@ async function main() { const run = async (ownerPrefix, buttonId) => { const owner = viewer.entities.add({ id: `qa:${ownerPrefix}:radio-camera-owner`, - position: viewer.camera.positionWC.clone(), + position: window.__godsEyeView.engine.camera.pose().position, }); owner.gevTrackedId = `${ownerPrefix}:qa-radio-camera-owner`; viewer.trackedEntity = owner; @@ -3903,7 +3867,7 @@ async function main() { afterStationId: radio.getUIState().selected?.id || null, flyToDelta: window.__qaRadioFlyToCalls.length - beforeFlyToCalls, trackedIdentityPreserved: viewer.trackedEntity === owner, - trackedId: viewer.trackedEntity?.gevTrackedId || null, + trackedId: window.__godsEyeView.engine.camera.following()?.label?.id || null, }; viewer.trackedEntity = undefined; viewer.entities.remove(owner); @@ -3937,7 +3901,7 @@ async function main() { const viewer = window.__godsEyeView.viewer; const owner = viewer.entities.add({ id: 'qa:flights:delayed-radio-fallback-owner', - position: viewer.camera.positionWC.clone(), + position: window.__godsEyeView.engine.camera.pose().position, }); owner.gevTrackedId = 'flights:qa-delayed-radio-fallback-owner'; viewer.trackedEntity = owner; @@ -3952,7 +3916,7 @@ async function main() { const result = { flyToDelta: window.__qaRadioFlyToCalls.length - beforeFlyToCalls, trackedIdentityPreserved: viewer.trackedEntity === owner, - trackedId: viewer.trackedEntity?.gevTrackedId || null, + trackedId: window.__godsEyeView.engine.camera.following()?.label?.id || null, selectedId: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id || null, }; viewer.trackedEntity = undefined; @@ -3968,10 +3932,9 @@ async function main() { && Boolean(delayedFallbackOwnership.selectedId), JSON.stringify(delayedFallbackOwnership), ); - await page.evaluate(() => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + await evaluateWithGeo(page, async (qaGeo) => { + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: Math.PI, latitude: -2 * Math.PI / 180, height: window.__qaRadioCameraHeight, @@ -3992,21 +3955,20 @@ async function main() { && playing.host.entriesBySource?.radio === playing.overlay.entryCount, JSON.stringify({ overlay: playing.overlay, host: playing.host.entriesBySource }), ); - const closeSelectedText = await page.evaluate(async () => { - const viewer = window.__godsEyeView.viewer; + const closeSelectedText = await evaluateWithGeo(page, async (qaGeo) => { const radio = window.__godsEyeView.dataManager.layers.get('radio').module; const selected = radio.getUIState().selected; window.__qaRadioVisualView = { - position: viewer.camera.positionWC.clone(), - direction: viewer.camera.directionWC.clone(), - up: viewer.camera.upWC.clone(), + position: window.__godsEyeView.engine.camera.pose().position, + direction: window.__godsEyeView.engine.camera.pose().direction, + up: window.__godsEyeView.engine.camera.pose().up, }; - const destination = viewer.scene.globe.ellipsoid.cartographicToCartesian({ + const destination = qaGeo.cartographic.toCartesian({ longitude: selected.lon * Math.PI / 180, latitude: selected.lat * Math.PI / 180, height: 5_000, }); - viewer.camera.setView({ + window.__godsEyeView.engine.camera.setView({ destination, orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); @@ -4033,7 +3995,7 @@ async function main() { removePostRender?.(); resolve({ rect: null, host: getWorldOverlayDiagnostics() }); }, 150); - removePostRender = viewer.scene.postRender.addEventListener(() => { + removePostRender = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { clearTimeout(timeout); removePostRender(); const rect = getOverlayPaintRect('radio', expectedEntryId); @@ -4042,7 +4004,7 @@ async function main() { host: getWorldOverlayDiagnostics(), }); }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); shown = selectedShown(); } @@ -4051,7 +4013,7 @@ async function main() { rect: painted?.rect || null, host: painted?.host || getWorldOverlayDiagnostics(), selectedEntityShow: shown, - cameraHeight: viewer.camera.positionCartographic.height, + cameraHeight: window.__godsEyeView.engine.camera.pose().cartographic.height, }; }); check( @@ -4062,13 +4024,11 @@ async function main() { JSON.stringify(closeSelectedText), ); await page.screenshot({ path: path.join(SHOTS_DIR, 'selected-label-desktop.png') }); - const highGlobalSelectedText = await page.evaluate(async () => { - const viewer = window.__godsEyeView.viewer; + const highGlobalSelectedText = await evaluateWithGeo(page, async (qaGeo) => { const radio = window.__godsEyeView.dataManager.layers.get('radio').module; const selected = radio.getUIState().selected; - const ellipsoid = viewer.scene.globe.ellipsoid; - viewer.camera.setView({ - destination: ellipsoid.cartographicToCartesian({ + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: (selected.lon + 10) * Math.PI / 180, latitude: selected.lat * Math.PI / 180, height: 5_000_000, @@ -4086,7 +4046,7 @@ async function main() { removePostRender?.(); resolve(null); }, 150); - removePostRender = viewer.scene.postRender.addEventListener(() => { + removePostRender = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { clearTimeout(timeout); removePostRender(); const painted = getOverlayPaintRect('radio', expectedEntryId); @@ -4097,7 +4057,7 @@ async function main() { h: painted.h, } : null); }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); } // The selected point and its bracket are instances of the @@ -4129,14 +4089,14 @@ async function main() { entityAnchor: entityAnchor ? { x: entityAnchor.x, y: entityAnchor.y } : null, entityDistance: entityPosition ? Math.hypot( - viewer.camera.positionWC.x - entityPosition.x, - viewer.camera.positionWC.y - entityPosition.y, - viewer.camera.positionWC.z - entityPosition.z, + window.__godsEyeView.engine.camera.pose().position.x - entityPosition.x, + window.__godsEyeView.engine.camera.pose().position.y - entityPosition.y, + window.__godsEyeView.engine.camera.pose().position.z - entityPosition.z, ) : null, overlay: radio.getOverlayDiagnostics(), host: getWorldOverlayDiagnostics(), - cameraHeight: viewer.camera.positionCartographic.height, + cameraHeight: window.__godsEyeView.engine.camera.pose().cartographic.height, }; }); check( @@ -4164,7 +4124,7 @@ async function main() { const viewer = window.__godsEyeView.viewer; window.__godsEyeView.styleManager.toggleCleanView(true); viewer.resize(); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); }); await page.screenshot({ path: path.join(SHOTS_DIR, 'selected-label-mobile.png') }); @@ -4173,19 +4133,18 @@ async function main() { const viewer = window.__godsEyeView.viewer; window.__godsEyeView.styleManager.toggleCleanView(false); viewer.resize(); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); await page.evaluate(() => { - const viewer = window.__godsEyeView.viewer; const view = window.__qaRadioVisualView; - viewer.camera.setView({ + window.__godsEyeView.engine.camera.setView({ destination: view.position, orientation: { direction: view.direction, up: view.up }, }); delete window.__qaRadioVisualView; - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }); - await new Promise((resolve) => setTimeout(resolve, 300)); + await whenFramesDrawn(page); await page.evaluate(() => { window.__qaRadioFlyToCalls = []; @@ -4202,10 +4161,10 @@ async function main() { radioOnlyNextClicked, `clicked=${radioOnlyNextClicked}`, ); - const radioOnlyFocusCalls = await page.evaluate(() => { + const radioOnlyFocusCalls = await evaluateWithGeo(page, async (qaGeo) => { const calls = window.__qaRadioFlyToCalls.length; const heights = window.__qaRadioFlyToCalls.map((options) => ( - window.__godsEyeView.viewer.scene.globe.ellipsoid.cartesianToCartographic(options.destination).height + qaGeo.cartographic.fromCartesian(options.destination).height )); window.__godsEyeView.viewer.camera.flyTo = window.__qaRadioOriginalFlyTo; delete window.__qaRadioOriginalFlyTo; @@ -4218,19 +4177,19 @@ async function main() { JSON.stringify(radioOnlyFocusCalls), ); - const stagedGlobeNavigation = await page.evaluate(async () => { + const stagedGlobeNavigation = await evaluateWithGeo(page, async (qaGeo) => { const gev = window.__godsEyeView; const viewer = gev.viewer; const camera = viewer.camera; const radio = gev.dataManager.layers.get('radio').module; const stationIds = radio.getTunerStations(3).map((station) => station.id); if (stationIds.length < 3) throw new Error('Radio recenter QA requires three visible stations'); - const originalFlyTo = camera.flyTo; + const originalFlyTo = window.__godsEyeView.engine.camera.flyTo; const calls = []; camera.flyTo = (options) => calls.push(options); const setCamera = (height, pitchDegrees, headingDegrees = 38) => { - camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: -133.9483 * Math.PI / 180, latitude: 23.4741 * Math.PI / 180, height, @@ -4241,18 +4200,18 @@ async function main() { roll: 0, }, }); - viewer.scene.requestRender(); + window.__godsEyeView.engine.render.request(); }; const completeFlight = (index) => { const flight = calls[index]; - camera.setView({ + window.__godsEyeView.engine.camera.setView({ destination: flight.destination, orientation: flight.orientation, }); flight.complete?.(); }; const describe = (flight) => { - const cartographic = viewer.scene.globe.ellipsoid.cartesianToCartographic(flight.destination); + const cartographic = qaGeo.cartographic.fromCartesian(flight.destination); return { lat: cartographic.latitude * 180 / Math.PI, lon: cartographic.longitude * 180 / Math.PI, @@ -4279,7 +4238,7 @@ async function main() { radio.cycleStation(1, { rotate: true, autoplay: false, stationIds }); const owner = viewer.entities.add({ id: 'qa:flights:radio-recenter-stage-owner', - position: viewer.camera.positionWC.clone(), + position: window.__godsEyeView.engine.camera.pose().position, }); owner.gevTrackedId = 'flights:qa-radio-recenter-stage-owner'; viewer.trackedEntity = owner; @@ -4509,7 +4468,7 @@ async function main() { ); await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('radio-panel', false)); - await sleep(350); + await whenFramesDrawn(page); await page.screenshot({ path: path.join(SHOTS_DIR, 'desktop.png') }); const desktop = await page.$eval('#radio-panel', (element) => { const rect = element.getBoundingClientRect(); @@ -4531,7 +4490,7 @@ async function main() { await page.click('#radio-play-btn'); await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing'); await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 1 }); - await sleep(500); + await whenFramesDrawn(page); await page.$eval('#radio-panel', (element) => element.scrollIntoView({ block: 'nearest' })); await page.screenshot({ path: path.join(SHOTS_DIR, 'mobile.png') }); const mobile = await page.$eval('#radio-panel', (element) => { @@ -4599,7 +4558,7 @@ async function main() { failedResponses.slice(0, 3).join(' | '), ); } finally { - await browser.close(); + await closeBrowser(browser); } const failures = results.filter((result) => !result.ok).length; diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index 89d60d96f..3f3f3115c 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -1,4 +1,7 @@ #!/usr/bin/env node +// Cesium-only probes: native imagery stacks and split position verify the Cesium imagery adapter. +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenUiSettled, whenSettled } from './qa/ready.mjs'; /** * Rendered acceptance for DATA ▸ Recent Imagery against the real NASA * endpoints (CMR catalog, GIBS tiles, Worldview snapshots): the rail panel, @@ -17,9 +20,9 @@ * 4 s ZOOM IN wait and 0.8 s height-stability tail (defaults: 2 s / 1.2 s). * Every wait is bounded. Without NASA access the run skips with code 0. */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; import { chromeExecutablePath } from './qa-chrome.mjs'; const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; @@ -51,21 +54,14 @@ if (!(await networkAvailable())) { process.exit(0); } -const browser = await puppeteer.launch({ - headless: !HEADFUL, - ...(HEADFUL - ? { - executablePath: chromeExecutablePath(), - } - : {}), +const browser = await launchBrowser({ + headless: true, args: [ '--no-sandbox', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', ...(HEADFUL ? ['--window-size=1280,860'] : []), - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), ], }); const page = await browser.newPage(); @@ -200,7 +196,7 @@ const state = () => : null, splitLeft: split ? split.getBoundingClientRect().left : null, splitPosition: gev.viewer.scene.splitPosition, - canvasWidth: gev.viewer.scene.canvas.clientWidth, + canvasWidth: window.__godsEyeView.engine.host.canvas.clientWidth, activeStack: qa.activeStack(), gibsOnGlobe: hosts.globe, gibsOnTileset: hosts.tileset, @@ -212,25 +208,27 @@ const hostsDetail = (s) => /** Put the camera straight down over a point at a height, no flight. */ const setView = (view) => - page.evaluate((target) => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.cancelFlight?.(); - viewer.scene.tweens?.removeAll?.(); - viewer.camera.setView({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ - longitude: (target.lon * Math.PI) / 180, - latitude: (target.lat * Math.PI) / 180, - height: target.height, - }), - orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, - }); - viewer.scene.requestRender(); - }, view); + evaluateWithGeo( + page, + async (qaGeo, target) => { + window.__godsEyeView.engine.camera.cancelFlight?.(); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ + longitude: (target.lon * Math.PI) / 180, + latitude: (target.lat * Math.PI) / 180, + height: target.height, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + window.__godsEyeView.engine.render.request(); + }, + view, + ); /** Ground width of the camera's view rectangle, in km (0 without one). */ const viewWidthKm = () => page.evaluate(() => { - const rect = window.__godsEyeView.viewer.camera.computeViewRectangle?.(); + const rect = window.__godsEyeView.engine.camera.viewRectangle?.(); if (!rect) return 0; const lat = (rect.south + rect.north) / 2; return Math.abs(rect.east - rect.west) * 6371 * Math.cos(lat); @@ -279,8 +277,9 @@ async function probe() { const tilesSettled = () => until(() => { const gev = window.__godsEyeView; - if (gev.viewer.scene.globe.show) return gev.viewer.scene.globe.tilesLoaded; - return !gev.tileset || gev.tileset.tilesLoaded; + if (gev.viewer.scene.globe.show) + return window.__godsEyeView.engine.surface.tilesLoaded(); + return !gev.tileset || window.__godsEyeView.engine.surface.tilesLoaded(); }); const backOnPhotoreal = () => until(() => window.__riQa.activeStack() === 'photoreal'); @@ -334,7 +333,7 @@ const measure = () => let layoutBaseline = null; /** Every control's rect matches the baseline within 1 px. */ async function checkLayout(label) { - await wait(150); + await whenUiSettled(page); const now = await measure(); if (!layoutBaseline) { layoutBaseline = now; @@ -370,12 +369,13 @@ try { () => window.__gevRecentImagery && window.__godsEyeView && - document.getElementById('loading-screen')?.classList.contains('hidden'), + window.__godsEyeView.engine, null, 90_000, ); check('the app boots with the Recent Imagery handle', booted); if (!booted) throw new Error('boot timeout'); + await whenSettled(page); await page.evaluate(installPageHelpers); await setView(AUSTIN); @@ -586,10 +586,8 @@ try { changedColour = colour; break; } - await page.evaluate(() => - window.__godsEyeView.viewer.scene.requestRender(), - ); - await wait(1000); + await page.evaluate(() => window.__godsEyeView.engine.render.request()); + await whenUiSettled(page); } check( 'the rendered frame inside the box changes once the preview drapes', @@ -681,7 +679,7 @@ try { ); await checkLayout('pinned'); await page.keyboard.press(targetIndex > 0 ? 'ArrowLeft' : 'ArrowRight'); - await wait(700); + await whenUiSettled(page); current = await state(); check( 'with the image pinned, focus moves without changing the map', @@ -981,7 +979,7 @@ try { let refusalWidth = 0; for (const height of [400_000, 900_000, 1_800_000]) { await setView({ ...AUSTIN, height }); - await wait(300); + await whenUiSettled(page); refusalWidth = await viewWidthKm(); if (refusalWidth > 1_100) break; } @@ -1041,10 +1039,9 @@ try { await panelShot('05-refused-panel'); // ZOOM IN: straight down to the height whose view is 400 km wide. const fitHeight = await page.evaluate(() => { - const viewer = window.__godsEyeView.viewer; - const canvas = viewer.scene.canvas; + const canvas = window.__godsEyeView.engine.host.canvas; const halfWidth = - Math.tan(viewer.camera.frustum.fovy / 2) * + Math.tan(window.__godsEyeView.engine.camera.pose().fovy / 2) * (canvas.clientWidth / canvas.clientHeight); return Math.min(400, Math.max(5, 400 / (2 * halfWidth))) * 1000; }); @@ -1052,14 +1049,14 @@ try { const zoomed = await until( (target) => Math.abs( - window.__godsEyeView.viewer.camera.positionCartographic.height - target, + window.__godsEyeView.engine.camera.pose().cartographic.height - target, ) <= target * 0.01, fitHeight, ZOOM_TIMEOUT_MS, ); const zoomedHeight = await page.evaluate( - () => window.__godsEyeView.viewer.camera.positionCartographic.height, + () => window.__godsEyeView.engine.camera.pose().cartographic.height, ); check( `ZOOM IN reaches target within ${ZOOM_TIMEOUT_MS / 1_000} s`, @@ -1235,7 +1232,7 @@ try { }, id); const imageryPanel = 'recent-imagery-panel'; const collapsedNow = (await togglePanel(imageryPanel)) === false; - await wait(400); + await whenUiSettled(page); const headerOnly = await page.evaluate(() => ({ panel: document.getElementById('recent-imagery-panel')?.offsetHeight, body: document.getElementById('recent-imagery-panel-body')?.offsetHeight, @@ -1249,7 +1246,7 @@ try { JSON.stringify(headerOnly), ); const expandedAgain = await togglePanel(imageryPanel); - await wait(400); + await whenUiSettled(page); const bodyBack = await page.evaluate( () => document.getElementById('recent-imagery-panel-body')?.offsetHeight, ); @@ -1270,7 +1267,7 @@ try { cctv: document.getElementById('cctv-panel')?.offsetHeight, })), ); - await wait(100); + await whenUiSettled(page); } const tail = (key) => new Set(samples.slice(-HEIGHT_TAIL_SAMPLES).map((s) => s[key])); @@ -1315,7 +1312,7 @@ try { for (const height of [900, 700]) { const at = `(${height} px viewport)`; await page.setViewport({ width: 1280, height }); - await wait(600); + await whenUiSettled(page); check( `the CCTV panel expands from its launcher ${at}`, (await togglePanel('cctv-panel')) === true, @@ -1372,7 +1369,7 @@ try { ?.classList.contains('collapsed'), ); if (imageryExpanded) await togglePanel(imageryPanel); - await wait(400); + await whenUiSettled(page); const onScreen = await page.evaluate(() => Object.fromEntries( [ @@ -1389,7 +1386,7 @@ try { JSON.stringify(onScreen), ); await togglePanel(imageryPanel); - await wait(400); + await whenUiSettled(page); } // ── scroll preservation (700 px viewport, body scrollable) ─────────────── @@ -1397,7 +1394,7 @@ try { const header = document.querySelector('#ri-details .rail-card-header'); if (header?.getAttribute('aria-expanded') !== 'true') header?.click(); }); - await wait(300); + await whenUiSettled(page); const bodyScroll = () => page.evaluate(() => { const body = document.getElementById('recent-imagery-panel-body'); @@ -1424,7 +1421,7 @@ try { layer.setToolActive(true); layer.setToolActive(false); }); - await wait(300); + await whenUiSettled(page); const afterNotify = await bodyScroll(); check( 'the body scroll position survives alpha and state notifications', @@ -1434,7 +1431,7 @@ try { await focusStrip(); await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowRight'); - await wait(600); + await whenUiSettled(page); const afterArrows = await bodyScroll(); check( 'the body scroll position survives arrow navigation', @@ -1488,7 +1485,7 @@ try { error?.stack || error?.message || String(error), ); } finally { - await browser.close(); + await closeBrowser(browser); } console.log(`shots: ${shots.join(', ')}`); console.log(`RESULT: ${checks} checks, ${failures} failures`); diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs index bda4a407c..5ab995f7a 100644 --- a/scripts/qa-scene-controls.mjs +++ b/scripts/qa-scene-controls.mjs @@ -1,18 +1,17 @@ #!/usr/bin/env node /** Exercise installed Scene controls and real project/playback operations. */ +import { whenSettled, whenUiSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; const shots = path.resolve('qa-shots/scene-controls'); fs.mkdirSync(shots, { recursive: true }); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: [ '--no-sandbox', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), ], }); const page = await browser.newPage(); @@ -34,11 +33,10 @@ try { { waitUntil: 'domcontentloaded' }, ); await page.waitForFunction( - () => - window.__godsEyeView?.sceneDirector?._controls && - document.getElementById('loading-screen')?.classList.contains('hidden'), + () => window.__godsEyeView?.sceneDirector?._controls, { timeout: 60000 }, ); + await whenSettled(page); await page.evaluate(() => { window.__qaSceneState = []; window.__godsEyeView.sceneDirector.subscribe((notification) => @@ -294,6 +292,7 @@ try { ); await page.click('#scene-start-btn'); await page.waitForFunction(() => window.__godsEyeView.sceneDirector.running); + await whenSettled(page); await page.$eval('#scene-stop-btn', (button) => button.click()); await page.waitForFunction(() => !window.__godsEyeView.sceneDirector.running); check( @@ -364,7 +363,7 @@ try { await page.setViewport({ width: 390, height: 844 }); // Phones present Scenes in the Layers tab's sheet. await page.click('#mobile-tab-bar [data-mobile-sheet="layers"]'); - await new Promise((resolve) => setTimeout(resolve, 400)); + await whenUiSettled(page); await page.screenshot({ path: path.join(shots, 'narrow.png') }); check( 'Scene controls stay inside the narrow viewport', @@ -483,6 +482,6 @@ try { errors.length === 0, ); } finally { - await browser.close(); + await closeBrowser(browser); } if (failures) process.exitCode = 1; diff --git a/scripts/qa-scene-dialog-file-drop.mjs b/scripts/qa-scene-dialog-file-drop.mjs index d6806ff72..ec6af59f5 100644 --- a/scripts/qa-scene-dialog-file-drop.mjs +++ b/scripts/qa-scene-dialog-file-drop.mjs @@ -14,13 +14,13 @@ * Needs the repository's puppeteer (devDependencies). Exit status 0 when the * drop selects the file, 1 otherwise. */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import http from 'node:http'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import vue from '@vitejs/plugin-vue'; -import puppeteer from 'puppeteer'; import { createServer } from 'vite'; import { VUE_PLUGIN_OPTIONS } from '../build/vueOptions.js'; @@ -77,7 +77,7 @@ async function main() { }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const url = `http://127.0.0.1:${server.address().port}/`; - const browser = await puppeteer.launch({ headless: true }); + const browser = await launchBrowser({ headless: true }); try { const page = await browser.newPage(); await page.setViewport({ width: 1000, height: 800 }); @@ -100,7 +100,7 @@ async function main() { } console.log('OK: a file dropped on the visible control is selected'); } finally { - await browser.close(); + await closeBrowser(browser); server.close(); await vite.close(); await rm(dir, { recursive: true, force: true }); diff --git a/scripts/qa-scope-hud.mjs b/scripts/qa-scope-hud.mjs index 1607b2631..183dad60d 100644 --- a/scripts/qa-scope-hud.mjs +++ b/scripts/qa-scope-hud.mjs @@ -7,17 +7,16 @@ * feather 0/11/100/200, layout independence, * share/scene restore, and visible text backing including rotated metadata. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import puppeteer from 'puppeteer'; -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: [ '--no-sandbox', - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), ], }); const page = await browser.newPage(); @@ -214,16 +213,7 @@ async function openApplication(url) { () => window.__godsEyeView?.styleManager?._applicationShortcuts, { timeout: 60_000 }, ); - await page.waitForFunction( - () => { - const cover = document.getElementById('loading-screen'); - return ( - cover?.classList.contains('hidden') && - Number(getComputedStyle(cover).opacity) === 0 - ); - }, - { timeout: 60_000 }, - ); + await whenSettled(page); } try { @@ -668,7 +658,7 @@ try { } check('runtime has no uncaught browser errors', errors.length === 0, errors); } finally { - await browser.close(); + await closeBrowser(browser); } console.log(`RESULT: ${failures} failures`); process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-segmented-controls.mjs b/scripts/qa-segmented-controls.mjs index d60b574fd..0398c942c 100644 --- a/scripts/qa-segmented-controls.mjs +++ b/scripts/qa-segmented-controls.mjs @@ -1,13 +1,12 @@ #!/usr/bin/env node /** Run against a dev server or built preview with QA_BASE_URL (default :4179). */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; -import puppeteer from 'puppeteer'; -const browser = await puppeteer.launch({ - executablePath: - process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()), +const browser = await launchBrowser({ headless: true, - args: ['--use-gl=angle', '--use-angle=swiftshader'], + args: [], }); const page = await browser.newPage(); const errors = []; @@ -30,11 +29,7 @@ try { () => window.__godsEyeView?.styleManager?._applicationShortcuts, { timeout: 60000 }, ); - await page.waitForFunction( - () => - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 60000 }, - ); + await whenSettled(page); // DISPLAY holds most segmented controls; Draw's shape group is in UTILITIES. await page.evaluate(() => { for (const panel of ['pp-toggles', 'utilities-panel']) @@ -347,7 +342,7 @@ try { await page.screenshot({ path: `${shots}/failure.png` }).catch(() => {}); throw error; } finally { - await browser.close(); + await closeBrowser(browser); } console.log(`RESULT: ${failures.length} failures`); process.exitCode = failures.length ? 1 : 0; diff --git a/scripts/qa-sky-model-lighting.mjs b/scripts/qa-sky-model-lighting.mjs index 69f727297..24dfdc61f 100644 --- a/scripts/qa-sky-model-lighting.mjs +++ b/scripts/qa-sky-model-lighting.mjs @@ -1,17 +1,18 @@ #!/usr/bin/env node +// Cesium-only probes: standalone native scenes validate Cesium model lighting. /** * Lit airplane regression: StarrySky must match a black environment baseline. * A white environment is a positive control: it must visibly brighten the * same model. Each case uses a fresh scene and dynamic environment map. * Usage: node scripts/qa-sky-model-lighting.mjs [--url http://localhost:4333/] */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; -import puppeteer from 'puppeteer'; const i = process.argv.indexOf('--url'); const base = i > 0 ? process.argv[i + 1] : 'http://localhost:4333/'; const url = new URL('/__qa_sky_model__', base); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, args: ['--no-sandbox', '--ignore-gpu-blocklist'], }); @@ -145,5 +146,5 @@ try { 'PASS: lit airplane matches black sky; white control is brighter', ); } finally { - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-status-badges.mjs b/scripts/qa-status-badges.mjs index 1d3b7347b..5746e15c9 100644 --- a/scripts/qa-status-badges.mjs +++ b/scripts/qa-status-badges.mjs @@ -1,11 +1,12 @@ #!/usr/bin/env node /** Badge acceptance on dev/preview. Camera state is a disposable controller fixture. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; -import puppeteer from 'puppeteer'; -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, - args: ['--use-gl=angle', '--use-angle=swiftshader'], + args: [], }); const page = await browser.newPage(); page.setDefaultNavigationTimeout(60000); @@ -31,11 +32,10 @@ try { { waitUntil: 'domcontentloaded' }, ); await page.waitForFunction( - () => - window.__godsEyeView?.styleManager?._cctvControls && - document.getElementById('loading-screen')?.classList.contains('hidden'), + () => window.__godsEyeView?.styleManager?._cctvControls, { timeout: 60000 }, ); + await whenSettled(page); await page.evaluate(async () => { const { dataManager, styleManager: manager } = window.__godsEyeView; await dataManager.setEnabled('wind', true); @@ -272,7 +272,7 @@ try { } check('no uncaught browser errors', errors.length === 0, errors); } finally { - await browser.close(); + await closeBrowser(browser); } console.log(`${checks} checks; ${failures} failures`); process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index a1b48fb92..91c366ddd 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -1,4 +1,7 @@ #!/usr/bin/env node +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import { blockPaidQaMaps } from './qa-map-policy.mjs'; /** * Browser QA for the Street Level layer against a running server: @@ -223,6 +226,7 @@ async function boot(page, url) { await page.waitForFunction(() => Boolean(window.__godsEyeView?.dataManager), { timeout: 150_000, }); + await whenSettled(page); await page.evaluate(() => document.querySelector('.first-run-explore')?.click(), ); @@ -338,8 +342,9 @@ async function assertKeyRequired(page) { const findPick = (page, prefix, { box = null, step = 3 } = {}) => page.evaluate( (want, area, stride) => { - const { scene } = window.__godsEyeView.viewer; - const rect = scene.canvas.getBoundingClientRect(); + const { engine } = window.__godsEyeView; + const rect = + window.__godsEyeView.engine.host.canvas.getBoundingClientRect(); const { x, y, w, h } = area || { x: 0, y: Math.round(rect.height / 2), @@ -348,7 +353,7 @@ const findPick = (page, prefix, { box = null, step = 3 } = {}) => }; for (let py = y; py < y + h; py += stride) for (let px = x; px < x + w; px += stride) { - const id = scene.pick({ x: px, y: py })?.id; + const id = engine.picker.pick({ x: px, y: py })?.id; if (typeof id === 'string' && id.startsWith(want)) return { x: rect.left + px, @@ -378,30 +383,33 @@ async function findPickUntil( for (;;) { const hit = await findPick(page, prefix, { box, step }); if (hit || Date.now() >= deadline) return hit; - await new Promise((resolve) => setTimeout(resolve, 500)); + await whenSettled(page); } } /** Park the camera at `at`; a late startup flight can move it. */ async function park(page, at = PARK) { for (let attempt = 0; attempt < 5; attempt++) { - const stayed = await page.evaluate(async (at) => { - const v = window.__godsEyeView.viewer; - v.camera.cancelFlight?.(); - const C = v.camera.positionCartographic.constructor; - v.camera.setView({ - destination: v.scene.globe.ellipsoid.cartographicToCartesian( - C.fromDegrees(at.lon, at.lat, at.height), - ), - orientation: { heading: 0, pitch: at.pitch, roll: 0 }, - }); - await new Promise((resolve) => setTimeout(resolve, 1500)); - const c = v.camera.positionCartographic; - return ( - Math.abs((c.longitude * 180) / Math.PI - at.lon) < 0.001 && - Math.abs((c.latitude * 180) / Math.PI - at.lat) < 0.001 - ); - }, at); + const stayed = await evaluateWithGeo( + page, + async (qaGeo, at) => { + window.__godsEyeView.engine.camera.cancelFlight?.(); + const C = qaGeo.cartographic; + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian( + C.fromDegrees(at.lon, at.lat, at.height), + ), + orientation: { heading: 0, pitch: at.pitch, roll: 0 }, + }); + await new Promise((resolve) => setTimeout(resolve, 1500)); + const c = window.__godsEyeView.engine.camera.pose().cartographic; + return ( + Math.abs((c.longitude * 180) / Math.PI - at.lon) < 0.001 && + Math.abs((c.latitude * 180) / Math.PI - at.lat) < 0.001 + ); + }, + at, + ); if (stayed) return; } throw new Error(`the camera would not stay parked at ${JSON.stringify(at)}`); @@ -414,14 +422,11 @@ async function main() { const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173'; const fixtures = args.includes('--fixtures'); const strict = args.includes('--strict'); - const browser = await puppeteer.launch({ + const browser = await launchBrowser({ headless: true, - executablePath: - process.env.PUPPETEER_EXECUTABLE_PATH || - (await puppeteer.executablePath()), defaultViewport: VIEWPORT, protocolTimeout: 300_000, - args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'], + args: ['--no-sandbox'], }); let count = 0; const skips = []; @@ -494,7 +499,7 @@ async function main() { box === 'column' ? await page.evaluate(() => { const { height, width } = - window.__godsEyeView.viewer.scene.canvas.getBoundingClientRect(); + window.__godsEyeView.engine.host.canvas.getBoundingClientRect(); return { x: Math.round(width * 0.3), y: 0, w: 1, h: height }; }) : null; @@ -560,7 +565,7 @@ async function main() { 'keyless server: the MAPILLARY chip explains its key instead of switching', async () => { await press(page, '#sl-provider-chips [data-chip-id="mapillary"]'); - await new Promise((resolve) => setTimeout(resolve, 500)); + await whenSettled(page); assert.ok(litAlone(await getUI(page), 'kartaview')); }, ); @@ -831,7 +836,7 @@ async function main() { if (dir) console.error(`failure evidence saved in ${dir}/`); throw error; } finally { - await browser.close(); + await closeBrowser(browser); } } diff --git a/scripts/qa-terrain-429.mjs b/scripts/qa-terrain-429.mjs index 8164e34b5..b3e27f0a0 100644 --- a/scripts/qa-terrain-429.mjs +++ b/scripts/qa-terrain-429.mjs @@ -1,4 +1,7 @@ #!/usr/bin/env node +// Cesium-only probes: native terrain-tree counters verify Cesium retry behavior. +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled, until } from './qa/ready.mjs'; /** * Keyless terrain throttling probe. * @@ -21,8 +24,8 @@ * Exits 1 when a throttled tile never recovered or a terrain tile is left in * Cesium's FAILED state. */ +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; -import puppeteer from 'puppeteer'; const appUrl = process.env.QA_BASE_URL || 'http://localhost:4173'; const argv = process.argv.slice(2); @@ -46,20 +49,10 @@ const headful = argv.includes('--headful'); const TERRAIN_HOST = 'terrain.reearth.land'; const TILE_PATTERN = /\/cesium-mesh\/ellipsoid\/\d+\/\d+\/\d+\.terrain/; -const executablePath = - process.env.PUPPETEER_EXECUTABLE_PATH || - (await puppeteer.executablePath().catch(() => null)); -if (!executablePath || !fs.existsSync(executablePath)) { - throw new Error('Puppeteer Chrome for Testing is unavailable'); -} - -const browser = await puppeteer.launch({ - headless: headful ? false : 'new', - executablePath, +const browser = await launchBrowser({ + headless: true, args: [ - ...(process.platform === 'darwin' - ? ['--use-angle=metal', '--enable-gpu'] - : ['--use-gl=angle', '--use-angle=swiftshader']), + ...(process.platform === 'darwin' ? ['--enable-gpu'] : []), '--no-sandbox', ], }); @@ -149,31 +142,17 @@ page.on('console', (message) => { }); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const waitFor = async (predicate, timeoutMs, label) => { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await predicate()) return; - await sleep(250); - } - throw new Error(`timed out waiting for ${label}`); -}; const setCamera = (lon, lat, height) => - page.evaluate( - ({ lon, lat, height }) => { - const viewer = window.__godsEyeView.viewer; + evaluateWithGeo( + page, + async (qaGeo, { lon, lat, height }) => { // The app's own startup flight would otherwise keep steering the camera // through the burst and turn the zoom-out into a zoom-in. - viewer.camera.cancelFlight(); - const cartographic = - viewer.camera.positionCartographic.constructor.fromDegrees( - lon, - lat, - height, - ); - viewer.camera.setView({ - destination: - viewer.scene.globe.ellipsoid.cartographicToCartesian(cartographic), + window.__godsEyeView.engine.camera.cancelFlight(); + const cartographic = qaGeo.cartographic.fromDegrees(lon, lat, height); + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian(cartographic), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); window.__godsEyeView.requestRender?.(); @@ -183,7 +162,7 @@ const setCamera = (lon, lat, height) => const cameraHeight = () => page.evaluate( - () => window.__godsEyeView.viewer.camera.positionCartographic.height, + () => window.__godsEyeView.engine.camera.pose().cartographic.height, ); const globeState = () => @@ -203,7 +182,7 @@ const globeState = () => return { failed, loaded, - tilesLoaded: viewer.scene.globe.tilesLoaded, + tilesLoaded: window.__godsEyeView.engine.surface.tilesLoaded(), provider: viewer.terrainProvider?.constructor?.name, }; }); @@ -212,8 +191,9 @@ const burst = async () => { if (mode === 'zoomOut') { for (let i = 0; i < notches; i += 1) { await page.evaluate(() => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.zoomOut(viewer.camera.positionCartographic.height * 0.6); + window.__godsEyeView.engine.camera.zoomOut( + window.__godsEyeView.engine.camera.pose().cartographic.height * 0.6, + ); window.__godsEyeView.requestRender?.(); }); await sleep(40); @@ -230,14 +210,22 @@ const burst = async () => { try { await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }); - await page.waitForFunction(() => Boolean(window.__godsEyeView?.viewer), { + await page.waitForFunction(() => Boolean(window.__godsEyeView?.engine), { timeout: 60000, }); - await waitFor(() => layerJsonSeen, 60000, 'the Re:Earth layer.json fetch'); - await waitFor( + await whenSettled(page); + await until(() => layerJsonSeen, { + timeoutMs: 60000, + intervalMs: 250, + label: 'the Re:Earth layer.json fetch', + }); + await until( async () => (await globeState()).provider === 'CesiumTerrainProvider', - 30000, - 'the keyless terrain provider', + { + timeoutMs: 30000, + intervalMs: 250, + label: 'the keyless terrain provider', + }, ); const summaries = []; @@ -246,16 +234,16 @@ try { // overwrite the first setView on the very next frame. for (let attempt = 0; attempt < 5; attempt += 1) { await setCamera(-97.7431, 30.2672, 40000); - await sleep(1500); + await whenSettled(page); const height = await cameraHeight(); if (Math.abs(height - 40000) < 1000) break; } - await waitFor( - async () => (await globeState()).tilesLoaded, - 30000, - 'the 40 km view to settle', - ).catch(() => {}); - await sleep(2000); + await until(async () => (await globeState()).tilesLoaded, { + timeoutMs: 30000, + intervalMs: 250, + label: 'the 40 km view to settle', + }).catch(() => {}); + await whenSettled(page); const settledHeight = await cameraHeight(); if (Math.abs(settledHeight - 40000) > 1000) { throw new Error( @@ -358,5 +346,5 @@ try { ); process.exitCode = failures.length ? 1 : 0; } finally { - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-traffic-baseline.mjs b/scripts/qa-traffic-baseline.mjs index 7059bf438..3f8311bae 100644 --- a/scripts/qa-traffic-baseline.mjs +++ b/scripts/qa-traffic-baseline.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled, whenFramesDrawn } from './qa/ready.mjs'; /** * qa-traffic-baseline.mjs — Traffic Phase 0 causal-chain capture. * @@ -35,7 +37,7 @@ * the latency plan. */ -import puppeteer from 'puppeteer'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; const argv = process.argv.slice(2); @@ -53,20 +55,7 @@ const HEADFUL = argv.includes('--headful'); const TIMEOUT_MS = Math.max(10_000, getNumberOpt('--timeout-ms', 120_000)); const DEGRADED_DELAY_MS = Math.max(0, getNumberOpt('--degraded-delay-ms', 2500)); -const CHROME_EXECUTABLE_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -function findChromeExecutable() { - for (const candidate of CHROME_EXECUTABLE_CANDIDATES) { - try { if (fs.existsSync(candidate)) return candidate; } catch { /* ignore */ } - } - return null; -} const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -95,16 +84,15 @@ function debugUrl(rawUrl) { /** Position the camera and explicitly raise the boundaries the layer observes. */ async function moveCamera(page, view) { - return page.evaluate((v) => { + return evaluateWithGeo(page, async (qaGeo, v) => { const gev = window.__godsEyeView; const module = gev.dataManager.layers.get('traffic').module; const beforeLastUpdate = module.getStats().lastUpdate; const boundaryTime = performance.now(); - const ellipsoid = gev.viewer.scene.globe.ellipsoid; const radians = Math.PI / 180; - try { gev.viewer.camera.cancelFlight(); } catch { /* no active flight */ } - gev.viewer.camera.setView({ - destination: ellipsoid.cartographicToCartesian({ + try { window.__godsEyeView.engine.camera.cancelFlight(); } catch { /* no active flight */ } + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: v.lon * radians, latitude: v.lat * radians, height: v.height, @@ -119,8 +107,7 @@ async function moveCamera(page, view) { // explicitly makes the capture boundary deterministic across GPU speeds. // Pairing still flows through camera.changed's schedule-time anchor; the // synthetic moveEnd is emitted only as the same diagnostic used naturally. - gev.viewer.camera.changed.raiseEvent(); - gev.viewer.camera.moveEnd.raiseEvent(); + window.__godsEyeView.engine.camera.moveEnd.raiseEvent(); return { beforeLastUpdate, boundaryTime }; }, view); } @@ -144,7 +131,7 @@ async function waitForTrafficCapture(page, beforeLastUpdate, boundaryTime) { boundaryTime, ); // Let a final full-pass postRender land after `_fetching` flips false. - await sleep(150); + await whenFramesDrawn(page); } /** Return serializable Traffic User Timing measures created after a boundary. */ @@ -272,7 +259,7 @@ function rowsForCapture(capture) { /** Read the active WebGL renderer for the real-GPU/SwiftShader warning. */ function readRenderer(page) { return page.evaluate(() => { - const gl = window.__godsEyeView?.viewer?.scene?.context?._gl; + const gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2'); if (!gl) return 'unknown'; const extension = gl.getExtension('WEBGL_debug_renderer_info'); return extension @@ -285,7 +272,7 @@ async function main() { const url = debugUrl(APP_URL); console.log('\nTraffic Phase 0 — causal-chain baseline capture'); console.log(` App URL : ${url}`); - console.log(` Browser mode : ${HEADFUL ? 'headful (real GPU expected)' : 'headless SwiftShader (relative-only)'}`); + console.log(` Browser mode : headless (QA_ANGLE=${process.env.QA_ANGLE || 'metal'})`); console.log(` Degraded delay : ${DEGRADED_DELAY_MS ? `${DEGRADED_DELAY_MS} ms/request` : 'skipped'}`); try { @@ -296,19 +283,9 @@ async function main() { process.exit(2); } - const executablePath = findChromeExecutable(); - const browser = await puppeteer.launch({ - headless: HEADFUL ? false : 'new', - ...(executablePath ? { executablePath } : {}), - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--window-size=1440,900', - ...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']), - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1440,900', ...(HEADFUL ? [] : [])], }); let activeProxyDelayMs = 0; @@ -343,19 +320,19 @@ async function main() { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await page.waitForFunction( - () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + () => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager, { timeout: 60_000 }, ); + await whenSettled(page); // Disable all persisted overlays, park above traffic's activation ceiling, // then enable traffic without accidentally starting an unmeasured load. - await page.evaluate(async (highView) => { + await evaluateWithGeo(page, async (qaGeo, highView) => { const gev = window.__godsEyeView; await gev.dataManager.restoreEnabledLayerIds([]); - const ellipsoid = gev.viewer.scene.globe.ellipsoid; const radians = Math.PI / 180; - gev.viewer.camera.setView({ - destination: ellipsoid.cartographicToCartesian({ + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: highView.lon * radians, latitude: highView.lat * radians, height: 9000, @@ -363,10 +340,9 @@ async function main() { orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); await gev.dataManager.setEnabled('traffic', true); - gev.viewer.camera.changed.raiseEvent(); - gev.viewer.camera.moveEnd.raiseEvent(); + window.__godsEyeView.engine.camera.moveEnd.raiseEvent(); }, VIEWS.austin); - await sleep(500); + await whenFramesDrawn(page); const renderer = await readRenderer(page); const softwareRenderer = /swiftshader|software/i.test(renderer); @@ -386,22 +362,19 @@ async function main() { // Clear the last-bounds gate without clearing the module's road cache, then // revisit the byte-identical camera pose for a true client-cache hit. - await page.evaluate((view) => { - const gev = window.__godsEyeView; - const ellipsoid = gev.viewer.scene.globe.ellipsoid; + await evaluateWithGeo(page, async (qaGeo, view) => { const radians = Math.PI / 180; - gev.viewer.camera.setView({ - destination: ellipsoid.cartographicToCartesian({ + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: view.lon * radians, latitude: view.lat * radians, height: 9000, }), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); - gev.viewer.camera.changed.raiseEvent(); - gev.viewer.camera.moveEnd.raiseEvent(); + window.__godsEyeView.engine.camera.moveEnd.raiseEvent(); }, VIEWS.austin); - await sleep(500); + await whenFramesDrawn(page); captures.push(await captureState(page, 'exact-viewport revisit', VIEWS.austin, (msValue) => { activeProxyDelayMs = msValue; @@ -467,7 +440,7 @@ async function main() { for (const error of consoleErrors.slice(0, 5)) console.log(` ${error}`); } } finally { - await browser.close(); + await closeBrowser(browser); } } diff --git a/scripts/qa-traffic-jamviz-ab.mjs b/scripts/qa-traffic-jamviz-ab.mjs index b236ab0b1..c93c42b36 100644 --- a/scripts/qa-traffic-jamviz-ab.mjs +++ b/scripts/qa-traffic-jamviz-ab.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { evaluateWithGeo } from './qa/geometry.mjs'; +import { whenSettled, whenTilesLoaded, whenFramesDrawn } from './qa/ready.mjs'; /** * qa-traffic-jamviz-ab.mjs — A/B screenshot capture for the jam-viz * congestion prototypes (feat/traffic-jam-viz). @@ -18,7 +20,7 @@ * Exits non-zero on harness failure (missing live mode, zero dots, etc.). */ -import puppeteer from 'puppeteer'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -53,50 +55,18 @@ const VIEWS = [ ]; const MODES = ['none', 'density', 'heatline', 'both']; -const CHROME_EXECUTABLE_CANDIDATES = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - // Prefer puppeteer's version-pinned Chrome-for-Testing over the system - // Chrome: /Applications auto-updates underneath the harnesses, and its - // software-GL behavior shifts across majors (system Chrome 150 blew the - // tile-gated drain budget under SwiftShader on 2026-07-30 — six - // false-negative qa-cctv-v2 runs against a healthy build). A deterministic - // pinned browser beats the newest one for regression harnesses. - await puppeteer.executablePath().catch(() => null), - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Chromium.app/Contents/MacOS/Chromium', -].filter(Boolean); -const findChromeExecutable = () => - CHROME_EXECUTABLE_CANDIDATES.find((c) => { try { return fs.existsSync(c); } catch { return false; } }) || null; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** Same pattern as qa-cctv-v2: wait for the 3D tileset to settle for crisp shots. */ -function waitForTilesLoaded(page, timeoutMs) { - return page.waitForFunction( - () => { - const prims = window.__godsEyeView.viewer.scene.primitives; - for (let i = 0; i < prims.length; i++) { - const p = prims.get(i); - if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') { - return p.tilesLoaded === true; - } - } - return true; - }, - { timeout: timeoutMs } - ).then(() => true).catch(() => false); -} /** Teleport to a view (cancelling any camera flight first). */ function setView(page, v) { - return page.evaluate((view) => { - const gev = window.__godsEyeView; - try { gev.viewer.camera.cancelFlight(); } catch { /* no flight */ } - const ell = gev.viewer.scene.globe.ellipsoid; + return evaluateWithGeo(page, async (qaGeo, view) => { + try { window.__godsEyeView.engine.camera.cancelFlight(); } catch { /* no flight */ } const d2r = Math.PI / 180; - gev.viewer.camera.setView({ - destination: ell.cartographicToCartesian({ + window.__godsEyeView.engine.camera.setView({ + destination: qaGeo.cartographic.toCartesian({ longitude: view.lon * d2r, latitude: view.lat * d2r, height: view.height, }), orientation: { @@ -155,15 +125,9 @@ async function main() { } fs.mkdirSync(OUT_DIR, { recursive: true }); - const browser = await puppeteer.launch({ - headless: 'new', - ...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}), - args: [ - '--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader', - '--disable-dev-shm-usage', '--disable-web-security', - '--disable-background-timer-throttling', '--disable-renderer-backgrounding', - '--window-size=1600,900', - ], + const browser = await launchBrowser({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-web-security', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1600,900'], }); const manifest = []; @@ -174,11 +138,12 @@ async function main() { console.log('Loading app...'); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForFunction( - () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + () => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager, { timeout: 60000 }, ); + await whenSettled(page); await page.evaluate(() => window.__godsEyeView.dataManager.setEnabled('traffic', true)); - await sleep(1500); + await whenFramesDrawn(page); let firstView = true; for (const view of VIEWS) { @@ -203,7 +168,7 @@ async function main() { ); await setView(page, view); const stats = await waitForFreshRender(page, since); - await waitForTilesLoaded(page, firstView ? 30000 : 15000); + await whenTilesLoaded(page, { timeoutMs: firstView ? 30000 : 15000 }).then(() => true).catch(() => false); await sleep(1400); // flow-recolor race + heat-line pulse mid-bright const final = await page.evaluate(() => { @@ -249,7 +214,7 @@ async function main() { exitCode = 3; } finally { fs.writeFileSync(path.join(OUT_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2)); - await browser.close(); + await closeBrowser(browser); } console.log(`\nShots + manifest.json → ${OUT_DIR}`); process.exit(exitCode); diff --git a/scripts/qa-traffic-motion.mjs b/scripts/qa-traffic-motion.mjs index 6973d0d99..cbbb14811 100644 --- a/scripts/qa-traffic-motion.mjs +++ b/scripts/qa-traffic-motion.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +// Cesium-only probes: native primitive inspection independently measures the Cesium road paint. +import { evaluateWithGeo } from './qa/geometry.mjs'; /** * Traffic motion acceptance: real-GPU Puppeteer, stable identities, stationary * camera holds and timestamped CDP JPEGs encoded with ffmpeg. No tile mocks. @@ -9,10 +11,11 @@ * --journey follows a neighborhood-to-city route with layer toggles, then * checks Capitol/greenbelt/parking/corridor views against a 1.5 s limit. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; -import puppeteer from 'puppeteer'; const args = process.argv.slice(2); if (args.includes('--help')) { console.log( @@ -37,11 +40,10 @@ fs.mkdirSync(root, { recursive: true }); const revision = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8', }).trim(); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, protocolTimeout: 300000, args: [ - '--use-angle=metal', '--window-size=1280,800', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', @@ -84,12 +86,10 @@ try { u.searchParams.set('trafficRoads', mode); u.searchParams.set('trafficDebug', '1'); await page.goto(u.href, { waitUntil: 'domcontentloaded', timeout: 60000 }); - await page.waitForFunction( - () => - window.__godsEyeView?.dataManager && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 120000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.dataManager, { + timeout: 120000, + }); + await whenSettled(page); await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, ); @@ -99,7 +99,6 @@ try { g.styleManager.setDetection({ enabled: false }); for (const [id] of g.dataManager.layers) g.dataManager.setEnabled(id, false); - g.viewer.scene.screenSpaceCameraController.enableCollisionDetection = false; }); const identity = await page.evaluate(() => ({ title: document.title, @@ -159,10 +158,10 @@ try { clock.style.cssText = 'position:fixed;bottom:4px;left:38%;padding:3px 8px;background:#000c;color:#fff;font:12px monospace;z-index:999999;pointer-events:none'; document.body.append(clock); - viewer.camera.moveEnd.addEventListener(() => + window.__godsEyeView.engine.camera.moveEnd.addEventListener(() => window.__motion.moveEnds.push(performance.now()), ); - scene.postRender.addEventListener(() => { + window.__godsEyeView.engine.frames.postRender.addEventListener(() => { if (!window.__motion.active) return; discoverCollections(); const now = performance.now(), @@ -174,7 +173,9 @@ try { : ` | camera stopped +${((now - window.__motion.holdStart) / 1000).toFixed(1)}s`); const visit = (id, roadId, point) => { if (!point.show) return; - const s = scene.cartesianToCanvasCoordinates(point.position); + const s = window.__godsEyeView.engine.projector.worldToWindow( + point.position, + ); if (s && s.x >= 0 && s.y >= 0 && s.x <= 1280 && s.y <= 800) points.set(id, { x: s.x, y: s.y, roadId }); }; @@ -268,21 +269,19 @@ try { }); }, baseline); const fly = (lat, lon, height, pitch, duration, heading = 20) => - page.evaluate( - async (v) => { - const { viewer } = window.__godsEyeView; + evaluateWithGeo( + page, + async (qaGeo, v) => { window.__motion.label = 'camera moving'; window.__motion.holdStart = null; - viewer.camera.cancelFlight(); + window.__godsEyeView.engine.camera.cancelFlight(); await new Promise((resolve) => - viewer.camera.flyTo({ - destination: viewer.scene.globe.ellipsoid.cartographicToCartesian( - { - latitude: (v.lat * Math.PI) / 180, - longitude: (v.lon * Math.PI) / 180, - height: v.height, - }, - ), + window.__godsEyeView.engine.camera.flyTo({ + destination: qaGeo.cartographic.toCartesian({ + latitude: (v.lat * Math.PI) / 180, + longitude: (v.lon * Math.PI) / 180, + height: v.height, + }), orientation: { heading: (v.heading * Math.PI) / 180, pitch: (v.pitch * Math.PI) / 180, @@ -534,7 +533,7 @@ try { await page.close(); } } finally { - await browser.close(); + await closeBrowser(browser); fs.writeFileSync( path.join(root, 'results.json'), JSON.stringify( diff --git a/scripts/qa-traffic-navigation.mjs b/scripts/qa-traffic-navigation.mjs index b45daeb2c..d02c85d6f 100644 --- a/scripts/qa-traffic-navigation.mjs +++ b/scripts/qa-traffic-navigation.mjs @@ -1,20 +1,18 @@ #!/usr/bin/env node +import { whenSettled, until } from './qa/ready.mjs'; // Deterministic city-navigation exercise; road/flow responses are fixtures. +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; -import puppeteer from 'puppeteer'; import { trafficFixtureResponse } from './traffic-fixtures.mjs'; const args = process.argv.slice(2); const url = args[args.indexOf('--url') + 1] || 'http://localhost:4173'; -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, protocolTimeout: 120000, args: [ '--no-sandbox', '--disable-dev-shm-usage', - '--use-gl=angle', - '--use-angle=swiftshader', '--disable-background-timer-throttling', ], }); @@ -44,15 +42,15 @@ try { await page.waitForFunction(() => window.__godsEyeView?.dataManager, { timeout: 60000, }); - await sleep(5000); + await whenSettled(page); await page.keyboard.press('Escape'); await page.evaluate(async (withCctv) => { - const { viewer, dataManager } = window.__godsEyeView; + const { dataManager } = window.__godsEyeView; const C = await import('/node_modules/.vite/deps/cesium.js'); for (const id of ['satellites', 'flights', 'military']) await dataManager.setEnabled(id, false); - viewer.camera.cancelFlight(); - viewer.camera.setView({ + window.__godsEyeView.engine.camera.cancelFlight(); + window.__godsEyeView.engine.camera.setView({ destination: C.Cartesian3.fromDegrees(-97.744, 30.267, 3200), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); @@ -71,27 +69,28 @@ try { console.log( 'Traffic timeout state', await page.evaluate(() => { - const { viewer, dataManager } = window.__godsEyeView; + const { dataManager } = window.__godsEyeView; return { stats: dataManager.layers.get('traffic').module.getStats(), - camera: viewer.camera.positionCartographic, + camera: window.__godsEyeView.engine.camera.pose().cartographic, }; }), ); throw error; } - await sleep(2500); + await whenSettled(page); await page.evaluate(async () => { - const { viewer } = window.__godsEyeView; const C = await import('/node_modules/.vite/deps/cesium.js'); - viewer.camera.flyTo({ + window.__godsEyeView.engine.camera.flyTo({ destination: C.Cartesian3.fromDegrees(-0.1276, 51.5072, 3200), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, duration: 4, }); }); - const deadline = Date.now() + 45000; - while (londonRequests < 2 && Date.now() < deadline) await sleep(500); + await until(() => londonRequests >= 2, { + timeoutMs: 45000, + label: 'London retry', + }); assert.ok( londonRequests >= 2, 'London must recover its failed request without a toggle', @@ -121,5 +120,5 @@ try { await page.close(); } } finally { - await browser.close(); + await closeBrowser(browser); } diff --git a/scripts/qa-traffic-oblique.mjs b/scripts/qa-traffic-oblique.mjs index cdc2b9265..6c2b60072 100644 --- a/scripts/qa-traffic-oblique.mjs +++ b/scripts/qa-traffic-oblique.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node /** Reticle coverage on oblique arrivals: source requests, toggle oracle and zoom revisits. */ +import { whenSettled } from './qa/ready.mjs'; +import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; -import puppeteer from 'puppeteer'; import { moveCamera, createScreencast, @@ -20,12 +21,10 @@ const modes = modeArg ? [modeArg] : ['tomtom', 'osm', 'hybrid']; const out = `qa-shots/oblique-${baseline ? 'before' : 'after'}`; fs.mkdirSync(out, { recursive: true }); const result = { views: [], errors: [], forbidden: [], failures: [] }; -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -const browser = await puppeteer.launch({ +const browser = await launchBrowser({ headless: true, protocolTimeout: 180000, args: [ - '--use-angle=metal', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', ], @@ -59,12 +58,10 @@ try { const url = new URL(args.find((a) => /^https?:/.test(a))); url.searchParams.set('trafficRoads', mode); await page.goto(url.href, { waitUntil: 'domcontentloaded' }); - await page.waitForFunction( - () => - window.__godsEyeView?.dataManager && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 120000 }, - ); + await page.waitForFunction(() => window.__godsEyeView?.dataManager, { + timeout: 120000, + }); + await whenSettled(page); await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, ); @@ -74,18 +71,19 @@ try { for (const [id] of g.dataManager.layers) await g.dataManager.setEnabled(id, false); g.styleManager.setDetection({ enabled: false }); - g.viewer.scene.screenSpaceCameraController.enableCollisionDetection = false; }); const snap = () => page.evaluate(() => { - const { viewer, dataManager } = window.__godsEyeView; + const { dataManager } = window.__godsEyeView; let upper = 0, lower = 0; dataManager.layers .get('traffic') .module.visitMotionDots((id, key, p) => { if (!p.show) return; - const s = viewer.scene.cartesianToCanvasCoordinates(p.position); + const s = window.__godsEyeView.engine.projector.worldToWindow( + p.position, + ); if (s && s.x >= 240 && s.x <= 1040 && s.y >= 0 && s.y <= 800) { if (s.y < 400) upper++; else lower++; @@ -98,7 +96,7 @@ try { }; }); const settle = async () => { - await sleep(700); + await whenSettled(page); await page .waitForFunction( () => { @@ -110,7 +108,7 @@ try { { timeout: 15000 }, ) .catch(() => {}); - await sleep(1200); + await whenSettled(page); return snap(); }; for (const [name, lat, lon, height] of [ @@ -186,20 +184,22 @@ try { mode === 'hybrid' ? await createScreencast(page, dir, { fps: 24 }) : null; await installFrameProbe(page); await page.evaluate(() => { - const { viewer, dataManager } = window.__godsEyeView; + const { dataManager } = window.__godsEyeView; window.__obliqueFrames = []; window.__obliqueMoving = true; - viewer.scene.postRender.addEventListener(() => { + window.__godsEyeView.engine.frames.postRender.addEventListener(() => { const s = dataManager.layers.get('traffic').module.getStats(); let upper = 0, lower = 0, near = 0; - const camera = viewer.camera.positionWC; + const camera = window.__godsEyeView.engine.camera.pose().position; dataManager.layers .get('traffic') .module.visitMotionDots((id, key, p) => { if (!p.show) return; - const q = viewer.scene.cartesianToCanvasCoordinates(p.position); + const q = window.__godsEyeView.engine.projector.worldToWindow( + p.position, + ); if (q && q.x >= 240 && q.x <= 1040 && q.y >= 0 && q.y <= 800) { if (q.y < 400) upper++; else lower++; @@ -251,7 +251,7 @@ try { window.__obliqueMoving = false; }); const panned = await settle(); - await sleep(1800); + await whenSettled(page); const frames = await page.evaluate(() => window.__obliqueFrames); await page.evaluate(() => window.__godsEyeView.dataManager.setEnabled('traffic', false), @@ -304,7 +304,7 @@ try { } } finally { fs.writeFileSync(`${out}/result.json`, JSON.stringify(result, null, 2)); - await browser.close(); + await closeBrowser(browser); } if ( !baseline &&