From 60f9de8511fc293fd05c259a4ab113f50e05e8a9 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 15:46:26 +0400 Subject: [PATCH] Wait for the application, not the clock, in the strict QA set The probe exposes settled() and the first-frame outcome; scripts/qa/ready.mjs waits for drawn frames with the camera at rest and for a pick at the click position, with a deadline and its reason in the error. The strict scenarios use these instead of fixed pauses and loading-screen polling, and the hidden-switch scenario checks how the loading screen was lifted instead of how long it took. Each switch scenario now records a draw ledger per engine and runs a contract monitor; both write to the scenario's evidence file only, with an empty allowlist until browser runs show what differs on purpose. --- docs/CURRENT-STATE.md | 9 + scripts/qa-app-once.mjs | 9 +- scripts/qa-cctv-engine-switch.mjs | 7 +- scripts/qa-directions-engine-switch.mjs | 43 +- scripts/qa-director-engine-switch.mjs | 10 +- scripts/qa-engine-switch.mjs | 32 +- scripts/qa-fire-camera-switch.mjs | 22 +- scripts/qa-ghostmaps-switch.mjs | 23 +- scripts/qa-ground-layer-switch.mjs | 26 +- scripts/qa-harness-baseline.json | 4 +- scripts/qa-imagery-contract.mjs | 24 +- scripts/qa-infrastructure-engine-switch.mjs | 15 +- scripts/qa-layer-handoff.mjs | 97 ++-- scripts/qa-ledger-allow.json | 1 + scripts/qa-navara-annotations.mjs | 15 +- scripts/qa-navara-cockpit-clouds.mjs | 21 +- scripts/qa-navara-draw.mjs | 33 +- scripts/qa-navara-geo-import.mjs | 28 +- scripts/qa-navara-scope.mjs | 11 +- scripts/qa-navara-sonar.mjs | 41 +- scripts/qa-nepal-event-switch.mjs | 3 +- scripts/qa-nepal-locator-switch.mjs | 7 +- scripts/qa-open-layers-switch.mjs | 3 +- scripts/qa-space-engine-switch.mjs | 7 +- scripts/qa-street-level-engine-switch.mjs | 16 +- scripts/qa-strict-cold-chunks.mjs | 4 +- scripts/qa-strict-context-loss.mjs | 28 +- scripts/qa-strict-harness.mjs | 176 +++++- scripts/qa-strict-harness.test.mjs | 537 ++++++++++++++++++ scripts/qa-strict-hidden-switch.mjs | 17 +- scripts/qa-strict-parked-release.mjs | 19 +- scripts/qa-strict-prod-smoke.mjs | 7 +- scripts/qa-strict-rapid-switch.mjs | 5 +- scripts/qa-strict-session.test.mjs | 51 +- scripts/qa-submarine-cables-engine-switch.mjs | 6 +- scripts/qa-traffic-engine-switch.mjs | 9 +- scripts/qa-urban-engine-switch.mjs | 3 +- scripts/qa-voice-engine-switch.mjs | 55 +- scripts/qa-weather-engine-switch.mjs | 3 +- scripts/qa-wildlife-switch.mjs | 3 +- scripts/qa-wind-engine-switch.mjs | 5 +- scripts/qa/ledger.mjs | 63 ++ scripts/qa/ready.mjs | 122 ++++ src/app/qaProbe.js | 86 ++- src/globe/testing/contractMonitor.js | 186 ++++++ src/globe/testing/contractMonitor.test.mjs | 184 ++++++ src/standalone/engineSwitch.js | 24 +- 47 files changed, 1788 insertions(+), 312 deletions(-) create mode 100644 scripts/qa-ledger-allow.json create mode 100644 scripts/qa/ledger.mjs create mode 100644 scripts/qa/ready.mjs create mode 100644 src/globe/testing/contractMonitor.js create mode 100644 src/globe/testing/contractMonitor.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index eafbd32f4..8fd3acd76 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -417,6 +417,15 @@ one application, and only the globe-bound parts move to the next engine. and switch scenario under a deny-by-default network policy, with free map tiles from a disk cache and a trap that fails any read of Cesium before it has loaded. Every catalog layer must be asserted on both engines. + Readiness uses the restored view, enabled camera inputs and an unchanged + camera pose over three drawn frames (`scripts/qa/ready.mjs`); timeouts name + the unmet conditions. The dev probe exposes the same state through + `layers.settled()`. Production checks inject their observers through the + existing debug handle, without shipping the probe. Evidence also records + the five drawing registries before and after picker switches and contract + violations for each observed engine; both remain diagnostic-only. Engine-only + registry ids need a reason in `scripts/qa-ledger-allow.json`. Hidden switches + report the actual first-frame, hidden-tab or timeout uncover path. Page clocks start at the 18:30 UTC nearest to the real time, so the sun is up over the test cities whenever the run happens, unless a scenario supplies `time` (an ISO instant or `'real'`); `QA_EXIT_DIAGNOSTICS=1` preserves diff --git a/scripts/qa-app-once.mjs b/scripts/qa-app-once.mjs index 6c850a5ef..7d367dcb8 100644 --- a/scripts/qa-app-once.mjs +++ b/scripts/qa-app-once.mjs @@ -1,3 +1,4 @@ +import { whenSettled } from './qa/ready.mjs'; import { closeBrowser, exitAfterFlush } from './qa/browser.mjs'; import { localPreview, waitForEngine } from './qa-strict-harness.mjs'; // The application is created once per page: switching engines detaches and @@ -56,7 +57,7 @@ async function escapeEffect(page) { await page.focus(FIELD); const before = await readUi(page); await page.keyboard.press('Escape'); - await new Promise((resolve) => setTimeout(resolve, 400)); + await whenSettled(page); const after = await readUi(page); return { before, after }; } @@ -141,7 +142,7 @@ try { ); await waitForEngine(page, 'cesium', { timeout: 90_000 }); // Let the first boot's deferred listeners land before counting. - await new Promise((resolve) => setTimeout(resolve, 2000)); + await whenSettled(page); const boot = await readSession(page); const bootListeners = await listenerCounts(page, { bySource: true }); report.boot = boot; @@ -218,7 +219,7 @@ try { window.__qaParkedNavara = window.__godsEyeView.engine; }); if (report.switches.length === 1) { - await new Promise((resolve) => setTimeout(resolve, 2000)); + await whenSettled(page); report.firstRoundTrip = await listenerCounts(page, { bySource: true }); } // Both engines draw the session's marks and files (Navara included). @@ -261,7 +262,7 @@ try { ui, ); } - await new Promise((resolve) => setTimeout(resolve, 2000)); + await whenSettled(page); const after = await readSession(page); const afterListeners = await listenerCounts(page, { bySource: true }); report.after = after; diff --git a/scripts/qa-cctv-engine-switch.mjs b/scripts/qa-cctv-engine-switch.mjs index 0497b5e78..3d9209483 100644 --- a/scripts/qa-cctv-engine-switch.mjs +++ b/scripts/qa-cctv-engine-switch.mjs @@ -1,3 +1,4 @@ +import { waitForEngine } from './qa-strict-harness.mjs'; import { assertKeylessPreview } from './qa-strict-harness.mjs'; import assert from 'node:assert/strict'; import { promisify } from 'node:util'; @@ -51,11 +52,7 @@ await strict.run({}, async (b) => { if (new URL(req.url()).pathname === '/api/cctv/sources') catalogs++; }); await p.goto(url.href); - await p.waitForFunction( - () => - window.__godsEyeView?.engine?.kind === 'navara' && - document.querySelector('#loading-screen.hidden'), - ); + await waitForEngine(p, 'navara'); await assertKeylessPreview(p); await p.evaluate(async () => { const { dataManager, styleManager } = window.__godsEyeView; diff --git a/scripts/qa-directions-engine-switch.mjs b/scripts/qa-directions-engine-switch.mjs index 66e9151d1..b2c09ae11 100644 --- a/scripts/qa-directions-engine-switch.mjs +++ b/scripts/qa-directions-engine-switch.mjs @@ -1,3 +1,4 @@ +import { whenSettled } from './qa/ready.mjs'; import assert from 'node:assert/strict'; import { createStrict, @@ -67,7 +68,7 @@ await strict.run({}, async (b) => { ]) { await p.evaluate((l) => window.qaRoute.setParams({ arm: l }), letter); await p.mouse.click(x, 440); - await new Promise((resolve) => setTimeout(resolve, 450)); + await whenSettled(p); } assert.equal(routeReads, 1); await strict.passed( @@ -88,7 +89,21 @@ await strict.run({}, async (b) => { 'the route asked for on Navara arrives on Cesium; a step is selectable', ); const transfer = async (kind) => { - await strict.switchTo(p, kind); + const flying = await p.evaluate(() => + Boolean(window.qaRoute.getFlightProgress()), + ); + if (flying) { + // A running dolly owns the camera; verify its handoff before asking for rest. + await strict.pick(p, kind, { settle: false }); + await p.waitForFunction( + (kind) => + window.__godsEyeView?.engine?.kind === kind && + !document.querySelector('#globe-engine[aria-busy="true"]') && + window.qaRoute.getFlightProgress()?.traveledM > 0, + {}, + kind, + ); + } else await strict.switchTo(p, kind); assert.equal( await p.evaluate( () => @@ -119,7 +134,7 @@ await strict.run({}, async (b) => { const pos = () => p.evaluate(() => window.__godsEyeView.engine.camera.pose().position); const d = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z); - await new Promise((resolve) => setTimeout(resolve, 1800)); + await p.waitForFunction(() => window.qaRoute.getFlightProgress()?.traveledM > 0); let progress = await p.evaluate(() => window.qaRoute.getFlightProgress()); for (const kind of ['cesium', 'navara']) { const before = await pos(); @@ -130,7 +145,17 @@ await strict.run({}, async (b) => { ?.active, ); const at = await pos(); - await new Promise((resolve) => setTimeout(resolve, 1500)); + await p.waitForFunction( + ({ at, traveledM }) => { + const next = window.__godsEyeView.engine.camera.pose().position; + return ( + window.qaRoute.getFlightProgress()?.traveledM > traveledM && + Math.hypot(next.x - at.x, next.y - at.y, next.z - at.z) > 1 + ); + }, + {}, + { at, traveledM: progress.traveledM }, + ); const next = await pos(); const resumed = await p.evaluate(() => window.qaRoute.getFlightProgress()); assert.equal(resumed.motionId, progress.motionId); @@ -149,14 +174,14 @@ await strict.run({}, async (b) => { assert.equal(await p.evaluate(() => window.qaRoute.getStats().count), 0); // CLEAR releases the camera: the route's motion ends, the camera stays // where it is, and a wheel zoom by the person moves it and is kept. - await new Promise((resolve) => setTimeout(resolve, 500)); + await waitForEngine(p, 'navara'); const released = await p.evaluate(() => ({ motion: window.qaRoute.getFlightProgress(), fly: window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly') ?.active, })); const still = await pos(); - await new Promise((resolve) => setTimeout(resolve, 1500)); + await whenSettled(p); const later = await pos(); const height = () => p.evaluate(() => window.__godsEyeView.engine.camera.height()); @@ -164,11 +189,11 @@ await strict.run({}, async (b) => { await p.mouse.move(512, 300); for (let i = 0; i < 4; i++) { await p.mouse.wheel({ deltaY: -240 }); - await new Promise((resolve) => setTimeout(resolve, 120)); + await whenSettled(p); } - await new Promise((resolve) => setTimeout(resolve, 1500)); + await whenSettled(p); const zoomed = await height(); - await new Promise((resolve) => setTimeout(resolve, 1500)); + await whenSettled(p); const kept = await height(); await strict.check( p, diff --git a/scripts/qa-director-engine-switch.mjs b/scripts/qa-director-engine-switch.mjs index e26c467ef..a77060d56 100644 --- a/scripts/qa-director-engine-switch.mjs +++ b/scripts/qa-director-engine-switch.mjs @@ -1,3 +1,4 @@ +import { whenPickable, whenSettled } from './qa/ready.mjs'; import { assertKeylessPreview } from './qa-strict-harness.mjs'; import assert from 'node:assert/strict'; import sharp from 'sharp'; @@ -177,11 +178,7 @@ await strict.run({}, async (b) => { return req.continue(); }); await p.goto(url.href); - await p.waitForFunction( - () => - window.__godsEyeView?.sceneDirector && - document.querySelector('#loading-screen.hidden'), - ); + await whenSettled(p); await assertKeylessPreview(p); await p.evaluate(async (project) => { const a = window.__godsEyeView; @@ -218,7 +215,7 @@ await strict.run({}, async (b) => { for (const kind of ['navara', 'cesium', 'navara']) { if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) await strict.switchTo(p, kind); - await new Promise((r) => setTimeout(r, 500)); + await whenSettled(p); const st = await p.evaluate(() => ({ same: window.director === window.__godsEyeView.sceneDirector, media: @@ -231,6 +228,7 @@ await strict.run({}, async (b) => { st.same && st.media && st.packs.count === 3 && st.actions.count === 3, ); const xy = await at(fromDegrees(-97.742, 30.27, 450)); + await whenPickable(p, xy, (picked) => picked?.id === '["data","point"]'); await p.mouse.click(xy.x, xy.y); await p.waitForFunction( () => document.activeElement?.dataset.directorAction === 'point', diff --git a/scripts/qa-engine-switch.mjs b/scripts/qa-engine-switch.mjs index 1f1451859..416a190f3 100644 --- a/scripts/qa-engine-switch.mjs +++ b/scripts/qa-engine-switch.mjs @@ -1,3 +1,5 @@ +import { waitForEngine } from './qa-strict-harness.mjs'; +import { whenSettled } from './qa/ready.mjs'; import { assertKeylessPreview } from './qa-strict-harness.mjs'; import assert from 'node:assert/strict'; import sharp from 'sharp'; @@ -61,17 +63,7 @@ await strict.run({}, async (browser) => { const errors = []; page.on('pageerror', (error) => errors.push(error.message)); async function ready(kind) { - await page.waitForFunction( - (kind) => - window.__godsEyeView?.engine?.kind === kind && - document.querySelector('#globe-engine')?.value === kind && - !document.querySelector( - '#globe-engine:is([disabled], [aria-busy="true"])', - ) && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 90000 }, - kind, - ); + await waitForEngine(page, kind); await assertKeylessPreview(page); await page.evaluate(async () => { await window.__godsEyeView.styleManager.initialRestorePromise; @@ -94,7 +86,7 @@ await strict.run({}, async (browser) => { .get('earthquakes') .module.getAnalystRecords().length === 1, ); - await new Promise((resolve) => setTimeout(resolve, 1800)); + await whenSettled(page); const bytes = await page.screenshot({ clip: { x: 675, y: 403, width: 20, height: 20 }, }); @@ -272,17 +264,11 @@ await strict.run({}, async (browser) => { await switchTo('navara'); assert.deepEqual(await sharpen(), { enabled: false, intensityPct: 49 }); // The address carries the running layer set once its debounced write lands. - const settledAddress = async () => { - let previous; - for (;;) { - await new Promise((resolve) => setTimeout(resolve, 1200)); - const current = await page.evaluate(() => - new URLSearchParams(location.hash.slice(1)).get('l'), - ); - if (current === previous) return current; - previous = current; - } - }; + const settledAddress = () => + page.evaluate(() => { + window.__godsEyeView.styleManager.shareLinkManager.flush(); + return new URLSearchParams(location.hash.slice(1)).get('l'); + }); const pick = async (kind) => { await strict.pick(page, kind); await ready(kind); diff --git a/scripts/qa-fire-camera-switch.mjs b/scripts/qa-fire-camera-switch.mjs index 8bc4f2ebe..f9284e658 100644 --- a/scripts/qa-fire-camera-switch.mjs +++ b/scripts/qa-fire-camera-switch.mjs @@ -1,3 +1,5 @@ +import { waitForEngine } from './qa-strict-harness.mjs'; +import { whenPickable, whenSettled } from './qa/ready.mjs'; import { assertKeylessPreview } from './qa-strict-harness.mjs'; import assert from 'node:assert/strict'; import { installAlprFixture, cameras } from './fixtures/layers/alpr.mjs'; @@ -47,11 +49,7 @@ await strict.run({}, async (b) => { }; }, firmsFixture()); await p.goto(url.href); - await p.waitForFunction( - () => - window.__godsEyeView?.engine?.kind === 'navara' && - document.querySelector('#loading-screen.hidden'), - ); + await waitForEngine(p, 'navara'); await assertKeylessPreview(p); await p.evaluate(async (id) => { const { dataManager, styleManager } = window.__godsEyeView; @@ -105,7 +103,15 @@ await strict.run({}, async (b) => { { timeout: 30000 }, fire, ) - .then((handle) => handle.jsonValue()); + .then((handle) => handle.jsonValue()) + .then((xy) => + whenPickable(p, xy, (picked, position) => { + const id = position.id.startsWith('FIRE-') + ? `firms-${Number(position.id.slice(5))}` + : position.id; + return (picked?.id?.id ?? picked?.id) === id; + }), + ); const selected = (id) => p.waitForFunction( (id, fire) => @@ -135,8 +141,8 @@ await strict.run({}, async (b) => { fire, ); }; + await whenSettled(p); const xy = await findTarget(); - await new Promise((r) => setTimeout(r, 1000)); await p.mouse.click(xy.x, xy.y); await selected(xy.id); await strict.passed( @@ -147,7 +153,6 @@ await strict.run({}, async (b) => { : 'a click on a camera selects it', xy, ); - await new Promise((r) => setTimeout(r, 1600)); // The selection flies the camera to the record (1.9 s, longer on a busy // machine); a switch before it lands opens the other engine on a view // between the two, where the record can be off screen. @@ -161,6 +166,7 @@ await strict.run({}, async (b) => { reads: window.qaFirmsReads, })); for (const kind of ['cesium', 'navara']) { + await whenSettled(p); await strict.switchTo(p, kind); const result = await p.evaluate( (id) => ({ diff --git a/scripts/qa-ghostmaps-switch.mjs b/scripts/qa-ghostmaps-switch.mjs index 105770799..8353c66f4 100644 --- a/scripts/qa-ghostmaps-switch.mjs +++ b/scripts/qa-ghostmaps-switch.mjs @@ -1,3 +1,4 @@ +import { whenPickable, whenSettled } from './qa/ready.mjs'; import { fromDegrees } from '../src/geo/cartesian3.js'; import { readFile } from 'node:fs/promises'; import assert from 'node:assert/strict'; @@ -68,7 +69,7 @@ await strict.run({}, async (b) => { await dataManager.setEnabled('s2-ghostmaps', true, { origin: 'user' }); window.qaGhost = dataManager.layers.get('s2-ghostmaps').module; }); - const click = async (lon, lat) => { + const click = async (lon, lat, pickId) => { const { x, y } = await p.evaluate( (pos) => { const { engine } = window.__godsEyeView; @@ -79,6 +80,12 @@ await strict.run({}, async (b) => { }, fromDegrees(lon, lat), ); + if (pickId) + await whenPickable( + p, + { x, y, id: pickId }, + (picked, position) => picked?.id === position.id, + ); await p.mouse.click(x, y); }; const overlay = () => @@ -120,13 +127,13 @@ await strict.run({}, async (b) => { 's2-ghostmaps', 'same model, three features, one event stream, one KMZ read', ); - await new Promise((resolve) => setTimeout(resolve, 2200)); - for (const [name, lon, lat] of [ - ['QA pin', -79.38, 43.65], - ['QA route', -79.375, 43.71], - ['QA zone', -79.28, 43.61], + await whenSettled(p); + for (const [name, lon, lat, pickId] of [ + ['QA pin', -79.38, 43.65, 'ghostmaps:cip:0:0'], + ['QA route', -79.375, 43.71, 'ghostmaps:cip:1:0'], + ['QA zone', -79.28, 43.61, 'ghostmaps:cip:2:0'], ]) { - await click(lon, lat); + await click(lon, lat, pickId); await p.waitForFunction( (name) => window.ghostStats.titles.some((v) => @@ -163,7 +170,7 @@ await strict.run({}, async (b) => { ); const hole = await overlay(); await click(-79.45, 43.55); - await new Promise((resolve) => setTimeout(resolve, 800)); + await whenSettled(p); const empty = await overlay(); console.log( kind, diff --git a/scripts/qa-ground-layer-switch.mjs b/scripts/qa-ground-layer-switch.mjs index 1dbeeac97..e4b032664 100644 --- a/scripts/qa-ground-layer-switch.mjs +++ b/scripts/qa-ground-layer-switch.mjs @@ -1,3 +1,4 @@ +import { clickUntilSelected, whenPickable, whenSettled } from './qa/ready.mjs'; import { fromDegrees } from '../src/geo/cartesian3.js'; import sharp from 'sharp'; import assert from 'node:assert/strict'; @@ -212,7 +213,7 @@ await strict.run({}, async (b) => { const deadline = Date.now() + 20000; let on = await capture(true, layerId); while (orangeOf(on) <= orangeOf(off) + 5 && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 500)); + await whenSettled(p); on = await sample(); } return on; @@ -327,7 +328,7 @@ await strict.run({}, async (b) => { { timeout: 15000 }, visible, ); - const click = async (lon, lat) => { + const click = async (lon, lat, predicate) => { const { x, y } = await p.evaluate( (world) => { const { engine } = window.__godsEyeView; @@ -338,6 +339,7 @@ await strict.run({}, async (b) => { }, fromDegrees(lon, lat), ); + if (predicate) await whenPickable(p, { x, y }, predicate); await p.mouse.click(x, y); }; for (const kind of ['navara', 'cesium', 'navara']) { @@ -398,7 +400,13 @@ await strict.run({}, async (b) => { await p.waitForFunction( () => window.qaVesselEntry.module.qaDrawn()?.chevrons[0]?.show, ); - await new Promise((resolve) => setTimeout(resolve, 800)); + const vesselPicked = (picked) => picked?.id === '123456789'; + const selectVessel = () => + clickUntilSelected( + p, + () => click(-79.38, 43.65, vesselPicked), + () => window.qaVesselEntry.module.getSelectedInfo()?.mmsi === '123456789', + ); // Click-picking on the running engine: clear the selection with a click on // empty water, then select the vessel with a click on its chevron. const pickVessel = async () => { @@ -418,12 +426,7 @@ await strict.run({}, async (b) => { await p.waitForFunction( () => !window.qaVesselEntry.module.getSelectedInfo(), ); - await new Promise((resolve) => setTimeout(resolve, 600)); - await click(-79.38, 43.65); - await p.waitForFunction( - () => window.qaVesselEntry.module.getSelectedInfo()?.mmsi === '123456789', - { timeout: 15000 }, - ); + await selectVessel(); await strict.passed( p, 'ais-live-vessels', @@ -432,10 +435,7 @@ await strict.run({}, async (b) => { // The next switch starts from the landed view. await waitForCameraAtRest(p); }; - await click(-79.38, 43.65); - await p.waitForFunction( - () => window.qaVesselEntry.module.getSelectedInfo()?.mmsi === '123456789', - ); + await selectVessel(); await strict.passed( p, 'ais-live-vessels', diff --git a/scripts/qa-harness-baseline.json b/scripts/qa-harness-baseline.json index 10a203a00..611b85329 100644 --- a/scripts/qa-harness-baseline.json +++ b/scripts/qa-harness-baseline.json @@ -1,8 +1,8 @@ { "metrics": { "ownLaunch": 98, - "fixedSleeps": 117, - "loadingScreenReadiness": 9, + "fixedSleeps": 85, + "loadingScreenReadiness": 1, "cesiumOnlyReads": 75, "ownWaitHelpers": 19 } diff --git a/scripts/qa-imagery-contract.mjs b/scripts/qa-imagery-contract.mjs index 1a7a34c50..5b57f014a 100644 --- a/scripts/qa-imagery-contract.mjs +++ b/scripts/qa-imagery-contract.mjs @@ -1,3 +1,4 @@ +import { whenSettled } from './qa/ready.mjs'; // Recent imagery on a keyless preview, starting on Navara: the NASA catalog // and imagery tiles are fixtures, and the image, A/B, swap, divider, opacity, // basemap, toggle, failure, box-drag and zoom-to-fit checks read the rendered @@ -46,7 +47,6 @@ const check = (name, value) => { if (value && engine) strict.record('recent-imagery', engine, name); console.log(`${value ? 'PASS' : 'FAIL'} ${name}`); }; -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const colors = await Promise.all( ['#ed3838', '#35c969'].map((background) => sharp({ create: { width: 256, height: 256, channels: 4, background } }) @@ -203,10 +203,10 @@ try { () => window.__godsEyeView.engine.surface.tilesLoaded(), { timeout: 90000 }, ); - await sleep(4000); + await whenSettled(page); const capture = async (name) => { await page.evaluate(() => window.__godsEyeView.requestRender('imagery-qa')); - await sleep(350); + await whenSettled(page); const buffer = await page.screenshot(); await fs.writeFile(output.replace(/\.json$/, `-${name}.png`), buffer); const pixels = await sharp(buffer) @@ -236,7 +236,7 @@ try { ); check('geographic clip outside box', !red(image.outside)); await page.evaluate(() => window.__gevRecentImagery.layer.setMode('ab')); - await sleep(2500); + await whenSettled(page); image = await capture('ab'); check('A/B dates on opposite sides', red(image.left) && green(image.right)); const first = await page.evaluate(() => { @@ -261,7 +261,7 @@ try { state.pins.a === first.a && state.pins.b === first.b, ); - await sleep(1800); + await whenSettled(page); const pixels = await capture('switch-' + kind); check( kind + ' renders both pinned dates', @@ -326,7 +326,7 @@ try { () => window.__gevRecentImagery.layer.diagnostics().ownedCount === 1, { timeout: 30000 }, ); - await sleep(2000); + await whenSettled(page); image = await capture('toggles'); check('ten toggles restore visible image', red(image.left)); report.afterToggle = await page.evaluate(() => @@ -352,7 +352,7 @@ try { .key, ); }); - await sleep(2000); + await whenSettled(page); image = await capture('source-failure'); check( 'failed replacement does not leave stale image pixels', @@ -369,14 +369,14 @@ try { .map((c) => c.key); for (let i = 0; i < 10; i++) l.setAssignment('a', keys[i % 2]); }); - await sleep(2000); + await whenSettled(page); image = await capture('replaced'); check( 'rapid replacement leaves final date only', green(image.left) && green(image.right), ); await page.evaluate(() => window.__gevRecentImagery.layer.clear()); - await sleep(1000); + await whenSettled(page); check( 'clear removes both slots', await page.evaluate( @@ -430,7 +430,7 @@ try { window.__godsEyeView.engine.camera.pose().cartographic.height > 1000000, { timeout: 10000 }, ); - await sleep(350); + await whenSettled(page); const fit = (report.fit = await page.evaluate(() => { const l = window.__gevRecentImagery.layer; const used = l.useCurrentView(); @@ -440,7 +440,7 @@ try { 'oversized view refuses and offers bounded zoom', !fit.used && fit.offered && fit.height >= 5000 && fit.height <= 400000, ); - await sleep(2200); + await whenSettled(page); check( 'ZOOM IN lands at requested height', await page.evaluate( @@ -455,7 +455,7 @@ try { window.__gevRecentImagery.layer.clear(); await window.__godsEyeView.dataManager.setEnabled('recent-imagery', false); }); - await sleep(1000); + await whenSettled(page); report.disabled = await page.evaluate(() => window.__gevRecentImagery.layer.diagnostics(), ); diff --git a/scripts/qa-infrastructure-engine-switch.mjs b/scripts/qa-infrastructure-engine-switch.mjs index 54563f6f5..ee345fa95 100644 --- a/scripts/qa-infrastructure-engine-switch.mjs +++ b/scripts/qa-infrastructure-engine-switch.mjs @@ -1,3 +1,4 @@ +import { whenPickable, whenSettled } from './qa/ready.mjs'; import { fromDegrees } from '../src/geo/cartesian3.js'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -94,6 +95,11 @@ await strict.run({}, async (b) => { }, fromDegrees(record.lon, record.lat), ); + await whenPickable( + p, + screen, + (picked) => picked?.id === 'local-datacenters:342664739', + ); await p.mouse.click(screen.x, screen.y); await p.waitForFunction( () => window.qaSelections.at(-1) === 'local-datacenters:342664739', @@ -119,6 +125,11 @@ await strict.run({}, async (b) => { }, fromDegrees(dam.lon, dam.lat), ); + await whenPickable( + p, + screen, + (picked) => picked?.id === 'local-dams:-12841971', + ); await p.mouse.click(screen.x, screen.y); await p .waitForFunction( @@ -160,7 +171,7 @@ await strict.run({}, async (b) => { }); render.request('qa-home'); }, start); - await new Promise((resolve) => setTimeout(resolve, 2000)); + await whenSettled(p); }; const counts = { ...reads }; for (const kind of ['navara', 'cesium', 'navara']) { @@ -174,7 +185,7 @@ await strict.run({}, async (b) => { { timeout: 15000 }, ); } - await new Promise((r) => setTimeout(r, 1500)); + await whenSettled(p); const result = await p.evaluate( (ids) => ids.map((id, i) => { diff --git a/scripts/qa-layer-handoff.mjs b/scripts/qa-layer-handoff.mjs index 77e9480a2..681dd13b7 100644 --- a/scripts/qa-layer-handoff.mjs +++ b/scripts/qa-layer-handoff.mjs @@ -1,3 +1,4 @@ +import { waitForEngine } from './qa-strict-harness.mjs'; import { assertKeylessPreview } from './qa-strict-harness.mjs'; import assert from 'node:assert/strict'; import { createStrict, localPreview } from './qa-strict-harness.mjs'; @@ -107,19 +108,21 @@ await strict.run({}, async (browser) => { aircraftId, ); await page.goto(url.href, { waitUntil: 'domcontentloaded' }); - const ready = (kind) => - page.waitForFunction( - (engine) => - window.__godsEyeView?.engine?.kind === engine && - !document.querySelector( - '#globe-engine:is([disabled], [aria-busy="true"])', - ) && - document.querySelector('#loading-screen.hidden') && - !document.querySelector('.engine-switch-frame'), + await waitForEngine(page, 'navara'); + // Cockpit mode deliberately holds camera inputs throughout these handoffs. + const attached = async (kind) => { + await page.waitForFunction( + (kind) => + window.__godsEyeView?.engine?.kind === kind && + document.querySelector('#globe-engine')?.value === kind && + !document.querySelector('#globe-engine:is([disabled], [aria-busy="true"])'), { timeout: 90000 }, kind, ); - await ready('navara'); + await page.evaluate( + () => window.__godsEyeView.styleManager.initialRestorePromise, + ); + }; await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, ); @@ -165,7 +168,7 @@ await strict.run({}, async (browser) => { const historyLength = await page.evaluate(() => history.length); await strict.selectEngine(page, 'cesium', HIDDEN_PICKER); await page.waitForFunction(() => window.__refusedContext === true); - await ready('navara'); + await attached('navara'); // A failed switch leaves the session usable on the engine that worked and // adds no history entry. const survived = (kind) => @@ -201,7 +204,7 @@ await strict.run({}, async (browser) => { for (const kind of ['cesium', 'navara']) { await strict.selectEngine(page, kind, HIDDEN_PICKER); - await ready(kind); + await attached(kind); const result = await page.evaluate(() => { const { dataManager, engine, styleManager } = window.__godsEyeView; const before = window.__handoff; @@ -260,29 +263,41 @@ await strict.run({}, async (browser) => { ); // Polling goes on after the switch: the same timer ticks and each tick // reads the source once (no tick may be skipped, none added). - const polls = await page.evaluate(async (layerId) => { + const pollStart = await page.evaluate((layerId) => { const entry = window.__godsEyeView.dataManager.layers.get(layerId); - const every = - Number(entry.module.refreshInterval) || - Number(entry.module.updateInterval); - const start = { + return { + layerId, + every: + Number(entry.module.refreshInterval) || + Number(entry.module.updateInterval), polls: window.__flightPolls, ticks: window.__handoff.ticks, stamp: entry.module.getStats().lastUpdate, }; - const deadline = Date.now() + 2.5 * every + 5000; - while (window.__handoff.ticks - start.ticks < 2 && Date.now() < deadline) - await new Promise((resolve) => setTimeout(resolve, 250)); - // Let the last tick's request land. - await new Promise((resolve) => setTimeout(resolve, 1500)); + }, layerId); + await page.waitForFunction( + (start) => { + const entry = window.__godsEyeView.dataManager.layers.get(start.layerId); + const ticks = window.__handoff.ticks - start.ticks; + return ( + ticks >= 2 && + window.__flightPolls - start.polls === ticks && + entry.module.getStats().lastUpdate !== start.stamp + ); + }, + { timeout: 2.5 * pollStart.every + 5000 }, + pollStart, + ); + const polls = await page.evaluate((start) => { + const entry = window.__godsEyeView.dataManager.layers.get(start.layerId); return { - every, + every: start.every, ticks: window.__handoff.ticks - start.ticks, polls: window.__flightPolls - start.polls, updated: entry.module.getStats().lastUpdate !== start.stamp, sameTimer: entry.intervalId === window.__handoff.timer, }; - }, layerId); + }, pollStart); await strict.check( page, layerId, @@ -308,7 +323,7 @@ await strict.run({}, async (browser) => { window.__parkedNavara = window.__godsEyeView.engine; }); await strict.selectEngine(page, 'cesium', HIDDEN_PICKER); - await ready('cesium'); + await attached('cesium'); const lengthOnCesium = await page.evaluate(() => { const request = window.__parkedNavara.render.request; window.__parkedNavara.render.request = function (...args) { @@ -320,7 +335,7 @@ await strict.run({}, async (browser) => { }); await strict.selectEngine(page, 'navara', HIDDEN_PICKER); await page.waitForFunction(() => window.__refusedResume === true); - await ready('cesium'); + await attached('cesium'); assert.ok( (await page.evaluate( (length) => history.length === length, @@ -349,7 +364,7 @@ await strict.run({}, async (browser) => { window.__parkedCesium = window.__godsEyeView.engine; }); await strict.selectEngine(page, 'navara', HIDDEN_PICKER); - await ready('navara'); + await attached('navara'); assert.ok( await page.evaluate(() => { const { engine, styleManager } = window.__godsEyeView; @@ -439,7 +454,7 @@ await strict.run({}, async (browser) => { await page.click( '#loading-screen .engine-switch-retry [data-engine="cesium"]', ); - await ready('cesium'); + await attached('cesium'); assert.ok( await page.evaluate(() => { const { dataManager, styleManager } = window.__godsEyeView; @@ -482,17 +497,25 @@ await strict.run({}, async (browser) => { ).intervalId; }); await strict.pick(page, 'navara'); - await ready('navara'); - const after = await page.evaluate(async () => { + await waitForEngine(page, 'navara'); + const every = await page.evaluate(() => { + const entry = window.__godsEyeView.dataManager.layers.get( + window.__qaAircraft.layerId, + ); + return ( + Number(entry.module.refreshInterval) || + Number(entry.module.updateInterval) + ); + }); + await page.waitForFunction( + () => + window.__handoff.ticks >= 1 && + window.__flightPolls - window.__handoff.pollCount === window.__handoff.ticks, + { timeout: 2.5 * every + 5000 }, + ); + const after = await page.evaluate(() => { const { dataManager } = window.__godsEyeView; const entry = dataManager.layers.get(window.__qaAircraft.layerId); - const every = - Number(entry.module.refreshInterval) || - Number(entry.module.updateInterval); - const deadline = Date.now() + 2.5 * every + 5000; - while (window.__handoff.ticks < 1 && Date.now() < deadline) - await new Promise((resolve) => setTimeout(resolve, 250)); - await new Promise((resolve) => setTimeout(resolve, 1500)); return { same: dataManager === window.__handoff.dataManager && diff --git a/scripts/qa-ledger-allow.json b/scripts/qa-ledger-allow.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/scripts/qa-ledger-allow.json @@ -0,0 +1 @@ +[] diff --git a/scripts/qa-navara-annotations.mjs b/scripts/qa-navara-annotations.mjs index 474e6c1ce..add034201 100644 --- a/scripts/qa-navara-annotations.mjs +++ b/scripts/qa-navara-annotations.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { whenSettled } from './qa/ready.mjs'; // Annotations on both engines, under the strict network policy: the same // board (a hand-drawn area, a pin in view and a pin on the far side of the // globe) is drawn on Navara, survives a switch to Cesium and back, and on @@ -23,7 +24,6 @@ base.hash = `v=2&lat=${LAT}&lon=${LON}&alt=7000&heading=0&pitch=-90&roll=0&map=o const strict = createStrict({ origin: base.origin, layers: ['annotations'] }); const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-annotations'; fs.mkdirSync(SHOTS, { recursive: true }); -const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // The area: a box around the view centre, clear of the panels. const RING = [ [LON - 0.012, LAT - 0.009], @@ -53,7 +53,7 @@ await strict.run( content: '.gev-screen-whiteboard{visibility:hidden!important}', }); await page.evaluate(() => window.__godsEyeView.requestRender?.('qa')); - await wait(700); + await whenSettled(page); const { data, info } = await sharp( await page.screenshot({ clip: { x: 340, y: 180, width: 600, height: 500 }, @@ -155,7 +155,7 @@ await strict.run( ) { await strict.switchTo(page, kind); } - await wait(2500); + await whenSettled(page); const state = await page.evaluate(() => ({ same: window.__gevAnnotations === window.qaBoard, count: window.qaBoard.count(), @@ -210,9 +210,12 @@ await strict.run( const before = engine.camera.height(); const area = window.qaBoard.list().find((m) => m.label === 'QA Area'); const ok = window.qaBoard.frame(area.id); - await new Promise((r) => setTimeout(r, 2600)); - return { ok, before, after: engine.camera.height() }; + return { ok, before }; }); + await whenSettled(page); + flight.after = await page.evaluate(() => + window.__godsEyeView.engine.camera.height(), + ); await strict.check( page, 'annotations', @@ -224,9 +227,9 @@ await strict.run( const hidden = await page.evaluate(async () => { for (const mark of window.qaBoard.list()) window.qaBoard.setHidden(mark.id, true); - await new Promise((r) => setTimeout(r, 600)); return window.qaBoard.list().every((m) => m.hidden); }); + await whenSettled(page); const afterHide = await board(); await strict.check( page, diff --git a/scripts/qa-navara-cockpit-clouds.mjs b/scripts/qa-navara-cockpit-clouds.mjs index 78899958a..36073d01d 100644 --- a/scripts/qa-navara-cockpit-clouds.mjs +++ b/scripts/qa-navara-cockpit-clouds.mjs @@ -71,13 +71,28 @@ const save = (name, pixels) => saveCapture(outDir, name, pixels); // Clouds grey the frame: the blue sky and the map's coloured lines go. const saturation = (pixels) => stats(pixels).saturation; -// A few seconds of continuous frames: the clouds converge over frames. +// The temporal cloud pass converges over drawn frames. async function converged(page, name) { await page.evaluate(async () => { const { engine } = window.__godsEyeView; engine.render.setContinuous(true); - await new Promise((resolve) => setTimeout(resolve, 3000)); - engine.render.setContinuous(false); + try { + await new Promise((resolve, reject) => { + let frames = 0; + const timer = setTimeout(() => { + remove(); + reject(new Error('Cloud convergence frames did not arrive')); + }, 30000); + const remove = engine.frames.postRender.addEventListener(() => { + if (++frames < 120) return; + remove(); + clearTimeout(timer); + resolve(); + }); + }); + } finally { + engine.render.setContinuous(false); + } }); const pixels = await capture(page); save(name, pixels); diff --git a/scripts/qa-navara-draw.mjs b/scripts/qa-navara-draw.mjs index 84615d832..0e99829b2 100644 --- a/scripts/qa-navara-draw.mjs +++ b/scripts/qa-navara-draw.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { waitForEngine } from './qa-strict-harness.mjs'; +import { whenSettled } from './qa/ready.mjs'; // UTILITIES ▸ Draw on Navara under the strict network policy, with real // clicks and keys: Draw takes the pointer, clicks on the globe are vertices // with a live rubber band, a double click finishes an area that paints on @@ -21,7 +23,6 @@ base.hash = `v=2&lat=${VIEW.lat}&lon=${VIEW.lon}&alt=${VIEW.alt}&heading=0&pitch const strict = createStrict({ origin: base.origin, layers: ['draw'] }); const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-draw'; fs.mkdirSync(SHOTS, { recursive: true }); -const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // An area's corners, clear of the open UTILITIES panel and the rails. const AREA = [ [560, 330], @@ -44,18 +45,8 @@ await strict.run( page.on('pageerror', (error) => errors.push(error.message)); await page.goto(base.href); const ready = async (kind) => { - await page.waitForFunction( - (k) => - window.__godsEyeView?.engine?.kind === k && - window.__gevDrawTool?.attached && - !document.querySelector( - '#globe-engine:is([disabled], [aria-busy="true"])', - ) && - !document.querySelector('.engine-switch-frame') && - document.querySelector('#loading-screen.hidden'), - { timeout: 120000 }, - kind, - ); + await waitForEngine(page, kind); + await page.waitForFunction(() => window.__gevDrawTool?.attached); await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, ); @@ -84,7 +75,7 @@ await strict.run( await page.addStyleTag({ content: '.gev-screen-whiteboard{visibility:hidden!important}', }); - await wait(800); + await whenSettled(page); const { data, info } = await sharp( await page.screenshot({ clip: { x: 520, y: 300, width: 280, height: 230 }, @@ -124,10 +115,10 @@ await strict.run( }, shape); for (const [x, y] of points) { await page.mouse.click(x, y); - await wait(120); + await whenSettled(page); } await finish(); - await wait(800); + await whenSettled(page); }; await openUtilities(); @@ -152,10 +143,10 @@ await strict.run( { timeout: 3000 }, index + 1, ); - await wait(150); + await whenSettled(page); } await page.mouse.move(...AREA[3]); - await wait(300); + await whenSettled(page); const sketch = await tool(); await page.screenshot({ path: `${SHOTS}/navara-sketch.png` }); await strict.check( @@ -173,7 +164,7 @@ await strict.run( await page.waitForFunction(() => window.__gevAnnotations.count() === 1, { timeout: 10000, }); - await wait(800); + await whenSettled(page); const drawn = await amber(); await page.screenshot({ path: `${SHOTS}/navara-area.png` }); const area = await page.evaluate(() => @@ -241,7 +232,7 @@ await strict.run( assert.equal((await tool()).vertices, 2); await strict.pick(page, 'cesium'); await ready('cesium'); - await wait(1500); + await whenSettled(page); const switched = await tool(); await page.screenshot({ path: `${SHOTS}/cesium-after-switch.png` }); await strict.check( @@ -280,7 +271,7 @@ await strict.run( ); await strict.pick(page, 'navara'); await ready('navara'); - await wait(2000); + await whenSettled(page); const back = await tool(); const backAmber = await amber(); await page.screenshot({ path: `${SHOTS}/navara-back.png` }); diff --git a/scripts/qa-navara-geo-import.mjs b/scripts/qa-navara-geo-import.mjs index 728a09b42..69689b273 100644 --- a/scripts/qa-navara-geo-import.mjs +++ b/scripts/qa-navara-geo-import.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { waitForEngine } from './qa-strict-harness.mjs'; +import { whenSettled } from './qa/ready.mjs'; // UTILITIES ▸ Import on Navara under the strict network policy: GeoJSON, // KML, KMZ (a packed icon and a ground overlay) and GPX through the real // file input, each one row; Navara draws their shapes in the row colour and @@ -25,7 +27,6 @@ base.hash = `v=2&lat=${VIEW.lat}&lon=${VIEW.lon}&alt=${VIEW.alt}&heading=0&pitch const strict = createStrict({ origin: base.origin, layers: ['geo-import'] }); const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-geo-import'; fs.mkdirSync(SHOTS, { recursive: true }); -const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const TRAP = 'gev-qa-trap.invalid'; /* ── fixtures (as scripts/qa-geo-import.mjs) ───────────────────────────── */ @@ -148,17 +149,9 @@ await strict.run( page.on('pageerror', (error) => errors.push(error.message)); await page.goto(base.href); const ready = async (kind) => { + await waitForEngine(page, kind); await page.waitForFunction( - (k) => - window.__godsEyeView?.engine?.kind === k && - window.__gevGeoImport?.diagnostics().attached && - !document.querySelector( - '#globe-engine:is([disabled], [aria-busy="true"])', - ) && - !document.querySelector('.engine-switch-frame') && - document.querySelector('#loading-screen.hidden'), - { timeout: 120000 }, - kind, + () => window.__gevGeoImport?.diagnostics().attached, ); await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, @@ -182,7 +175,7 @@ await strict.run( /** Pixels near the first file's colour (#39d0ff) on the canvas. */ const cyan = async () => { await page.evaluate(() => window.__godsEyeView.requestRender?.('qa')); - await wait(900); + await whenSettled(page); const { data, info } = await sharp( await page.screenshot({ clip: { x: 400, y: 260, width: 480, height: 300 }, @@ -257,7 +250,7 @@ await strict.run( { pins: imported.pins }, ); // The first file is flown to after the batch; settle there. - await wait(3000); + await whenSettled(page); await page.screenshot({ path: `${SHOTS}/navara-imported.png` }); for (const kind of ['navara', 'cesium', 'navara']) { @@ -270,7 +263,7 @@ await strict.run( () => window.__gevGeoImport.diagnostics().drawn === 4, { timeout: 60000 }, ); - await wait(2500); + await whenSettled(page); await page.screenshot({ path: `${SHOTS}/${kind}-after-switch.png` }); } const shown = await cyan(); @@ -302,11 +295,14 @@ await strict.run( const before = engine.camera.height(); const [first] = window.__gevGeoImport.list(); const ok = window.__gevGeoImport.flyTo(first.id); - await new Promise((r) => setTimeout(r, 2500)); - return { ok, before, after: engine.camera.height() }; + return { ok, before }; }, fromDegrees(-100, 40, 3e6), ); + await whenSettled(page); + flight.after = await page.evaluate(() => + window.__godsEyeView.engine.camera.height(), + ); await strict.check( page, 'geo-import', diff --git a/scripts/qa-navara-scope.mjs b/scripts/qa-navara-scope.mjs index efce19245..7a8ef7851 100644 --- a/scripts/qa-navara-scope.mjs +++ b/scripts/qa-navara-scope.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { whenSettled } from './qa/ready.mjs'; // The DISPLAY scope mask and the celestial ring on Navara, under the strict // network policy: at city altitude the mask canvas sits in the engine's // container with an opaque outside, the screen's edge goes dark with the @@ -26,7 +27,7 @@ const strict = createStrict({ }); const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-scope'; fs.mkdirSync(SHOTS, { recursive: true }); -const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + // The screen's right edge below the rail: outside the keyhole, clear of UI. const GLOBE = fromDegrees(-122.4194, 37.7749, 20_000_000); const CITY = fromDegrees(-122.4194, 37.7749, 3000); @@ -47,7 +48,7 @@ await strict.run( await page.goto(base.href); await waitForEngine(page, 'navara'); const edgeLuminance = async () => { - await wait(900); + await whenSettled(page); const { data, info } = await sharp(await page.screenshot({ clip: EDGE })) .removeAlpha() .raw() @@ -75,7 +76,7 @@ await strict.run( ) { await strict.switchTo(page, kind); } - await wait(2500); + await whenSettled(page); const mask = await page.evaluate(() => { const canvas = document.getElementById('scope-mask'); const { engine } = window.__godsEyeView; @@ -133,7 +134,7 @@ await strict.run( engine.camera.setView({ destination }); styleManager.setCelestialRingEnabled(true); }, GLOBE); - await wait(2500); + await whenSettled(page); const ring = await page.evaluate(() => { const overlay = document.getElementById('celestial-ring-overlay'); const canvases = [...(overlay?.querySelectorAll('canvas') ?? [])]; @@ -165,7 +166,7 @@ await strict.run( styleManager.setCelestialRingEnabled(false); engine.camera.setView({ destination }); }, CITY); - await wait(1000); + await whenSettled(page); } assert.deepEqual(errors, []); console.log('PASS scope mask and celestial ring on both engines'); diff --git a/scripts/qa-navara-sonar.mjs b/scripts/qa-navara-sonar.mjs index 6b8aeb376..f9c8088a9 100644 --- a/scripts/qa-navara-sonar.mjs +++ b/scripts/qa-navara-sonar.mjs @@ -1,3 +1,4 @@ +import { whenSettled } from './qa/ready.mjs'; // The Cyber HUD sonar on Navara: with the Cyber HUD on, the // sweep lights the contacts it passes and dims the rest to the opacity // floor, by each contact's place on screen exactly as the CPU sampler of @@ -85,15 +86,23 @@ const read = (page, places) => }, places); // Frames the engine draws in `ms` without being asked. -const framesIn = (page, ms) => - page.evaluate(async (ms) => { - const { engine } = window.__godsEyeView; - let frames = 0; - const off = engine.frames.postRender.addEventListener(() => frames++); - await new Promise((resolve) => setTimeout(resolve, ms)); - off(); - return frames; - }, ms); +const framesIn = async (page, ms) => { + await page.evaluate(() => { + const sample = (window.__qaSonarFrames = { frames: 0, at: performance.now() }); + sample.off = window.__godsEyeView.engine.frames.postRender.addEventListener( + () => sample.frames++, + ); + }); + await page.waitForFunction( + (ms) => performance.now() - window.__qaSonarFrames.at >= ms, + {}, + ms, + ); + return page.evaluate(() => { + window.__qaSonarFrames.off(); + return window.__qaSonarFrames.frames; + }); +}; const mean = (values) => values.reduce((sum, value) => sum + value, 0) / Math.max(1, values.length); @@ -165,8 +174,16 @@ await strict.run({}, async (browser) => { ); const sweeps = []; - for (const wait of [0, 1300, 1300]) { - await new Promise((resolve) => setTimeout(resolve, wait)); + for (let sample = 0; sample < 3; sample++) { + if (sample) + await page.waitForFunction( + (previous) => { + const angle = window.__godsEyeView.engine.render.contactSonar().angleDeg; + return (angle - previous + 360) % 360 > 30; + }, + {}, + sweeps.at(-1).angle, + ); const { luma, sonar } = await read(page, places); const expected = places.map(({ x, y }) => sonarFactorAt(x, y, WIDTH, HEIGHT, sonar), @@ -219,7 +236,7 @@ await strict.run({}, async (browser) => { const spread = Math.max(...neutral.luma.slice(0, 32)) - Math.min(...neutral.luma.slice(0, 32)); - await new Promise((resolve) => setTimeout(resolve, 300)); + await whenSettled(page); const idle = await framesIn(page, 1500); check( 'with the sweep off every contact takes the neutral factor and the scene rests', diff --git a/scripts/qa-nepal-event-switch.mjs b/scripts/qa-nepal-event-switch.mjs index 3f9aeb775..b686a3ddf 100644 --- a/scripts/qa-nepal-event-switch.mjs +++ b/scripts/qa-nepal-event-switch.mjs @@ -1,3 +1,4 @@ +import { whenSettled } from './qa/ready.mjs'; import sharp from 'sharp'; import assert from 'node:assert/strict'; import { @@ -89,7 +90,7 @@ await strict.run({}, async (b) => { }); }); const sample = async () => { - await new Promise((r) => setTimeout(r, 500)); + await whenSettled(p); return sharp(await p.screenshot()) .extract({ left: 490, top: 370, width: 25, height: 25 }) .removeAlpha() diff --git a/scripts/qa-nepal-locator-switch.mjs b/scripts/qa-nepal-locator-switch.mjs index 95a9aa977..e33a6ffa4 100644 --- a/scripts/qa-nepal-locator-switch.mjs +++ b/scripts/qa-nepal-locator-switch.mjs @@ -1,3 +1,4 @@ +import { whenSettled } from './qa/ready.mjs'; import { fromDegrees } from '../src/geo/cartesian3.js'; import sharp from 'sharp'; import assert from 'node:assert/strict'; @@ -52,7 +53,7 @@ await strict.run({}, async (b) => { assert.equal(stats.count, 1); assert.equal(params.presentation, 'bhote-koshi-path-overview'); // Look for the cyan river in a scene crop away from the UI rails. - await new Promise((r) => setTimeout(r, 1000)); + await whenSettled(p); const crop = async () => sharp(await p.screenshot()) .extract({ left: 400, top: 320, width: 230, height: 180 }) @@ -63,7 +64,7 @@ await strict.run({}, async (b) => { await p.evaluate(() => window.qaLocator.setParams({ presentation: 'nepal-context' }), ); - await new Promise((resolve) => setTimeout(resolve, 500)); + await whenSettled(p); const baseline = await crop(); let added = 0; for (let i = 0; i < rendered.length; i += 3) { @@ -103,7 +104,7 @@ await strict.run({}, async (b) => { preview: true, }); }); - await new Promise((resolve) => setTimeout(resolve, 2500)); + await whenSettled(p); // The last of three quick presentation changes is the one that stays. const last = await p.evaluate(() => ({ params: window.qaLocator.getParams(), diff --git a/scripts/qa-open-layers-switch.mjs b/scripts/qa-open-layers-switch.mjs index 410e72022..826a70994 100644 --- a/scripts/qa-open-layers-switch.mjs +++ b/scripts/qa-open-layers-switch.mjs @@ -1,3 +1,4 @@ +import { whenSettled } from './qa/ready.mjs'; import sharp from 'sharp'; import assert from 'node:assert/strict'; import { @@ -151,7 +152,7 @@ await strict.run({}, async (b) => { const deadline = Date.now() + 20000; let on = await sample(); while (!test(on, off) && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 500)); + await whenSettled(p); on = await sample(); } return on; diff --git a/scripts/qa-space-engine-switch.mjs b/scripts/qa-space-engine-switch.mjs index b00adc8c1..57daab5cd 100644 --- a/scripts/qa-space-engine-switch.mjs +++ b/scripts/qa-space-engine-switch.mjs @@ -1,3 +1,4 @@ +import { waitForEngine } from './qa-strict-harness.mjs'; import { assertKeylessPreview } from './qa-strict-harness.mjs'; import assert from 'node:assert/strict'; import { launchFixture } from '../src/bench/launchFixture.js'; @@ -58,11 +59,7 @@ await strict.run({}, async (b) => { SATELLITE_FIXTURE_EPOCH, ); await p.goto(url.href); - await p.waitForFunction( - () => - window.__godsEyeView?.engine?.kind === 'navara' && - document.querySelector('#loading-screen.hidden'), - ); + await waitForEngine(p, 'navara'); await assertKeylessPreview(p); await p.evaluate(async (missions) => { const { dataManager, styleManager } = window.__godsEyeView; diff --git a/scripts/qa-street-level-engine-switch.mjs b/scripts/qa-street-level-engine-switch.mjs index b2efa8f3f..1dcb649ea 100644 --- a/scripts/qa-street-level-engine-switch.mjs +++ b/scripts/qa-street-level-engine-switch.mjs @@ -1,3 +1,4 @@ +import { whenPickable } from './qa/ready.mjs'; import assert from 'node:assert/strict'; import { createStrict, @@ -74,8 +75,9 @@ await strict.run({}, async (browser) => { await until(() => window.qaStreet.getUIState().coverage.count > 0); // The fixture drive passes under the middle of the canvas. Click its actual picked line. const pickLine = async () => { - const xy = await p.evaluate( - (prefix) => { + const xy = await whenPickable( + p, + () => { const { engine } = window.__godsEyeView, canvas = engine.host.canvas, rect = canvas.getBoundingClientRect(); @@ -84,14 +86,17 @@ await strict.run({}, async (browser) => { const at = { x: rect.width / 2 + dx, y: rect.height / 2 + dy }, pick = engine.picker.pick(at), id = pick?.id?.id ?? pick?.id; - if (typeof id === 'string' && id.startsWith(prefix)) + if (typeof id === 'string' && /^(pnx:seq:|kv:line:)/.test(id)) return { x: at.x + rect.x, y: at.y + rect.y, id }; } return null; }, - panorama ? 'pnx:seq:' : 'kv:line:', + (picked, position) => (picked?.id?.id ?? picked?.id) === position.id, + ); + assert.ok( + xy.id.startsWith(panorama ? 'pnx:seq:' : 'kv:line:'), + 'native ground line was pickable', ); - assert.ok(xy, 'native ground line was pickable'); console.log('pick', xy); await p.mouse.click(xy.x, xy.y); await until( @@ -186,7 +191,6 @@ await strict.run({}, async (browser) => { !window.qaStreet.getUIState().coverage.loading && window.qaStreet.getUIState().coverage.count > 0, ); - await new Promise((resolve) => setTimeout(resolve, 1000)); await pickLine(); assert.deepEqual(errors, []); assert.deepEqual(fixture.unknown, []); diff --git a/scripts/qa-strict-cold-chunks.mjs b/scripts/qa-strict-cold-chunks.mjs index e5e1b59f6..f57a6cb49 100644 --- a/scripts/qa-strict-cold-chunks.mjs +++ b/scripts/qa-strict-cold-chunks.mjs @@ -1,3 +1,4 @@ +import { whenSettled } from './qa/ready.mjs'; // Contract: a cold start loads only the engine it starts. From the network // log of a fresh page: // - a cold Navara start requests nothing of the Cesium library (/cesium/…: @@ -52,7 +53,8 @@ await strict.run({}, async (b) => { await installSessionFixtures(p); await openApp(p, base.origin, start); // Let lazy loads that follow the start (workers, idle prefetch) happen. - await new Promise((resolve) => setTimeout(resolve, 5000)); + await p.waitForNetworkIdle({ idleTime: 500, timeout: 30000 }); + await whenSettled(p); const before = [...urls]; const other = start === 'navara' ? 'cesium' : 'navara'; await strict.pick(p, other); diff --git a/scripts/qa-strict-context-loss.mjs b/scripts/qa-strict-context-loss.mjs index 60c530c6d..b0413540a 100644 --- a/scripts/qa-strict-context-loss.mjs +++ b/scripts/qa-strict-context-loss.mjs @@ -70,12 +70,14 @@ await strict.run({}, async (b) => { !document.querySelector( '#globe-engine:is([disabled], [aria-busy="true"])', ) && - !document.querySelector('.engine-switch-frame') && - document.querySelector('#loading-screen.hidden')) || + !document.querySelector('.engine-switch-frame')) || document.querySelector('.engine-switch-retry'), { polling: 100, timeout: 90000 }, ); - await new Promise((resolve) => setTimeout(resolve, 1500)); + if (!(await p.$('.engine-switch-retry'))) { + const kind = await p.$eval('#globe-engine', (el) => el.value); + await waitForEngine(p, kind); + } }; const intact = (state) => state.running && @@ -166,7 +168,7 @@ await strict.run({}, async (b) => { const outcome = after.engine === to ? 'switched' : 'rolled back'; if (after.running) await rememberEngine(p); const drawn = after.running ? await draws(after.engine) : false; - // Why no frame passed, when none did: frames drawn in 3 s by the engine + // Why no frame passed, when none did: three frames drawn by the engine // the page runs and by the one the probe watched, and how many of 48x48 // samples of the last frame differ from the first one. const noFrame = @@ -202,7 +204,23 @@ await strict.run({}, async (b) => { () => count.watched++, ), ]; - await new Promise((resolve) => setTimeout(resolve, 3000)); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + remove(); + reject(new Error('No running frames')); + }, 15000); + const remove = running.frames.postRender.addEventListener(() => { + if (count.running >= 3) { + remove(); + clearTimeout(timer); + resolve(); + } else + queueMicrotask(() => + running.render.request('qa-context-frames'), + ); + }); + running.render.request('qa-context-frames'); + }); offs.forEach((off) => off?.()); return { ...count, diff --git a/scripts/qa-strict-harness.mjs b/scripts/qa-strict-harness.mjs index de9d9821f..1d5afe35c 100644 --- a/scripts/qa-strict-harness.mjs +++ b/scripts/qa-strict-harness.mjs @@ -29,6 +29,30 @@ import { import { createMissLoader, tileCacheFile } from './qa/tileCache.mjs'; import { closeBrowser, diagnoseExit, exitAfterFlush } from './qa/browser.mjs'; import { DAYTIME, shiftTime } from './qa/time.mjs'; +import { whenSettled } from './qa/ready.mjs'; +import { drawLedger, compareLedgers } from './qa/ledger.mjs'; +import { createContractMonitor } from '../src/globe/testing/contractMonitor.js'; + +const ledgerAllow = JSON.parse( + fs.readFileSync(new URL('./qa-ledger-allow.json', import.meta.url), 'utf8'), +); +const drawComparisons = new WeakMap(); +const pendingDraws = new WeakMap(); + +async function finishDrawComparison(page) { + const pending = pendingDraws.get(page); + if (!pending) return; + const after = await drawLedger(page); + const settledEngine = await page.evaluate( + () => window.__godsEyeView.engine.kind, + ); + pendingDraws.delete(page); + for (const draw of pending) { + draw.after = after; + draw.settledEngine = settledEngine; + draw.comparison = compareLedgers(draw.before, after, ledgerAllow); + } +} export const ENGINES = Object.freeze(['cesium', 'navara']); /** What the Globe Engine picker offers: the engines and Navara HD. */ @@ -363,11 +387,28 @@ async function trapCesiumGlobal(session, ledger, time) { else if (ledger.cesiumBeforeLoad.length < 50) ledger.cesiumBeforeLoad.push({ stack, count: 1 }); }); + const monitorBinding = '__gevStrictMonitor'; + const monitors = new Map(); + session.on('Runtime.bindingCalled', (event) => { + if (event.name !== monitorBinding) return; + const { id, kind, record, finished } = JSON.parse(event.payload); + const key = `${event.executionContextId}:${id}`; + let entry = monitors.get(key); + if (!entry) { + entry = { id: ledger.contractMonitors.length + 1, kind, records: [] }; + monitors.set(key, entry); + ledger.contractMonitors.push(entry); + } + if (record) entry.records.push(record); + if (finished) entry.finished = true; + }); await session.send('Runtime.enable'); + await session.send('Runtime.addBinding', { name: monitorBinding }); await session.send('Runtime.addBinding', { name: binding }); await session.send('Page.enable'); await session.send('Page.addScriptToEvaluateOnNewDocument', { source: `(${shiftTime})(${JSON.stringify(time)}); + (${installContractMonitors})(${createContractMonitor}); (() => { if (window.top !== window) return; const seen = new Set(); @@ -396,6 +437,74 @@ async function trapCesiumGlobal(session, ledger, time) { }); } +/** Injected before the app: follow the existing debug handle across attachments. */ +export function installContractMonitors(createMonitor) { + if (window.top !== window || typeof document === 'undefined') return; + const entries = new WeakMap(); + const engines = []; + const handles = new WeakSet(); + const attach = (engine) => { + if (!engine || entries.has(engine)) return; + const id = engines.length + 1; + engines.push(new WeakRef(engine)); + const send = (value) => + window.__gevStrictMonitor?.( + JSON.stringify({ id, kind: engine.kind, ...value }), + ); + send({}); + const monitor = createMonitor(engine, (record) => send({ record })); + entries.set(engine, { monitor, send }); + }; + const observe = (debug) => { + if (!debug || handles.has(debug)) return; + handles.add(debug); + let engine = debug.engine; + attach(engine); + Object.defineProperty(debug, 'engine', { + configurable: true, + enumerable: true, + get: () => engine, + set(value) { + engine = value; + attach(value); + }, + }); + }; + const arm = () => { + const descriptor = Object.getOwnPropertyDescriptor(window, '__godsEyeView'); + if (descriptor?.get) return; + let debug = window.__godsEyeView; + observe(debug); + Object.defineProperty(window, '__godsEyeView', { + configurable: true, + get: () => debug, + set(value) { + debug = value; + observe(value); + }, + }); + }; + arm(); + new MutationObserver(arm).observe(document, { + childList: true, + subtree: true, + }); + let finishing; + window.__qaFinishMonitors = () => + (finishing ??= Promise.all( + engines.map(async (ref) => { + const engine = ref.deref(); + if (!engine) return; + const { monitor, send } = entries.get(engine); + await monitor.finish({ + active: engine === window.__godsEyeView?.engine, + }); + send({ finished: true }); + }), + )); + window.addEventListener('pagehide', window.__qaFinishMonitors); +} + /** A new, empty network ledger. */ export function networkLedger() { return { @@ -416,6 +525,7 @@ export function networkLedger() { violations: [], setupErrors: [], cesiumBeforeLoad: [], + contractMonitors: [], }; } @@ -532,6 +642,9 @@ export async function pickEngine( 'globe-engine', 'the click focuses the picker', ); + const draw = { from, to: kind, before: await drawLedger(page) }; + drawComparisons.get(page)?.push(draw); + pendingDraws.set(page, [...(pendingDraws.get(page) ?? []), draw]); await page.keyboard.type(kind[0]); // Whatever the person does next, right after the key (no round trip to // the page in between). @@ -555,12 +668,13 @@ export async function pickEngine( (window.__qaPick.busy && !document.querySelector( '#globe-engine:is([disabled], [aria-busy="true"])', - ) && - document.querySelector('#loading-screen.hidden') && - !document.querySelector('.engine-switch-frame')) || - document.querySelector('.engine-switch-retry'), + )) || document.querySelector('.engine-switch-retry'), { timeout: 120000, polling: 50 }, ); + if (!(await page.$('.engine-switch-retry'))) { + const running = await page.$eval('#globe-engine', (el) => el.value); + await waitForEngine(page, running); + } await page.evaluate(() => window.__qaPickObserver?.disconnect()); Object.assign( how, @@ -583,23 +697,22 @@ export async function pickEngine( /** Wait until `kind` runs, its picker is idle and its view is restored. */ export async function waitForEngine(page, kind, { timeout = 120000 } = {}) { + const started = Date.now(); await page.waitForFunction( (expected) => - // Navara HD runs the Navara engine. window.__godsEyeView?.engine?.kind === (expected === 'cesium' ? 'cesium' : 'navara') && document.querySelector('#globe-engine')?.value === expected && !document.querySelector( '#globe-engine:is([disabled], [aria-busy="true"])', - ) && - !document.querySelector('.engine-switch-frame') && - document.querySelector('#loading-screen.hidden'), + ), { timeout }, kind, ); - await page.evaluate( - () => window.__godsEyeView.styleManager.initialRestorePromise, - ); + await whenSettled(page, { + timeoutMs: Math.max(1, timeout - (Date.now() - started)), + }); + await finishDrawComparison(page); await assertKeylessPreview(page); } @@ -668,6 +781,7 @@ export function createStrict({ const ledger = networkLedger(); const assertions = []; const picks = []; + const draws = []; const timings = []; const extra = {}; let finished = false; @@ -718,6 +832,29 @@ export function createStrict({ await closeBrowser(browser); throw error; } + const newPage = browser.newPage.bind(browser); + browser.newPage = async (...args) => { + const page = await newPage(...args); + drawComparisons.set(page, draws); + const close = page.close.bind(page); + page.close = async (...args) => { + await page + .evaluate(() => window.__qaFinishMonitors?.()) + .catch(() => {}); + return close(...args); + }; + return page; + }; + const close = browser.close.bind(browser); + browser.close = async (...args) => { + const pages = await browser.pages().catch(() => []); + await Promise.all( + pages.map((page) => + page.evaluate(() => window.__qaFinishMonitors?.()).catch(() => {}), + ), + ); + return close(...args); + }; return browser; }, /** Launch with `options`, run `body(browser)`, close, then finish(). */ @@ -859,6 +996,8 @@ export function createStrict({ assertions, network, timings, + drawLedgers: draws, + contractMonitors: ledger.contractMonitors, ...extra, }; if (args.evidence) { @@ -937,18 +1076,5 @@ export async function clickEmptyGround( * inputs off, and a click does not cut it short. */ export async function waitForCameraAtRest(page, { timeout = 15000 } = {}) { - await page.evaluate(() => { - window.__qaRestPose = null; - }); - await page.waitForFunction( - () => { - const { engine } = window.__godsEyeView; - const { x, y, z } = engine.camera.pose().position; - const pose = [x, y, z].map((value) => value.toFixed(2)).join(); - const still = window.__qaRestPose === pose; - window.__qaRestPose = pose; - return still && engine.input.cameraInputsEnabled(); - }, - { timeout, polling: 100 }, - ); + await whenSettled(page, { timeoutMs: timeout }); } diff --git a/scripts/qa-strict-harness.test.mjs b/scripts/qa-strict-harness.test.mjs index cd2b5b7df..ae9467b15 100644 --- a/scripts/qa-strict-harness.test.mjs +++ b/scripts/qa-strict-harness.test.mjs @@ -10,7 +10,9 @@ import { DAYTIME, daytimeNear, shiftTime } from './qa/time.mjs'; import { createStrict, installStrictNetwork, + installContractMonitors, networkLedger, + pickEngine, } from './qa-strict-harness.mjs'; function fakeBrowser(t, close = async () => {}) { @@ -303,3 +305,538 @@ test('strict.run flushes large output and exits despite active resources on succ if (fail) assert.match(result.stderr, /Error: body failed/); } }); + +// Production readiness runs the same state observer as the dev-only probe. +import { createFakeEngine } from '../src/globe/testing/fakeEngine.js'; +import { createSettledState } from '../src/app/qaProbe.js'; +import { + clickUntilSelected, + waitForSettled, + whenPickable, + whenSettled, +} from './qa/ready.mjs'; +import { drawLedger, compareLedgers } from './qa/ledger.mjs'; +import { createContractMonitor } from '../src/globe/testing/contractMonitor.js'; +import { QA_HARNESS_METRICS } from './report-qa-harness.mjs'; +import { waitForFirstFrame } from '../src/standalone/engineSwitch.js'; + +test('readiness counts drawn frames and resets on orientation, flight, and input changes', async () => { + const engine = createFakeEngine(); + const restore = Promise.withResolvers(); + const state = createSettledState(engine, () => restore.promise); + assert.deepEqual(state.settled().unmet, [ + 'initialViewRestored', + 'frameDrawn', + 'cameraPoseStable', + ]); + engine.fake.frames(4); + assert.deepEqual(state.settled().unmet, ['initialViewRestored']); + restore.resolve(); + await restore.promise; + assert.equal(state.settled().settled, true); + engine.fake.setPose({ heading: 0.2 }); + assert.equal(state.settled().settled, false); + engine.fake.frames(3); + assert.equal(state.settled().settled, false); + engine.fake.frame(); + assert.equal(state.settled().settled, true); + const resume = engine.input.suspendCameraInputs(); + engine.fake.frame(); + assert.ok(state.settled().unmet.includes('cameraInputsEnabled')); + resume(); + engine.camera.flyTo({ destination: { x: 7000000, y: 0, z: 0 }, duration: 1 }); + engine.fake.frames(4); + assert.ok(state.settled().unmet.includes('noFlight')); + engine.camera.cancelFlight(); + engine.fake.frames(4); + assert.equal(state.settled().settled, true); + state.destroy(); + assert.equal(engine.frames.postRender.numberOfListeners, 0); +}); + +test('readiness follows a replaced or rejected restore promise', async () => { + const engine = createFakeEngine(); + let promise = Promise.resolve(); + const state = createSettledState(engine, () => promise); + await promise; + engine.fake.frames(4); + assert.equal(state.settled().settled, true); + promise = Promise.reject(new Error('restore failed')); + state.settled(); + await promise.catch(() => {}); + assert.deepEqual(state.settled().unmet, ['initialViewRestored']); + assert.match(state.settled().restoreError, /restore failed/); + state.destroy(); +}); + +test('readiness requests real frames and cleans up on success and timeout', async (t) => { + const engine = createFakeEngine(); + globalThis.window = { + __godsEyeView: { + engine, + styleManager: { initialRestorePromise: Promise.resolve() }, + }, + }; + t.after(() => { + delete globalThis.window; + }); + engine.render.request = () => setImmediate(() => engine.fake.frame()); + const result = await waitForSettled(createSettledState, { + frames: 3, + timeoutMs: 1000, + }); + assert.equal(result.settled, true); + assert.equal(result.framesDrawn, 4); + assert.equal(engine.frames.postRender.numberOfListeners, 0); + engine.render.request = () => {}; + await assert.rejects( + waitForSettled(createSettledState, { frames: 3, timeoutMs: 10 }), + /frameDrawn, cameraPoseStable/, + ); + assert.equal(engine.frames.postRender.numberOfListeners, 0); +}); + +test('readiness reports missing startup conditions instead of a selector timeout', async () => { + await assert.rejects( + whenSettled( + { + waitForFunction() { + throw new Error('selector'); + }, + }, + { timeoutMs: 1 }, + ), + /engineStarted, initialViewRestored/, + ); +}); + +test('readiness reports destruction without reading a released camera', async () => { + const engine = createFakeEngine(); + const state = createSettledState(engine, () => Promise.resolve()); + engine.destroy(); + engine.camera.pose = () => { + throw new Error('camera released'); + }; + assert.ok(state.settled().unmet.includes('engineStarted')); + state.destroy(); +}); + +test('pick readiness waits beyond camera rest for the intended target and requests frames', async (t) => { + const engine = createFakeEngine(); + const restore = Promise.resolve(); + const state = createSettledState(engine, () => restore); + await restore; + engine.fake.frames(4); + assert.equal(state.settled().settled, true); + state.destroy(); + const window = { __godsEyeView: { engine } }; + engine.host.canvas.getBoundingClientRect = () => ({ left: 40, top: 25 }); + const request = t.mock.method(engine.render, 'request'); + let picked; + t.mock.method(engine.picker, 'pick', (position) => { + assert.deepEqual({ ...position }, { x: 60, y: 55 }); + return picked; + }); + const dispose = t.mock.fn(); + const position = { x: 100, y: 80 }; + const result = await whenPickable( + { + async waitForFunction(source, options) { + assert.deepEqual(options, { polling: 50, timeout: 30000 }); + const poll = () => vm.runInNewContext(source, { window }); + assert.equal(poll(), false); + engine.fake.frame(); + picked = { id: 'another-vessel' }; + assert.equal(poll(), false); + engine.fake.frame(); + picked = { id: '123456789' }; + picked.object = picked; + const value = poll(); + return { jsonValue: () => JSON.parse(JSON.stringify(value)), dispose }; + }, + }, + position, + (picked) => picked?.id === '123456789', + ); + assert.deepEqual(result, position); + assert.equal(request.mock.callCount(), 3); + assert.ok( + request.mock.calls.every( + ({ arguments: args }) => args[0] === 'qa-pick-readiness', + ), + ); + assert.equal(dispose.mock.callCount(), 1); + assert.equal(engine.frames.postRender.numberOfListeners, 0); +}); + +test('pick readiness retries a page-side locator and keeps its target identity', async (t) => { + const engine = createFakeEngine(); + engine.host.canvas.getBoundingClientRect = () => ({ left: 0, top: 0 }); + const window = { __godsEyeView: { engine }, target: null }; + const pick = t.mock.method(engine.picker, 'pick', () => ({ id: 'line' })); + const dispose = t.mock.fn(); + const result = await whenPickable( + { + async waitForFunction(source) { + const poll = () => vm.runInNewContext(source, { window }); + assert.equal(poll(), false); + assert.equal(pick.mock.callCount(), 0); + window.target = { x: 12, y: 34, id: 'line' }; + return { jsonValue: () => poll(), dispose }; + }, + }, + () => window.target, + (picked, position) => picked?.id === position.id, + ); + assert.deepEqual(result, window.target); + assert.equal(dispose.mock.callCount(), 1); +}); + +test('pick readiness reports a bounded timeout for a missing target or unavailable engine', async (t) => { + for (const kind of ['missing target', 'missing engine', 'destroyed engine']) { + await t.test(kind, async (t) => { + const engine = createFakeEngine(); + engine.host.canvas.getBoundingClientRect = () => ({ left: 0, top: 0 }); + if (kind === 'destroyed engine') engine.destroy(); + const window = { + __godsEyeView: kind === 'missing engine' ? undefined : { engine }, + }; + const pick = t.mock.method(engine.picker, 'pick', () => undefined); + const timeout = new Error('waiting failed'); + timeout.name = 'TimeoutError'; + await assert.rejects( + whenPickable( + { + async waitForFunction(source, options) { + assert.equal(options.timeout, 10); + assert.equal(vm.runInNewContext(source, { window }), false); + throw timeout; + }, + }, + { x: 1, y: 2 }, + (picked) => picked?.id === 'vessel', + { timeoutMs: 10 }, + ), + (error) => + error.message === 'Readiness timeout: pickable target' && + error.cause === timeout, + ); + assert.equal(pick.mock.callCount(), kind === 'missing target' ? 1 : 0); + assert.equal(engine.frames.postRender.numberOfListeners, 0); + }); + } +}); + +test('pick readiness preserves picker errors and releases handles after a failed read', async () => { + const failure = new Error('pick failed'); + await assert.rejects( + whenPickable( + { + waitForFunction: async () => { + throw failure; + }, + }, + { x: 1, y: 2 }, + () => true, + ), + (error) => error === failure, + ); + let disposed = false; + await assert.rejects( + whenPickable( + { + waitForFunction: async () => ({ + jsonValue: async () => { + throw failure; + }, + dispose: () => { + disposed = true; + }, + }), + }, + { x: 1, y: 2 }, + () => true, + ), + (error) => error === failure, + ); + assert.equal(disposed, true); +}); + +test('selection readiness retries lost clicks and stops as soon as selection succeeds', async (t) => { + for (const succeedsAt of [1, 2, 5]) { + await t.test(`selection on click ${succeedsAt}`, async (t) => { + let clicks = 0; + let selected = null; + const selection = () => selected?.mmsi === '123456789'; + const dispose = t.mock.fn(); + await clickUntilSelected( + { + async waitForFunction(predicate, options) { + assert.equal(predicate, selection); + assert.deepEqual(options, { timeout: 2000 }); + if (predicate()) return { dispose }; + const timeout = new Error('no selection'); + timeout.name = 'TimeoutError'; + throw timeout; + }, + }, + async () => { + clicks++; + if (clicks === succeedsAt) selected = { mmsi: '123456789' }; + }, + selection, + ); + assert.equal(clicks, succeedsAt); + assert.equal(dispose.mock.callCount(), 1); + }); + } +}); + +test('selection readiness fails after five unselected clicks', async () => { + let clicks = 0; + let waits = 0; + const timeout = new Error('no selection'); + timeout.name = 'TimeoutError'; + await assert.rejects( + clickUntilSelected( + { + async waitForFunction(predicate, options) { + waits++; + assert.equal(predicate(), false); + assert.deepEqual(options, { timeout: 2000 }); + throw timeout; + }, + }, + async () => clicks++, + () => false, + ), + (error) => + error.message === 'Readiness timeout: selection after 5 clicks' && + error.cause === timeout, + ); + assert.equal(clicks, 5); + assert.equal(waits, 5); +}); + +test('selection readiness does not retry click or page errors', async (t) => { + for (const failureAt of ['click', 'selection']) { + await t.test(failureAt, async () => { + let clicks = 0; + const failure = new Error('page closed'); + await assert.rejects( + clickUntilSelected( + { + async waitForFunction() { + throw failure; + }, + }, + async () => { + clicks++; + if (failureAt === 'click') throw failure; + }, + () => false, + ), + (error) => error === failure, + ); + assert.equal(clicks, 1); + }); + } +}); + +test('draw ledgers normalize all five families and compare absences and visibility', async (t) => { + const engine = createFakeEngine(); + engine.movers.createSet({ + id: 'point', + kind: 'point', + capacity: 1, + point: { sizePx: 4, color: [1, 1, 1, 1] }, + }); + engine.lines.createLine({ id: 'line', color: [1, 1, 1, 1], widthPx: 1 }); + engine.ground.createSet({ id: 'ground', kind: 'disc' }); + engine.imagery.list = () => [{ id: 'tiles', kind: 'xyz', shown: true }]; + engine.meshes.list = () => [{ id: 'model', shown: true }]; + globalThis.window = { __godsEyeView: { engine } }; + t.after(() => { + delete globalThis.window; + }); + const before = await drawLedger({ evaluate: (fn) => fn() }); + assert.ok(before.every((row) => row.shown === true)); + assert.deepEqual( + before.map((row) => row.family), + ['ground', 'imagery', 'lines', 'meshes', 'movers'], + ); + const after = before + .filter((row) => row.id !== 'line') + .map((row) => ({ ...row, shown: row.id !== 'point' })); + after.push({ id: 'overlay', family: 'imagery' }); + const comparison = compareLedgers(before, after, [ + { id: 'overlay', reason: 'engine overlay' }, + ]); + assert.deepEqual(comparison.onlyBefore, [{ id: 'line', family: 'lines' }]); + assert.deepEqual(comparison.onlyAfter, []); + assert.deepEqual(comparison.shown, [ + { id: 'point', family: 'movers', before: true, after: false }, + ]); + assert.equal(comparison.allowed[0].reason, 'engine overlay'); + assert.deepEqual( + compareLedgers( + [{ id: 'same', family: 'movers' }], + [{ id: 'same', family: 'movers', shown: true }], + ).shown, + [], + ); +}); + +test('the strict scenario set has no private launches, sleeps, or loading-screen readiness', () => { + const runner = fs.readFileSync( + new URL('./qa-all-layer-switches.mjs', import.meta.url), + 'utf8', + ); + const runs = vm.runInNewContext( + runner.match(/const runs = ([\s\S]*?);\n/)[1], + { soakRuns: [], longRun: '' }, + ); + const files = [...new Set(runs.map(([, script]) => script))].map((name) => ({ + file: `qa-${name}.mjs`, + text: fs.readFileSync(new URL(`./qa-${name}.mjs`, import.meta.url), 'utf8'), + })); + for (const metric of QA_HARNESS_METRICS.filter(({ id }) => + ['ownLaunch', 'fixedSleeps', 'loadingScreenReadiness'].includes(id), + )) { + assert.deepEqual( + files.filter((file) => metric.measure([file])).map(({ file }) => file), + [], + metric.id, + ); + } +}); + +test('the injected monitor follows debug-handle attachments and streams evidence before close', async () => { + const rows = []; + const first = createFakeEngine({ kind: 'navara' }); + const second = createFakeEngine({ kind: 'cesium' }); + const context = vm.createContext({ + document: {}, + MutationObserver: class { + observe() {} + }, + first, + second, + queueMicrotask, + setTimeout, + clearTimeout, + report: (json) => rows.push(JSON.parse(json)), + }); + vm.runInContext( + ` + globalThis.window = globalThis; + window.top = window; + window.addEventListener = () => {}; + window.__gevStrictMonitor = report; + (${installContractMonitors})(${createContractMonitor}); + const debug = { engine: first }; + window.__godsEyeView = debug; + delete window.__godsEyeView; + debug.engine = null; + debug.engine = second; + window.__godsEyeView = debug; + `, + context, + ); + assert.deepEqual( + rows.map(({ kind }) => kind), + ['navara', 'cesium'], + ); + first.destroy(); + first.fake.emit('camera.changed'); + second.input.suspendCameraInputs(); + await vm.runInContext('window.__qaFinishMonitors()', context); + await vm.runInContext('window.__qaFinishMonitors()', context); + assert.deepEqual( + rows.filter(({ record }) => record).map(({ record }) => record.invariant), + ['event-after-destroy', 'final-inputs-disabled'], + ); + assert.equal(rows.filter(({ finished }) => finished).length, 2); +}); + +test('the first-frame outcome names the actual frame, visibility, or timeout path', async () => { + for (const path of ['frame', 'hidden', 'timeout']) { + const engine = createFakeEngine({ canvas: { dataset: {} } }); + let timer; + const result = waitForFirstFrame(engine, { + document: { hidden: path === 'hidden' }, + setTimer: (callback) => { + timer = callback; + }, + clearTimer() {}, + }); + if (path === 'frame') { + engine.fake.frame(); + timer(); + } + if (path === 'timeout') timer(); + assert.equal(await result, path === 'frame'); + assert.equal(engine.host.canvas.dataset.firstFrameOutcome, path); + } +}); + +test('picker readiness also settles the surviving engine after a rollback', async (t) => { + const engine = createFakeEngine({ kind: 'navara' }); + const element = { + value: 'navara', + tagName: 'SELECT', + id: 'globe-engine', + className: '', + closest: () => element, + getAttribute: () => 'true', + getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 30 }), + addEventListener() {}, + }; + globalThis.window = { + __godsEyeView: { + engine, + styleManager: { initialRestorePromise: Promise.resolve() }, + }, + }; + globalThis.document = { + activeElement: element, + querySelector: (selector) => + selector === '#globe-engine' ? element : null, + elementFromPoint: () => element, + }; + globalThis.MutationObserver = class { + observe() {} + disconnect() {} + }; + t.after(() => { + delete globalThis.window; + delete globalThis.document; + delete globalThis.MutationObserver; + }); + engine.render.request = () => setImmediate(() => engine.fake.frame()); + const page = { + $: async () => null, + $eval: async (selector, fn) => fn(element), + $$eval: async (selector, fn) => + fn([{ value: 'cesium' }, { value: 'navara' }]), + evaluate: async (fn) => + typeof fn === 'string' ? vm.runInThisContext(fn) : fn(), + waitForSelector: async () => {}, + waitForFunction: async (fn, options, arg) => assert.ok(fn(arg)), + mouse: { click: async () => {} }, + keyboard: { + type: async () => { + // A failed attempt leaves the original engine and picker value intact. + Object.assign(window.__qaPick, { + changed: true, + busy: true, + at: 1, + idleAt: 2, + }); + }, + }, + }; + const result = await pickEngine(page, 'cesium'); + assert.equal(result.idleAt, 2); + assert.equal(engine.frames.postRender.numberOfListeners, 0); +}); diff --git a/scripts/qa-strict-hidden-switch.mjs b/scripts/qa-strict-hidden-switch.mjs index 1d2b9ce64..508b7baaa 100644 --- a/scripts/qa-strict-hidden-switch.mjs +++ b/scripts/qa-strict-hidden-switch.mjs @@ -7,7 +7,11 @@ // node scripts/qa-strict-hidden-switch.mjs http://127.0.0.1:4208 import assert from 'node:assert/strict'; -import { createStrict, localPreview } from './qa-strict-harness.mjs'; +import { + createStrict, + localPreview, + waitForEngine, +} from './qa-strict-harness.mjs'; import { assertSession, installFirstFrameProbe, @@ -76,8 +80,7 @@ await strict.run({}, async (b) => { !document.querySelector( '#globe-engine:is([disabled], [aria-busy="true"])', ) && - !document.querySelector('.engine-switch-frame') && - document.querySelector('#loading-screen.hidden'), + !document.querySelector('.engine-switch-frame'), { polling: 250, timeout: 60000 }, kind, ); @@ -86,6 +89,8 @@ await strict.run({}, async (b) => { hide: window.__qaHidden.shown.find((e) => e.state === 'hidden') ?? null, pick: { at: window.__qaPick.at, idleAt: window.__qaPick.idleAt }, drawn: window.__qaHidden.drawn ?? null, + coverPath: + window.__godsEyeView.engine.host.canvas.dataset.firstFrameOutcome, })); await assertSession( p, @@ -111,10 +116,12 @@ await strict.run({}, async (b) => { { polling: 250, timeout: 30000 }, reads, ); + await waitForEngine(p, kind); await rememberEngine(p); const row = { kind, temperature, + coverPath: hidden.coverPath, hiddenWhileBusy: Boolean(hidden.hide?.busy), settledWhileHidden: hidden.state === 'hidden', // From the tab being hidden to the picker being idle again. @@ -131,9 +138,9 @@ await strict.run({}, async (b) => { results.push(row); assert.ok(row.hiddenWhileBusy, 'the tab is hidden while the switch runs'); assert.ok(row.settledWhileHidden, 'the switch settles in the hidden tab'); - // A hidden tab draws nothing: no first-frame wait (10 s) there. + // The cover must leave by a frame or visibility change, never its timeout. assert.ok( - row.settleAfterHideMs !== null && row.settleAfterHideMs < 9000, + ['frame', 'hidden'].includes(row.coverPath), JSON.stringify(row), ); assert.equal(row.history, 1, 'one history entry'); diff --git a/scripts/qa-strict-parked-release.mjs b/scripts/qa-strict-parked-release.mjs index db44ada6b..2f5f39c63 100644 --- a/scripts/qa-strict-parked-release.mjs +++ b/scripts/qa-strict-parked-release.mjs @@ -1,4 +1,4 @@ -// Contract: a parked globe is released after ?parkedMs= (8 s here; three +// Contract: a parked globe is released after ?parkedMs= (one minute here; three // minutes by default). A switch back in time is warm (the same engine // object); after the delay the parked engine's WebGL context is lost and a // switch back builds a new engine on the same session that draws a @@ -21,9 +21,9 @@ import { startSession, } from './qa-strict-session.mjs'; -// Room for a cold switch on a busy machine (2-3 s) before the switch back. -const PARKED_MS = 8000; const base = localPreview(process.argv[2]); +// Leave a margin for both complete switches on a busy GPU. +const PARKED_MS = Number(base.searchParams.get('parkedMs') ?? 60000); const other = (kind) => (kind === 'navara' ? 'cesium' : 'navara'); const strict = createStrict({ origin: base.origin, layers: [] }); await strict.run({}, async (b) => { @@ -70,7 +70,6 @@ await strict.run({}, async (b) => { ]), kind, ); - const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); for (const parked of ['navara', 'cesium']) { const running = other(parked); @@ -82,7 +81,6 @@ await strict.run({}, async (b) => { const parkedAt = await pick(running); await rememberEngine(p); // A switch back in time is warm. - await wait(1000); const backAt = await pick(parked); const early = await engines(parked); await assertSession(p, parked, `a quick switch back to ${parked}`); @@ -93,7 +91,16 @@ await strict.run({}, async (b) => { ); // Parked past the delay: released, and the next switch back is cold. await pick(running); - await wait(PARKED_MS + 1500); + await p.waitForFunction( + (kind) => { + const canvas = window.__qaEngines.get(kind)?.host.canvas; + return ( + canvas?.getContext('webgl2') ?? canvas?.getContext('webgl') + )?.isContextLost(); + }, + { polling: 100, timeout: PARKED_MS + 30000 }, + parked, + ); const released = await engines(parked); contract( `${parked} parked past the delay is released: its context is lost`, diff --git a/scripts/qa-strict-prod-smoke.mjs b/scripts/qa-strict-prod-smoke.mjs index 93e275a5b..5190b44fb 100644 --- a/scripts/qa-strict-prod-smoke.mjs +++ b/scripts/qa-strict-prod-smoke.mjs @@ -1,3 +1,4 @@ +import { whenSettled } from './qa/ready.mjs'; // Contract: the production build starts cold on each engine without a page // error. A fresh page per engine opens the keyless preview of `vite build` // (its hashed entry chunk, not the dev server), waits until the engine runs @@ -47,11 +48,13 @@ await strict.run({}, async (b) => { ); } // Late work after the start: idle prefetch, layer timers, workers. - await new Promise((resolve) => setTimeout(resolve, 4000)); + await page.waitForNetworkIdle({ idleTime: 500, timeout: 30000 }); + await whenSettled(page); const other = start === 'cesium' ? 'navara' : 'cesium'; await strict.pick(page, other); await waitForEngine(page, other, { timeout: 90000 }); - await new Promise((resolve) => setTimeout(resolve, 2000)); + await page.waitForNetworkIdle({ idleTime: 500, timeout: 30000 }); + await whenSettled(page); const row = { start, switchedTo: other, entry: entry.scripts, errors }; results.push(row); console.log(errors.length ? 'FAIL' : 'PASS', JSON.stringify(row)); diff --git a/scripts/qa-strict-rapid-switch.mjs b/scripts/qa-strict-rapid-switch.mjs index aa10158e9..2c9219c4b 100644 --- a/scripts/qa-strict-rapid-switch.mjs +++ b/scripts/qa-strict-rapid-switch.mjs @@ -1,3 +1,4 @@ +import { whenSettled } from './qa/ready.mjs'; // Contract: rapid switches. Back-to-back picks with real input, a second // pointer click on the picker while it is busy, a history visit superseded // by the opposite visit before its engine is ready, and a pick superseded by @@ -115,7 +116,7 @@ await strict.run({}, async (b) => { history.back(); }); await waitForEngine(p, 'navara'); - await new Promise((resolve) => setTimeout(resolve, 1500)); + await whenSettled(p); await assertSession(p, 'navara', 'Forward supersedes Back'); const visited = await sessionState(p); const shown = await engines(); @@ -145,7 +146,7 @@ await strict.run({}, async (b) => { }); await strict.pick(p, 'cesium', { settle: false }); await waitForEngine(p, 'cesium', { timeout: 30000 }); - await new Promise((resolve) => setTimeout(resolve, 1500)); + await whenSettled(p); await assertSession(p, 'cesium', 'Back supersedes the pick'); const after = await sessionState(p); const backShown = await engines(); diff --git a/scripts/qa-strict-session.test.mjs b/scripts/qa-strict-session.test.mjs index c6ac629b8..caece9435 100644 --- a/scripts/qa-strict-session.test.mjs +++ b/scripts/qa-strict-session.test.mjs @@ -1,6 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; +import { runInThisContext } from 'node:vm'; +import { createFakeEngine } from '../src/globe/testing/fakeEngine.js'; import { quantile, slope } from './qa-strict-session.mjs'; import { strictArguments, waitForCameraAtRest } from './qa-strict-harness.mjs'; @@ -47,26 +49,37 @@ test('the runner hands each scenario its layers and its manifest path', () => { }); }); -// A page whose camera answers one sampled position (x, metres) and input -// state per waitForFunction poll; `polls()` counts the polls taken. +// Each render request draws the next scripted camera sample through the fake engine. function cameraPage(t, positions, inputs, globals = {}) { - let frame = 0; - const engine = { - camera: { - pose: () => ({ position: { x: positions[frame], y: 0, z: 6.4e6 } }), - }, - input: { cameraInputsEnabled: () => inputs[frame] }, - }; + let frame = -1; + const engine = createFakeEngine(); + engine.input.cameraInputsEnabled = () => inputs[Math.max(0, frame)]; + engine.camera.flightActive = () => !inputs[Math.max(0, frame)]; + engine.render.request = () => + setImmediate(() => { + if (frame + 1 >= positions.length) return; + frame++; + engine.fake.setPose({ + position: { x: positions[frame], y: 0, z: 6.4e6 }, + }); + engine.fake.frame(); + }); const previous = globalThis.window; - globalThis.window = { __godsEyeView: { engine }, ...globals }; + globalThis.window = { + __godsEyeView: { + engine, + styleManager: { initialRestorePromise: Promise.resolve() }, + }, + ...globals, + }; t.after(() => { globalThis.window = previous; }); const page = { - evaluate: async (fn) => fn(), + evaluate: async (fn) => + typeof fn === 'string' ? runInThisContext(fn) : fn(), async waitForFunction(fn) { - for (; frame < positions.length; frame++) if (fn()) return; - throw new Error('timed out'); + assert.ok(fn()); }, }; return { page, polls: () => frame }; @@ -77,21 +90,21 @@ test('the camera is at rest once a flight has landed and it stays still', async // still moves one more sample before it stays put. const { page, polls } = cameraPage( t, - [0, 400, 900, 900, 1250, 1250, 1250], - [false, false, false, false, true, true, true], + [0, 400, 900, 900, 1250, 1250, 1250, 1250], + [false, false, false, false, true, true, true, true], ); await waitForCameraAtRest(page); // Not while the flight holds the inputs (sample 3 repeats a pose), nor // while the camera moves (sample 4). - assert.equal(polls(), 5); + assert.equal(polls(), 7); }); test('qa-wildlife-switch waits for the focus flight even when the camera was already still', async (t) => { // A rest the scenario recorded before the click is no rest of the flight. const { page, polls } = cameraPage( t, - [0, 0, 100, 500, 500], - [false, false, false, true, true], + [0, 0, 100, 500, 500, 500, 500], + [false, false, false, true, true, true, true], { __qaWildPose: { key: '0.0,0.0,6400000.0', at: performance.now() - 5000 }, }, @@ -109,5 +122,5 @@ test('qa-wildlife-switch waits for the focus flight even when the camera was alr )}\nreturn still;`, )(page, waitForCameraAtRest); await still(); - assert.equal(polls(), 4); + assert.equal(polls(), 6); }); diff --git a/scripts/qa-submarine-cables-engine-switch.mjs b/scripts/qa-submarine-cables-engine-switch.mjs index 34939b9ac..b3735c2ba 100644 --- a/scripts/qa-submarine-cables-engine-switch.mjs +++ b/scripts/qa-submarine-cables-engine-switch.mjs @@ -1,3 +1,4 @@ +import { whenPickable, whenSettled } from './qa/ready.mjs'; import { fromDegrees } from '../src/geo/cartesian3.js'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -90,7 +91,7 @@ await strict.run({}, async (b) => { }, fromDegrees(10.7995, 55.3241, 25000), ); - await new Promise((resolve) => setTimeout(resolve, 2500)); + await whenSettled(p); const screen = await p.evaluate( (world) => { const { engine } = window.__godsEyeView; @@ -100,6 +101,9 @@ await strict.run({}, async (b) => { }, fromDegrees(10.799480647390464, 55.324102015186114), ); + await whenPickable(p, screen, (picked) => + picked?.id?.startsWith('landing-point-reference-'), + ); await p.mouse.click(screen.x, screen.y); await p.waitForFunction( () => { diff --git a/scripts/qa-traffic-engine-switch.mjs b/scripts/qa-traffic-engine-switch.mjs index da0e6bde2..94e61dfaf 100644 --- a/scripts/qa-traffic-engine-switch.mjs +++ b/scripts/qa-traffic-engine-switch.mjs @@ -1,3 +1,4 @@ +import { whenSettled } from './qa/ready.mjs'; import { trafficFixtureResponse } from './traffic-fixtures.mjs'; import sharp from 'sharp'; import assert from 'node:assert/strict'; @@ -156,14 +157,14 @@ await strict.run({}, async (b) => { 'traffic did not settle with 12 vehicles in view: ' + JSON.stringify({ loading, inView: rows.length }), ); - await new Promise((resolve) => setTimeout(resolve, 500)); + await whenSettled(p); } }; // The population once roads, heights and dots have settled. Source counts // can be ready before the renderer has drawn terrain, so the camera and the - // attached road heights get the same delay before and after the switches. + // attached road heights are sampled after drawn frames on either engine. const settledPopulation = async () => { - await new Promise((resolve) => setTimeout(resolve, 2200)); + await whenSettled(p); await settled(); return p.evaluate(() => window.qaTraffic.getStats().count); }; @@ -251,7 +252,7 @@ await strict.run({}, async (b) => { origin: 'user', }), ); - await new Promise((resolve) => setTimeout(resolve, 600)); + await whenSettled(p); const hidden = await raw(); const { width, height } = shown.info; const scale = width / 1024; diff --git a/scripts/qa-urban-engine-switch.mjs b/scripts/qa-urban-engine-switch.mjs index 7d0a5b8be..b67a1aebb 100644 --- a/scripts/qa-urban-engine-switch.mjs +++ b/scripts/qa-urban-engine-switch.mjs @@ -1,3 +1,4 @@ +import { whenSettled } from './qa/ready.mjs'; import assert from 'node:assert/strict'; import { fromDegrees } from '../src/geo/cartesian3.js'; import { @@ -50,7 +51,7 @@ await strict.run({}, async (b) => { .length; if (bus) { // Outside every feed region the layer waits and reads no feed. - await new Promise((resolve) => setTimeout(resolve, 3000)); + await whenSettled(p); const outside = await p.evaluate(() => window.qaUrban.getStats()); await strict.check( p, diff --git a/scripts/qa-voice-engine-switch.mjs b/scripts/qa-voice-engine-switch.mjs index 15dfd38dd..48f254095 100644 --- a/scripts/qa-voice-engine-switch.mjs +++ b/scripts/qa-voice-engine-switch.mjs @@ -30,9 +30,9 @@ const base = localPreview(process.argv[2]); // Play one server function call on the fake data channel and wait for the // client's function_call_output. -function callTool(page, name, args, { beforeCall = null } = {}) { - return page.evaluate( - async (name, args, beforeCall) => { +async function callTool(page, name, args, { beforeCall = null } = {}) { + const call = await page.evaluate( + (name, args, beforeCall) => { const evidence = window.__qaVoice; const callId = `qa-call-${(window.__qaCalls = (window.__qaCalls ?? 0) + 1)}`; const responseId = `qa-response-${callId}`; @@ -54,30 +54,41 @@ function callTool(page, name, args, { beforeCall = null } = {}) { name, arguments: JSON.stringify(args), }); - const started = performance.now(); - for (;;) { - const sent = evidence.sent.find( - (event) => - event?.type === 'conversation.item.create' && - event.item?.type === 'function_call_output' && - event.item.call_id === callId, - ); - if (sent) { - deliver({ - type: 'response.done', - response: { id: responseId, status: 'completed', output: [] }, - }); - return JSON.parse(sent.item.output); - } - if (performance.now() - started > 60_000) - throw new Error(`No output for ${name}`); - await new Promise((resolve) => setTimeout(resolve, 50)); - } + return { callId, responseId }; }, name, args, beforeCall, ); + await page.waitForFunction( + (callId) => + window.__qaVoice.sent.some( + (event) => + event?.type === 'conversation.item.create' && + event.item?.type === 'function_call_output' && + event.item.call_id === callId, + ), + { timeout: 60000 }, + call.callId, + ); + return page.evaluate(({ callId, responseId }) => { + const evidence = window.__qaVoice; + const sent = evidence.sent.find( + (event) => + event?.type === 'conversation.item.create' && + event.item?.type === 'function_call_output' && + event.item.call_id === callId, + ); + evidence.channel.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ + type: 'response.done', + response: { id: responseId, status: 'completed', output: [] }, + }), + }), + ); + return JSON.parse(sent.item.output); + }, call); } const engineChanges = (page) => diff --git a/scripts/qa-weather-engine-switch.mjs b/scripts/qa-weather-engine-switch.mjs index 2218f2b4f..452596828 100644 --- a/scripts/qa-weather-engine-switch.mjs +++ b/scripts/qa-weather-engine-switch.mjs @@ -1,3 +1,4 @@ +import { whenSettled } from './qa/ready.mjs'; import { fromDegrees } from '../src/geo/cartesian3.js'; import sharp from 'sharp'; import assert from 'node:assert/strict'; @@ -182,7 +183,7 @@ await strict.run({}, async (b) => { time, ); const sample = async () => { - await new Promise((r) => setTimeout(r, 500)); + await whenSettled(p); const crop = await sharp(await p.screenshot()) .extract({ left: 535, top: 388, width: 12, height: 12 }) .removeAlpha() diff --git a/scripts/qa-wildlife-switch.mjs b/scripts/qa-wildlife-switch.mjs index c76143641..eadef2bab 100644 --- a/scripts/qa-wildlife-switch.mjs +++ b/scripts/qa-wildlife-switch.mjs @@ -1,3 +1,4 @@ +import { whenSettled } from './qa/ready.mjs'; import assert from 'node:assert/strict'; import { fromDegrees } from '../src/geo/cartesian3.js'; import { fromCartesian } from '../src/geo/cartographic.js'; @@ -161,7 +162,7 @@ await strict.run({}, async (b) => { (badge) => badge.picked?.id === `wildlife:${badge.id}`, ); if (ok || Date.now() > deadline) return drawn; - await new Promise((resolve) => setTimeout(resolve, 500)); + await whenSettled(p); } }; const degrees = (position) => { diff --git a/scripts/qa-wind-engine-switch.mjs b/scripts/qa-wind-engine-switch.mjs index 5a274b042..1a454f4dd 100644 --- a/scripts/qa-wind-engine-switch.mjs +++ b/scripts/qa-wind-engine-switch.mjs @@ -1,3 +1,4 @@ +import { whenSettled } from './qa/ready.mjs'; import sharp from 'sharp'; import assert from 'node:assert/strict'; import { @@ -95,7 +96,7 @@ await strict.run({}, async (b) => { window.qaWind.getRowControls().summary.reading?.speed === 10, { timeout: 90000 }, ); - await new Promise((resolve) => setTimeout(resolve, 800)); + await whenSettled(p); const check = await p.evaluate(() => { const { dataManager } = window.__godsEyeView; const mod = dataManager.layers.get('wind').module; @@ -161,7 +162,7 @@ await strict.run({}, async (b) => { const frozen = await p.evaluate( () => window.qaWind.getDiagnostics().gpu.phaseSeconds, ); - await new Promise((resolve) => setTimeout(resolve, 250)); + await whenSettled(p); await strict.check( p, 'wind', diff --git a/scripts/qa/ledger.mjs b/scripts/qa/ledger.mjs new file mode 100644 index 000000000..60e16a148 --- /dev/null +++ b/scripts/qa/ledger.mjs @@ -0,0 +1,63 @@ +/** The five public drawing registries, with no SDK objects or volatile fields. */ +export function drawLedger(page) { + return page.evaluate(() => { + const { engine } = window.__godsEyeView; + return ['movers', 'lines', 'ground', 'imagery', 'meshes'] + .flatMap((family) => + engine[family].list().map((row) => ({ + id: String(row.id), + family, + ...(row.kind === undefined ? {} : { kind: row.kind }), + ...(row.count === undefined ? {} : { count: row.count }), + shown: row.shown ?? true, + })), + ) + .sort( + (a, b) => a.family.localeCompare(b.family) || a.id.localeCompare(b.id), + ); + }); +} + +/** Allowlisted absences keep their reasons; visibility differences are never hidden. */ +export function compareLedgers(before, after, allow = []) { + const key = (row) => `${row.family}:${row.id}`; + const left = new Map(before.map((row) => [key(row), row])); + const right = new Map(after.map((row) => [key(row), row])); + const allowed = []; + const only = (a, b, side) => + [...a].flatMap(([key, row]) => { + if (b.has(key)) return []; + const exception = allow.find( + (entry) => + entry.id === row.id && (!entry.family || entry.family === row.family), + ); + if (exception) { + allowed.push({ + id: row.id, + family: row.family, + side, + reason: exception.reason, + }); + return []; + } + return [{ id: row.id, family: row.family }]; + }); + return { + onlyBefore: only(left, right, 'before'), + onlyAfter: only(right, left, 'after'), + shown: [...left].flatMap(([key, row]) => { + const next = right.get(key); + return next && (row.shown ?? true) !== (next.shown ?? true) + ? [ + { + id: row.id, + family: row.family, + before: row.shown ?? true, + after: next.shown ?? true, + }, + ] + : []; + }), + allowed, + }; +} diff --git a/scripts/qa/ready.mjs b/scripts/qa/ready.mjs new file mode 100644 index 000000000..25814c04b --- /dev/null +++ b/scripts/qa/ready.mjs @@ -0,0 +1,122 @@ +import { createSettledState } from '../../src/app/qaProbe.js'; + +/** A CPU-ready pick can precede the GPU hit delivered for a real click. */ +export async function clickUntilSelected(page, click, selected) { + for (let attempt = 0; attempt < 5; attempt++) { + await click(); + let handle; + try { + handle = await page.waitForFunction(selected, { timeout: 2000 }); + return; + } catch (error) { + if (error.name !== 'TimeoutError') throw error; + if (attempt === 4) + throw new Error('Readiness timeout: selection after 5 clicks', { + cause: error, + }); + } finally { + await handle?.dispose(); + } + } +} + +/** Viewport CSS coordinates (or a page-side locator); predicate runs beside the picker. */ +export async function whenPickable( + page, + windowPosition, + predicate, + { timeoutMs = 30000 } = {}, +) { + const position = + typeof windowPosition === 'function' + ? `(${windowPosition})()` + : JSON.stringify(windowPosition); + let handle; + try { + handle = await page.waitForFunction( + `(() => { + const engine = window.__godsEyeView?.engine; + if (!engine || engine.isDestroyed()) return false; + engine.render.request('qa-pick-readiness'); + const position = ${position}; + if (!position) return false; + const box = engine.host.canvas.getBoundingClientRect(); + const picked = engine.picker.pick({ + x: position.x - box.left, + y: position.y - box.top, + }); + return (${predicate})(picked, position) ? position : false; + })()`, + { polling: 50, timeout: timeoutMs }, + ); + return await handle.jsonValue(); + } catch (error) { + if (error.name === 'TimeoutError') + throw new Error('Readiness timeout: pickable target', { cause: error }); + throw error; + } finally { + await handle?.dispose(); + } +} + +/** Wait for the restored camera over drawn frames, including request-mode globes. */ +export async function whenSettled( + page, + { frames = 3, timeoutMs = 120000 } = {}, +) { + const started = Date.now(); + try { + await page.waitForFunction(() => window.__godsEyeView?.engine, { + timeout: timeoutMs, + polling: 50, + }); + } catch { + throw new Error( + 'Readiness timeout: engineStarted, initialViewRestored, frameDrawn, noFlight, cameraInputsEnabled, cameraPoseStable', + ); + } + return page.evaluate( + `(${waitForSettled})(${createSettledState}, ${JSON.stringify({ frames, timeoutMs: Math.max(1, timeoutMs - (Date.now() - started)) })})`, + ); +} + +/** Self-contained so production pages need no QA module. */ +export function waitForSettled(createState, { frames, timeoutMs }) { + return new Promise((resolve, reject) => { + const { engine } = window.__godsEyeView; + const state = createState(engine); + let finished = false; + let remove = () => {}; + const finish = (error, result) => { + if (finished) return; + finished = true; + clearTimeout(timer); + remove(); + state.destroy(); + if (error) reject(error); + else resolve(result); + }; + const timer = setTimeout(() => { + const status = state.settled({ frames }); + if (window.__godsEyeView?.engine !== engine) + status.unmet.push('engineUnchanged'); + finish( + new Error( + `Readiness timeout: ${status.unmet.join(', ')}${status.restoreError ? ': ' + status.restoreError : ''}`, + ), + ); + }, timeoutMs); + remove = engine.frames.postRender.addEventListener(() => { + const status = state.settled({ frames }); + if (window.__godsEyeView?.engine !== engine) + return finish(new Error('Readiness interrupted: engineChanged')); + if (status.settled) finish(null, status); + else + queueMicrotask(() => { + if (!finished && !engine.isDestroyed()) + engine.render.request('qa-readiness'); + }); + }); + engine.render.request('qa-readiness'); + }); +} diff --git a/src/app/qaProbe.js b/src/app/qaProbe.js index 5d025ba92..9f5d7da44 100644 --- a/src/app/qaProbe.js +++ b/src/app/qaProbe.js @@ -16,10 +16,89 @@ // following() { label, native } of engine.camera.following() // movers() engine.movers.list() // framesDrawn() frames drawn since the probe started +// settled({ frames? }) readiness conditions over drawn frames const plain = (position) => position ? { x: position.x, y: position.y, z: position.z } : null; +/** Frame-based readiness, also serialized by the production QA harness. */ +export function createSettledState( + engine, + getRestore = () => + globalThis.window?.__godsEyeView?.styleManager?.initialRestorePromise, +) { + let drawn = 0; + let stable = 0; + let previous = null; + let restore; + let restored = false; + let restoreError = null; + const observeRestore = () => { + const next = getRestore(); + if (!next || next === restore) return; + restore = next; + restored = false; + restoreError = null; + Promise.resolve(next).then( + () => { + if (restore === next) restored = true; + }, + (error) => { + if (restore === next) restoreError = String(error); + }, + ); + }; + const pose = () => { + const value = engine.camera.pose(); + return [ + ...['position', 'direction', 'up'].flatMap((key) => + ['x', 'y', 'z'].map((axis) => value[key][axis]), + ), + value.fovy, + value.aspectRatio, + ]; + }; + const same = (a, b) => + a && a.every((n, i) => Math.abs(n - b[i]) <= (i < 3 ? 1e-6 : 1e-10)); + const remove = engine.frames.postRender.addEventListener(() => { + observeRestore(); + drawn++; + const current = pose(); + stable = + same(previous, current) && + !engine.camera.flightActive() && + engine.input.cameraInputsEnabled() + ? stable + 1 + : 0; + previous = current; + }); + observeRestore(); + return { + settled({ frames = 3 } = {}) { + observeRestore(); + const started = !engine.isDestroyed(); + const conditions = { + engineStarted: started, + initialViewRestored: restored, + frameDrawn: drawn > 0, + noFlight: started && !engine.camera.flightActive(), + cameraInputsEnabled: started && engine.input.cameraInputsEnabled(), + cameraPoseStable: started && stable >= frames && same(previous, pose()), + }; + const unmet = Object.keys(conditions).filter((key) => !conditions[key]); + return { + settled: !unmet.length, + conditions, + unmet, + framesDrawn: drawn, + stableFrames: stable, + restoreError, + }; + }, + destroy: remove, + }; +} + /** * @param {{ * engine: import('../globe/contract.js').GlobeEngine, @@ -27,6 +106,7 @@ const plain = (position) => * }} options */ export function createQaProbe({ engine, dataManager }) { + const readiness = createSettledState(engine); let frames = 0; const removeFrames = engine.frames.postRender.addEventListener(() => { frames++; @@ -84,6 +164,10 @@ export function createQaProbe({ engine, dataManager }) { }, movers: () => engine.movers.list(), framesDrawn: () => frames, - destroy: () => removeFrames(), + settled: (options) => readiness.settled(options), + destroy() { + removeFrames(); + readiness.destroy(); + }, }; } diff --git a/src/globe/testing/contractMonitor.js b/src/globe/testing/contractMonitor.js new file mode 100644 index 000000000..da0ac43ac --- /dev/null +++ b/src/globe/testing/contractMonitor.js @@ -0,0 +1,186 @@ +/** Observe a started engine without importing runtime or SDK modules. */ +export function createContractMonitor(engine, report = () => {}) { + const records = []; + const flights = []; + const removers = []; + const restorers = []; + let destroyed = false; + let moving = false; + let frame = 0; + let inFrame = false; + let requestedInFrame = null; + const record = (invariant, detail = {}) => { + const row = { invariant, frame, ...detail }; + records.push(row); + report(row); + }; + const wrap = (owner, name, decorate) => { + const original = owner[name]; + const wrapped = decorate(original); + owner[name] = wrapped; + restorers.push(() => { + if (owner[name] === wrapped) owner[name] = original; + }); + }; + const listen = (event, name, callback = () => {}) => { + if (!event) return; + removers.push( + event.addEventListener(() => { + if (destroyed) record('event-after-destroy', { event: name }); + else callback(); + }), + ); + }; + for (const name of ['changed', 'commanded', 'followChanged']) + listen(engine.camera[name], `camera.${name}`); + listen(engine.camera.moveStart, 'camera.moveStart', () => { + if (moving) record('move-alternation', { event: 'moveStart' }); + moving = true; + }); + listen(engine.camera.moveEnd, 'camera.moveEnd', () => { + if (!moving) record('move-alternation', { event: 'moveEnd' }); + moving = false; + }); + listen(engine.frames.preUpdate, 'frames.preUpdate', () => { + frame++; + inFrame = true; + // Request-mode engines can update without drawing a frame. + queueMicrotask(() => { + inFrame = false; + }); + }); + listen(engine.frames.preRender, 'frames.preRender', () => { + inFrame = true; + }); + listen(engine.frames.postRender, 'frames.postRender', () => { + if (requestedInFrame !== null && frame > requestedInFrame) + requestedInFrame = null; + // Include requests made by listeners registered after the monitor. + queueMicrotask(() => { + inFrame = false; + }); + }); + listen(engine.maps.errors, 'maps.errors'); + for (const type of ['click', 'doubleClick', 'move', 'down', 'up']) { + removers.push( + engine.input.on(type, () => { + if (destroyed) + record('event-after-destroy', { event: `input.${type}` }); + }), + ); + } + wrap( + engine.render, + 'request', + (original) => + function (...args) { + if (inFrame) requestedInFrame = frame; + return original.apply(this, args); + }, + ); + const missing = () => { + for (const flight of flights) { + if (!flight.ends && !flight.reported) { + flight.reported = true; + record('flight-terminal', { flight: flight.id, ends: 0 }); + } + } + }; + for (const name of ['flyTo', 'flyToSphere']) { + wrap( + engine.camera, + name, + (original) => + function (...args) { + const index = name === 'flyTo' ? 0 : 1; + const options = args[index] ?? {}; + const flight = { id: flights.length + 1, ends: 0 }; + flights.push(flight); + const observed = { ...options }; + for (const outcome of ['complete', 'cancel']) { + observed[outcome] = function (...values) { + flight.ends++; + if (flight.ends !== 1) + record('flight-terminal', { + flight: flight.id, + ends: flight.ends, + outcome, + }); + if (outcome === 'complete' && engine.camera.flightActive()) + record('flight-active', { flight: flight.id, at: 'complete' }); + return options[outcome]?.apply(this, values); + }; + } + args[index] = observed; + try { + return original.apply(this, args); + } catch (error) { + // Invalid commands do not promise a flight or a terminal callback. + flights.splice(flights.indexOf(flight), 1); + throw error; + } + }, + ); + } + for (const name of ['cancelFlight', 'completeFlight']) { + wrap( + engine.camera, + name, + (original) => + function (...args) { + const result = original.apply(this, args); + if (engine.camera.flightActive()) + record('flight-active', { at: name }); + missing(); + return result; + }, + ); + } + wrap( + engine, + 'destroy', + (original) => + function (...args) { + const result = original.apply(this, args); + destroyed = true; + inFrame = false; + requestedInFrame = null; + missing(); + return result; + }, + ); + return { + records, + async finish({ active = true } = {}) { + missing(); + if (!destroyed && active) { + if (!engine.input.cameraInputsEnabled()) + record('final-inputs-disabled'); + if (engine.camera.flightActive()) record('final-flight-active'); + if (requestedInFrame !== null) { + const requested = requestedInFrame; + // A request on the last observed frame still deserves its next frame. + await new Promise((resolve) => { + const remove = engine.frames.postRender.addEventListener(() => { + if (frame <= requested) return; + clearTimeout(timer); + remove(); + resolve(); + }); + const timer = setTimeout(() => { + remove(); + if (!destroyed) + record('render-request-lost', { requestedInFrame: requested }); + resolve(); + }, 2000); + }); + } + } + return records; + }, + dispose() { + for (const remove of removers) remove(); + for (const restore of restorers.reverse()) restore(); + }, + }; +} diff --git a/src/globe/testing/contractMonitor.test.mjs b/src/globe/testing/contractMonitor.test.mjs new file mode 100644 index 000000000..ccb0c48c5 --- /dev/null +++ b/src/globe/testing/contractMonitor.test.mjs @@ -0,0 +1,184 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createFakeEngine } from './fakeEngine.js'; +import { createContractMonitor } from './contractMonitor.js'; + +const destination = { x: 7000000, y: 1000000, z: 0 }; +const violations = (monitor) => monitor.records.map((row) => row.invariant); + +test('normal flights, moves, in-frame requests and destruction preserve the contract', async () => { + const engine = createFakeEngine(); + const monitor = createContractMonitor(engine); + engine.camera.flyTo({ destination, duration: 0 }); + engine.camera.flyTo({ destination, duration: 1 }); + engine.camera.cancelFlight(); + engine.camera.flyToSphere({ center: destination, radius: 100 }); + engine.camera.completeFlight(); + let remove = engine.frames.postRender.addEventListener(() => { + remove(); + engine.render.request(); + }); + engine.fake.frames(3); + await Promise.resolve(); + await monitor.finish(); + engine.destroy(); + assert.deepEqual(monitor.records, []); + monitor.dispose(); +}); + +test('terminal callbacks are counted even without caller callbacks', () => { + for (const endings of [ + [], + ['complete', 'cancel'], + ['complete', 'complete'], + ]) { + const engine = createFakeEngine(); + engine.camera.flyTo = (options) => + endings.forEach((name) => options[name]()); + const monitor = createContractMonitor(engine); + engine.camera.flyTo({ destination }); + monitor.finish(); + assert.ok(violations(monitor).includes('flight-terminal')); + monitor.dispose(); + } +}); + +test('complete callbacks and explicit flight termination must see an inactive flight', () => { + for (const at of ['complete', 'cancelFlight', 'completeFlight']) { + const engine = createFakeEngine(); + if (at === 'complete') + engine.camera.flyTo = (options) => options.complete(); + else engine.camera[at] = () => {}; + engine.camera.flightActive = () => true; + const monitor = createContractMonitor(engine); + if (at === 'complete') engine.camera.flyTo({ destination }); + else engine.camera[at](); + assert.ok( + monitor.records.some( + (row) => row.invariant === 'flight-active' && row.at === at, + ), + ); + monitor.dispose(); + } +}); + +test('a cancelled flight that loses its callback is reported at cancellation', () => { + const engine = createFakeEngine(); + engine.camera.flyTo = () => {}; + const monitor = createContractMonitor(engine); + engine.camera.flyTo({ destination }); + engine.camera.cancelFlight(); + assert.ok(violations(monitor).includes('flight-terminal')); +}); + +test('moveStart and moveEnd must alternate', () => { + for (const events of [ + ['moveEnd'], + ['moveStart', 'moveStart'], + ['moveStart', 'moveEnd', 'moveEnd'], + ]) { + const engine = createFakeEngine(); + const monitor = createContractMonitor(engine); + for (const name of events) engine.fake.emit(`camera.${name}`); + assert.ok(violations(monitor).includes('move-alternation')); + } +}); + +test('a swallowed in-frame render request leaves a missing next frame', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const engine = createFakeEngine(); + engine.render.request = () => {}; + const monitor = createContractMonitor(engine); + engine.frames.postRender.addEventListener(() => engine.render.request()); + engine.fake.frame(); + const finish = monitor.finish(); + t.mock.timers.tick(2000); + await finish; + assert.ok(violations(monitor).includes('render-request-lost')); +}); + +test('a request in the next frame must also draw a later frame', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const engine = createFakeEngine(); + engine.render.request = () => {}; + const monitor = createContractMonitor(engine); + engine.frames.preRender.addEventListener(() => engine.render.request()); + engine.fake.frames(2); + const finish = monitor.finish(); + t.mock.timers.tick(2000); + await finish; + assert.ok(violations(monitor).includes('render-request-lost')); +}); + +test('an update without a draw does not mark later requests as in-frame', async () => { + const engine = createFakeEngine(); + const monitor = createContractMonitor(engine); + engine.fake.emit('frames.preUpdate'); + await Promise.resolve(); + engine.render.request(); + await monitor.finish(); + assert.deepEqual(monitor.records, []); +}); + +test('the active engine must finish with inputs enabled and without a flight', () => { + const engine = createFakeEngine(); + const monitor = createContractMonitor(engine); + engine.input.suspendCameraInputs(); + engine.camera.flyTo({ destination }); + monitor.finish(); + assert.ok(violations(monitor).includes('final-inputs-disabled')); + assert.ok(violations(monitor).includes('final-flight-active')); +}); + +test('events emitted after destroy remain observable until disposal', () => { + const engine = createFakeEngine(); + const monitor = createContractMonitor(engine); + engine.destroy(); + engine.fake.emit('camera.changed'); + engine.fake.emit('frames.postRender'); + assert.deepEqual(violations(monitor), [ + 'event-after-destroy', + 'event-after-destroy', + ]); + monitor.dispose(); + engine.fake.emit('camera.changed'); + assert.equal(monitor.records.length, 2); +}); + +test('wrappers preserve callback arguments, receivers, returns, and thrown commands', () => { + const engine = createFakeEngine(); + const receiver = {}; + engine.camera.flyTo = (options) => options.complete.call(receiver, 7); + const original = engine.camera.flyTo; + const monitor = createContractMonitor(engine); + assert.equal( + engine.camera.flyTo({ + complete(value) { + assert.equal(this, receiver); + return value; + }, + }), + 7, + ); + monitor.dispose(); + assert.equal(engine.camera.flyTo, original); + const real = createFakeEngine(); + const other = createContractMonitor(real); + assert.throws(() => real.camera.flyTo({}), /destination/); + other.finish(); + assert.deepEqual(other.records, []); +}); + +test('a final in-frame request may draw its next frame before evidence closes', async () => { + const engine = createFakeEngine(); + const monitor = createContractMonitor(engine); + let remove = engine.frames.postRender.addEventListener(() => { + remove(); + engine.render.request(); + }); + engine.fake.frame(); + const finish = monitor.finish(); + engine.fake.frame(); + await finish; + assert.deepEqual(monitor.records, []); +}); diff --git a/src/standalone/engineSwitch.js b/src/standalone/engineSwitch.js index 3cd40073a..f67867c8e 100644 --- a/src/standalone/engineSwitch.js +++ b/src/standalone/engineSwitch.js @@ -121,21 +121,33 @@ export function waitForFirstFrame( clearTimer = clearTimeout, } = {}, ) { - if (document?.hidden) return Promise.resolve(false); + const outcome = (path) => { + if (engine.host?.canvas?.dataset) + engine.host.canvas.dataset.firstFrameOutcome = path; + }; + if (document?.hidden) { + outcome('hidden'); + return Promise.resolve(false); + } + outcome('pending'); return new Promise((resolve) => { + let finished = false; let timer = null; let remove = () => {}; - const finish = (drawn) => { + const finish = (path) => { + if (finished) return; + finished = true; + outcome(path); remove(); clearTimer(timer); document?.removeEventListener?.('visibilitychange', hidden); - resolve(drawn); + resolve(path === 'frame'); }; function hidden() { - if (document.hidden) finish(false); + if (document.hidden) finish('hidden'); } - remove = engine.frames.postRender.addEventListener(() => finish(true)); - timer = setTimer(() => finish(false), timeoutMs); + remove = engine.frames.postRender.addEventListener(() => finish('frame')); + timer = setTimer(() => finish('timeout'), timeoutMs); document?.addEventListener?.('visibilitychange', hidden); engine.render.request('engine-switch'); });