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.
This commit is contained in:
German Khokhlov committed 2026-11-10 15:46:26 +04:00
1 parent 9c2a2d6951
commit 60f9de8511
47 files changed
+1788 -312

No files matched your search

+9
View File
@@ -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
+5 -4
View File
@@ -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;
+2 -5
View File
@@ -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;
+34 -9
View File
@@ -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,
+4 -6
View File
@@ -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',
+9 -23
View File
@@ -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);
+14 -8
View File
@@ -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) => ({
+15 -8
View File
@@ -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,
+13 -13
View File
@@ -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',
+2 -2
View File
@@ -1,8 +1,8 @@
{
"metrics": {
"ownLaunch": 98,
"fixedSleeps": 117,
"loadingScreenReadiness": 9,
"fixedSleeps": 85,
"loadingScreenReadiness": 1,
"cesiumOnlyReads": 75,
"ownWaitHelpers": 19
}
+12 -12
View File
@@ -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(),
);
+13 -2
View File
@@ -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) => {
+60 -37
View File
@@ -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 &&
+1
View File
@@ -0,0 +1 @@
[]
+9 -6
View File
@@ -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,
+18 -3
View File
@@ -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);
+12 -21
View File
@@ -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` });
+12 -16
View File
@@ -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',
+6 -5
View File
@@ -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');
+29 -12
View File
@@ -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',
+2 -1
View File
@@ -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()
+4 -3
View File
@@ -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(),
+2 -1
View File
@@ -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;
+2 -5
View File
@@ -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;
+10 -6
View File
@@ -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, []);
+3 -1
View File
@@ -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);
+23 -5
View File
@@ -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,
+151 -25
View File
@@ -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 });
}
+537
View File
@@ -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);
});
+12 -5
View File
@@ -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');
+13 -6
View File
@@ -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`,
+5 -2
View File
@@ -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));
+3 -2
View File
@@ -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();
+32 -19
View File
@@ -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);
});
@@ -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(
() => {
+5 -4
View File
@@ -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;
+2 -1
View File
@@ -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,
+33 -22
View File
@@ -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) =>
+2 -1
View File
@@ -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()
+2 -1
View File
@@ -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) => {
+3 -2
View File
@@ -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',
+63
View File
@@ -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,
};
}
+122
View File
@@ -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');
});
}
+85 -1
View File
@@ -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();
},
};
}
+186
View File
@@ -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();
},
};
}
+184
View File
@@ -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, []);
});
+18 -6
View File
@@ -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');
});