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 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 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. 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 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 up over the test cities whenever the run happens, unless a scenario supplies
`time` (an ISO instant or `'real'`); `QA_EXIT_DIAGNOSTICS=1` preserves `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 { closeBrowser, exitAfterFlush } from './qa/browser.mjs';
import { localPreview, waitForEngine } from './qa-strict-harness.mjs'; import { localPreview, waitForEngine } from './qa-strict-harness.mjs';
// The application is created once per page: switching engines detaches and // The application is created once per page: switching engines detaches and
@@ -56,7 +57,7 @@ async function escapeEffect(page) {
await page.focus(FIELD); await page.focus(FIELD);
const before = await readUi(page); const before = await readUi(page);
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await new Promise((resolve) => setTimeout(resolve, 400)); await whenSettled(page);
const after = await readUi(page); const after = await readUi(page);
return { before, after }; return { before, after };
} }
@@ -141,7 +142,7 @@ try {
); );
await waitForEngine(page, 'cesium', { timeout: 90_000 }); await waitForEngine(page, 'cesium', { timeout: 90_000 });
// Let the first boot's deferred listeners land before counting. // 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 boot = await readSession(page);
const bootListeners = await listenerCounts(page, { bySource: true }); const bootListeners = await listenerCounts(page, { bySource: true });
report.boot = boot; report.boot = boot;
@@ -218,7 +219,7 @@ try {
window.__qaParkedNavara = window.__godsEyeView.engine; window.__qaParkedNavara = window.__godsEyeView.engine;
}); });
if (report.switches.length === 1) { if (report.switches.length === 1) {
await new Promise((resolve) => setTimeout(resolve, 2000)); await whenSettled(page);
report.firstRoundTrip = await listenerCounts(page, { bySource: true }); report.firstRoundTrip = await listenerCounts(page, { bySource: true });
} }
// Both engines draw the session's marks and files (Navara included). // Both engines draw the session's marks and files (Navara included).
@@ -261,7 +262,7 @@ try {
ui, ui,
); );
} }
await new Promise((resolve) => setTimeout(resolve, 2000)); await whenSettled(page);
const after = await readSession(page); const after = await readSession(page);
const afterListeners = await listenerCounts(page, { bySource: true }); const afterListeners = await listenerCounts(page, { bySource: true });
report.after = after; 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 { assertKeylessPreview } from './qa-strict-harness.mjs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
@@ -51,11 +52,7 @@ await strict.run({}, async (b) => {
if (new URL(req.url()).pathname === '/api/cctv/sources') catalogs++; if (new URL(req.url()).pathname === '/api/cctv/sources') catalogs++;
}); });
await p.goto(url.href); await p.goto(url.href);
await p.waitForFunction( await waitForEngine(p, 'navara');
() =>
window.__godsEyeView?.engine?.kind === 'navara' &&
document.querySelector('#loading-screen.hidden'),
);
await assertKeylessPreview(p); await assertKeylessPreview(p);
await p.evaluate(async () => { await p.evaluate(async () => {
const { dataManager, styleManager } = window.__godsEyeView; 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 assert from 'node:assert/strict';
import { import {
createStrict, createStrict,
@@ -67,7 +68,7 @@ await strict.run({}, async (b) => {
]) { ]) {
await p.evaluate((l) => window.qaRoute.setParams({ arm: l }), letter); await p.evaluate((l) => window.qaRoute.setParams({ arm: l }), letter);
await p.mouse.click(x, 440); await p.mouse.click(x, 440);
await new Promise((resolve) => setTimeout(resolve, 450)); await whenSettled(p);
} }
assert.equal(routeReads, 1); assert.equal(routeReads, 1);
await strict.passed( 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', 'the route asked for on Navara arrives on Cesium; a step is selectable',
); );
const transfer = async (kind) => { 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( assert.equal(
await p.evaluate( await p.evaluate(
() => () =>
@@ -119,7 +134,7 @@ await strict.run({}, async (b) => {
const pos = () => const pos = () =>
p.evaluate(() => window.__godsEyeView.engine.camera.pose().position); 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); 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()); let progress = await p.evaluate(() => window.qaRoute.getFlightProgress());
for (const kind of ['cesium', 'navara']) { for (const kind of ['cesium', 'navara']) {
const before = await pos(); const before = await pos();
@@ -130,7 +145,17 @@ await strict.run({}, async (b) => {
?.active, ?.active,
); );
const at = await pos(); 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 next = await pos();
const resumed = await p.evaluate(() => window.qaRoute.getFlightProgress()); const resumed = await p.evaluate(() => window.qaRoute.getFlightProgress());
assert.equal(resumed.motionId, progress.motionId); 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); assert.equal(await p.evaluate(() => window.qaRoute.getStats().count), 0);
// CLEAR releases the camera: the route's motion ends, the camera stays // 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. // 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(() => ({ const released = await p.evaluate(() => ({
motion: window.qaRoute.getFlightProgress(), motion: window.qaRoute.getFlightProgress(),
fly: window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly') fly: window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')
?.active, ?.active,
})); }));
const still = await pos(); const still = await pos();
await new Promise((resolve) => setTimeout(resolve, 1500)); await whenSettled(p);
const later = await pos(); const later = await pos();
const height = () => const height = () =>
p.evaluate(() => window.__godsEyeView.engine.camera.height()); p.evaluate(() => window.__godsEyeView.engine.camera.height());
@@ -164,11 +189,11 @@ await strict.run({}, async (b) => {
await p.mouse.move(512, 300); await p.mouse.move(512, 300);
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
await p.mouse.wheel({ deltaY: -240 }); 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(); const zoomed = await height();
await new Promise((resolve) => setTimeout(resolve, 1500)); await whenSettled(p);
const kept = await height(); const kept = await height();
await strict.check( await strict.check(
p, p,
+4 -6
View File
@@ -1,3 +1,4 @@
import { whenPickable, whenSettled } from './qa/ready.mjs';
import { assertKeylessPreview } from './qa-strict-harness.mjs'; import { assertKeylessPreview } from './qa-strict-harness.mjs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import sharp from 'sharp'; import sharp from 'sharp';
@@ -177,11 +178,7 @@ await strict.run({}, async (b) => {
return req.continue(); return req.continue();
}); });
await p.goto(url.href); await p.goto(url.href);
await p.waitForFunction( await whenSettled(p);
() =>
window.__godsEyeView?.sceneDirector &&
document.querySelector('#loading-screen.hidden'),
);
await assertKeylessPreview(p); await assertKeylessPreview(p);
await p.evaluate(async (project) => { await p.evaluate(async (project) => {
const a = window.__godsEyeView; const a = window.__godsEyeView;
@@ -218,7 +215,7 @@ await strict.run({}, async (b) => {
for (const kind of ['navara', 'cesium', 'navara']) { for (const kind of ['navara', 'cesium', 'navara']) {
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind)
await strict.switchTo(p, kind); await strict.switchTo(p, kind);
await new Promise((r) => setTimeout(r, 500)); await whenSettled(p);
const st = await p.evaluate(() => ({ const st = await p.evaluate(() => ({
same: window.director === window.__godsEyeView.sceneDirector, same: window.director === window.__godsEyeView.sceneDirector,
media: media:
@@ -231,6 +228,7 @@ await strict.run({}, async (b) => {
st.same && st.media && st.packs.count === 3 && st.actions.count === 3, st.same && st.media && st.packs.count === 3 && st.actions.count === 3,
); );
const xy = await at(fromDegrees(-97.742, 30.27, 450)); 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.mouse.click(xy.x, xy.y);
await p.waitForFunction( await p.waitForFunction(
() => document.activeElement?.dataset.directorAction === 'point', () => 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 { assertKeylessPreview } from './qa-strict-harness.mjs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import sharp from 'sharp'; import sharp from 'sharp';
@@ -61,17 +63,7 @@ await strict.run({}, async (browser) => {
const errors = []; const errors = [];
page.on('pageerror', (error) => errors.push(error.message)); page.on('pageerror', (error) => errors.push(error.message));
async function ready(kind) { async function ready(kind) {
await page.waitForFunction( await waitForEngine(page, kind);
(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 assertKeylessPreview(page); await assertKeylessPreview(page);
await page.evaluate(async () => { await page.evaluate(async () => {
await window.__godsEyeView.styleManager.initialRestorePromise; await window.__godsEyeView.styleManager.initialRestorePromise;
@@ -94,7 +86,7 @@ await strict.run({}, async (browser) => {
.get('earthquakes') .get('earthquakes')
.module.getAnalystRecords().length === 1, .module.getAnalystRecords().length === 1,
); );
await new Promise((resolve) => setTimeout(resolve, 1800)); await whenSettled(page);
const bytes = await page.screenshot({ const bytes = await page.screenshot({
clip: { x: 675, y: 403, width: 20, height: 20 }, clip: { x: 675, y: 403, width: 20, height: 20 },
}); });
@@ -272,17 +264,11 @@ await strict.run({}, async (browser) => {
await switchTo('navara'); await switchTo('navara');
assert.deepEqual(await sharpen(), { enabled: false, intensityPct: 49 }); assert.deepEqual(await sharpen(), { enabled: false, intensityPct: 49 });
// The address carries the running layer set once its debounced write lands. // The address carries the running layer set once its debounced write lands.
const settledAddress = async () => { const settledAddress = () =>
let previous; page.evaluate(() => {
for (;;) { window.__godsEyeView.styleManager.shareLinkManager.flush();
await new Promise((resolve) => setTimeout(resolve, 1200)); return new URLSearchParams(location.hash.slice(1)).get('l');
const current = await page.evaluate(() => });
new URLSearchParams(location.hash.slice(1)).get('l'),
);
if (current === previous) return current;
previous = current;
}
};
const pick = async (kind) => { const pick = async (kind) => {
await strict.pick(page, kind); await strict.pick(page, kind);
await ready(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 { assertKeylessPreview } from './qa-strict-harness.mjs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { installAlprFixture, cameras } from './fixtures/layers/alpr.mjs'; import { installAlprFixture, cameras } from './fixtures/layers/alpr.mjs';
@@ -47,11 +49,7 @@ await strict.run({}, async (b) => {
}; };
}, firmsFixture()); }, firmsFixture());
await p.goto(url.href); await p.goto(url.href);
await p.waitForFunction( await waitForEngine(p, 'navara');
() =>
window.__godsEyeView?.engine?.kind === 'navara' &&
document.querySelector('#loading-screen.hidden'),
);
await assertKeylessPreview(p); await assertKeylessPreview(p);
await p.evaluate(async (id) => { await p.evaluate(async (id) => {
const { dataManager, styleManager } = window.__godsEyeView; const { dataManager, styleManager } = window.__godsEyeView;
@@ -105,7 +103,15 @@ await strict.run({}, async (b) => {
{ timeout: 30000 }, { timeout: 30000 },
fire, 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) => const selected = (id) =>
p.waitForFunction( p.waitForFunction(
(id, fire) => (id, fire) =>
@@ -135,8 +141,8 @@ await strict.run({}, async (b) => {
fire, fire,
); );
}; };
await whenSettled(p);
const xy = await findTarget(); const xy = await findTarget();
await new Promise((r) => setTimeout(r, 1000));
await p.mouse.click(xy.x, xy.y); await p.mouse.click(xy.x, xy.y);
await selected(xy.id); await selected(xy.id);
await strict.passed( await strict.passed(
@@ -147,7 +153,6 @@ await strict.run({}, async (b) => {
: 'a click on a camera selects it', : 'a click on a camera selects it',
xy, xy,
); );
await new Promise((r) => setTimeout(r, 1600));
// The selection flies the camera to the record (1.9 s, longer on a busy // 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 // machine); a switch before it lands opens the other engine on a view
// between the two, where the record can be off screen. // between the two, where the record can be off screen.
@@ -161,6 +166,7 @@ await strict.run({}, async (b) => {
reads: window.qaFirmsReads, reads: window.qaFirmsReads,
})); }));
for (const kind of ['cesium', 'navara']) { for (const kind of ['cesium', 'navara']) {
await whenSettled(p);
await strict.switchTo(p, kind); await strict.switchTo(p, kind);
const result = await p.evaluate( const result = await p.evaluate(
(id) => ({ (id) => ({
+15 -8
View File
@@ -1,3 +1,4 @@
import { whenPickable, whenSettled } from './qa/ready.mjs';
import { fromDegrees } from '../src/geo/cartesian3.js'; import { fromDegrees } from '../src/geo/cartesian3.js';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -68,7 +69,7 @@ await strict.run({}, async (b) => {
await dataManager.setEnabled('s2-ghostmaps', true, { origin: 'user' }); await dataManager.setEnabled('s2-ghostmaps', true, { origin: 'user' });
window.qaGhost = dataManager.layers.get('s2-ghostmaps').module; 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( const { x, y } = await p.evaluate(
(pos) => { (pos) => {
const { engine } = window.__godsEyeView; const { engine } = window.__godsEyeView;
@@ -79,6 +80,12 @@ await strict.run({}, async (b) => {
}, },
fromDegrees(lon, lat), fromDegrees(lon, lat),
); );
if (pickId)
await whenPickable(
p,
{ x, y, id: pickId },
(picked, position) => picked?.id === position.id,
);
await p.mouse.click(x, y); await p.mouse.click(x, y);
}; };
const overlay = () => const overlay = () =>
@@ -120,13 +127,13 @@ await strict.run({}, async (b) => {
's2-ghostmaps', 's2-ghostmaps',
'same model, three features, one event stream, one KMZ read', 'same model, three features, one event stream, one KMZ read',
); );
await new Promise((resolve) => setTimeout(resolve, 2200)); await whenSettled(p);
for (const [name, lon, lat] of [ for (const [name, lon, lat, pickId] of [
['QA pin', -79.38, 43.65], ['QA pin', -79.38, 43.65, 'ghostmaps:cip:0:0'],
['QA route', -79.375, 43.71], ['QA route', -79.375, 43.71, 'ghostmaps:cip:1:0'],
['QA zone', -79.28, 43.61], ['QA zone', -79.28, 43.61, 'ghostmaps:cip:2:0'],
]) { ]) {
await click(lon, lat); await click(lon, lat, pickId);
await p.waitForFunction( await p.waitForFunction(
(name) => (name) =>
window.ghostStats.titles.some((v) => window.ghostStats.titles.some((v) =>
@@ -163,7 +170,7 @@ await strict.run({}, async (b) => {
); );
const hole = await overlay(); const hole = await overlay();
await click(-79.45, 43.55); await click(-79.45, 43.55);
await new Promise((resolve) => setTimeout(resolve, 800)); await whenSettled(p);
const empty = await overlay(); const empty = await overlay();
console.log( console.log(
kind, 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 { fromDegrees } from '../src/geo/cartesian3.js';
import sharp from 'sharp'; import sharp from 'sharp';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -212,7 +213,7 @@ await strict.run({}, async (b) => {
const deadline = Date.now() + 20000; const deadline = Date.now() + 20000;
let on = await capture(true, layerId); let on = await capture(true, layerId);
while (orangeOf(on) <= orangeOf(off) + 5 && Date.now() < deadline) { while (orangeOf(on) <= orangeOf(off) + 5 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 500)); await whenSettled(p);
on = await sample(); on = await sample();
} }
return on; return on;
@@ -327,7 +328,7 @@ await strict.run({}, async (b) => {
{ timeout: 15000 }, { timeout: 15000 },
visible, visible,
); );
const click = async (lon, lat) => { const click = async (lon, lat, predicate) => {
const { x, y } = await p.evaluate( const { x, y } = await p.evaluate(
(world) => { (world) => {
const { engine } = window.__godsEyeView; const { engine } = window.__godsEyeView;
@@ -338,6 +339,7 @@ await strict.run({}, async (b) => {
}, },
fromDegrees(lon, lat), fromDegrees(lon, lat),
); );
if (predicate) await whenPickable(p, { x, y }, predicate);
await p.mouse.click(x, y); await p.mouse.click(x, y);
}; };
for (const kind of ['navara', 'cesium', 'navara']) { for (const kind of ['navara', 'cesium', 'navara']) {
@@ -398,7 +400,13 @@ await strict.run({}, async (b) => {
await p.waitForFunction( await p.waitForFunction(
() => window.qaVesselEntry.module.qaDrawn()?.chevrons[0]?.show, () => 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 // 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. // empty water, then select the vessel with a click on its chevron.
const pickVessel = async () => { const pickVessel = async () => {
@@ -418,12 +426,7 @@ await strict.run({}, async (b) => {
await p.waitForFunction( await p.waitForFunction(
() => !window.qaVesselEntry.module.getSelectedInfo(), () => !window.qaVesselEntry.module.getSelectedInfo(),
); );
await new Promise((resolve) => setTimeout(resolve, 600)); await selectVessel();
await click(-79.38, 43.65);
await p.waitForFunction(
() => window.qaVesselEntry.module.getSelectedInfo()?.mmsi === '123456789',
{ timeout: 15000 },
);
await strict.passed( await strict.passed(
p, p,
'ais-live-vessels', 'ais-live-vessels',
@@ -432,10 +435,7 @@ await strict.run({}, async (b) => {
// The next switch starts from the landed view. // The next switch starts from the landed view.
await waitForCameraAtRest(p); await waitForCameraAtRest(p);
}; };
await click(-79.38, 43.65); await selectVessel();
await p.waitForFunction(
() => window.qaVesselEntry.module.getSelectedInfo()?.mmsi === '123456789',
);
await strict.passed( await strict.passed(
p, p,
'ais-live-vessels', 'ais-live-vessels',
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"metrics": { "metrics": {
"ownLaunch": 98, "ownLaunch": 98,
"fixedSleeps": 117, "fixedSleeps": 85,
"loadingScreenReadiness": 9, "loadingScreenReadiness": 1,
"cesiumOnlyReads": 75, "cesiumOnlyReads": 75,
"ownWaitHelpers": 19 "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 // 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, // 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 // 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); if (value && engine) strict.record('recent-imagery', engine, name);
console.log(`${value ? 'PASS' : 'FAIL'} ${name}`); console.log(`${value ? 'PASS' : 'FAIL'} ${name}`);
}; };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const colors = await Promise.all( const colors = await Promise.all(
['#ed3838', '#35c969'].map((background) => ['#ed3838', '#35c969'].map((background) =>
sharp({ create: { width: 256, height: 256, channels: 4, background } }) sharp({ create: { width: 256, height: 256, channels: 4, background } })
@@ -203,10 +203,10 @@ try {
() => window.__godsEyeView.engine.surface.tilesLoaded(), () => window.__godsEyeView.engine.surface.tilesLoaded(),
{ timeout: 90000 }, { timeout: 90000 },
); );
await sleep(4000); await whenSettled(page);
const capture = async (name) => { const capture = async (name) => {
await page.evaluate(() => window.__godsEyeView.requestRender('imagery-qa')); await page.evaluate(() => window.__godsEyeView.requestRender('imagery-qa'));
await sleep(350); await whenSettled(page);
const buffer = await page.screenshot(); const buffer = await page.screenshot();
await fs.writeFile(output.replace(/\.json$/, `-${name}.png`), buffer); await fs.writeFile(output.replace(/\.json$/, `-${name}.png`), buffer);
const pixels = await sharp(buffer) const pixels = await sharp(buffer)
@@ -236,7 +236,7 @@ try {
); );
check('geographic clip outside box', !red(image.outside)); check('geographic clip outside box', !red(image.outside));
await page.evaluate(() => window.__gevRecentImagery.layer.setMode('ab')); await page.evaluate(() => window.__gevRecentImagery.layer.setMode('ab'));
await sleep(2500); await whenSettled(page);
image = await capture('ab'); image = await capture('ab');
check('A/B dates on opposite sides', red(image.left) && green(image.right)); check('A/B dates on opposite sides', red(image.left) && green(image.right));
const first = await page.evaluate(() => { const first = await page.evaluate(() => {
@@ -261,7 +261,7 @@ try {
state.pins.a === first.a && state.pins.a === first.a &&
state.pins.b === first.b, state.pins.b === first.b,
); );
await sleep(1800); await whenSettled(page);
const pixels = await capture('switch-' + kind); const pixels = await capture('switch-' + kind);
check( check(
kind + ' renders both pinned dates', kind + ' renders both pinned dates',
@@ -326,7 +326,7 @@ try {
() => window.__gevRecentImagery.layer.diagnostics().ownedCount === 1, () => window.__gevRecentImagery.layer.diagnostics().ownedCount === 1,
{ timeout: 30000 }, { timeout: 30000 },
); );
await sleep(2000); await whenSettled(page);
image = await capture('toggles'); image = await capture('toggles');
check('ten toggles restore visible image', red(image.left)); check('ten toggles restore visible image', red(image.left));
report.afterToggle = await page.evaluate(() => report.afterToggle = await page.evaluate(() =>
@@ -352,7 +352,7 @@ try {
.key, .key,
); );
}); });
await sleep(2000); await whenSettled(page);
image = await capture('source-failure'); image = await capture('source-failure');
check( check(
'failed replacement does not leave stale image pixels', 'failed replacement does not leave stale image pixels',
@@ -369,14 +369,14 @@ try {
.map((c) => c.key); .map((c) => c.key);
for (let i = 0; i < 10; i++) l.setAssignment('a', keys[i % 2]); for (let i = 0; i < 10; i++) l.setAssignment('a', keys[i % 2]);
}); });
await sleep(2000); await whenSettled(page);
image = await capture('replaced'); image = await capture('replaced');
check( check(
'rapid replacement leaves final date only', 'rapid replacement leaves final date only',
green(image.left) && green(image.right), green(image.left) && green(image.right),
); );
await page.evaluate(() => window.__gevRecentImagery.layer.clear()); await page.evaluate(() => window.__gevRecentImagery.layer.clear());
await sleep(1000); await whenSettled(page);
check( check(
'clear removes both slots', 'clear removes both slots',
await page.evaluate( await page.evaluate(
@@ -430,7 +430,7 @@ try {
window.__godsEyeView.engine.camera.pose().cartographic.height > 1000000, window.__godsEyeView.engine.camera.pose().cartographic.height > 1000000,
{ timeout: 10000 }, { timeout: 10000 },
); );
await sleep(350); await whenSettled(page);
const fit = (report.fit = await page.evaluate(() => { const fit = (report.fit = await page.evaluate(() => {
const l = window.__gevRecentImagery.layer; const l = window.__gevRecentImagery.layer;
const used = l.useCurrentView(); const used = l.useCurrentView();
@@ -440,7 +440,7 @@ try {
'oversized view refuses and offers bounded zoom', 'oversized view refuses and offers bounded zoom',
!fit.used && fit.offered && fit.height >= 5000 && fit.height <= 400000, !fit.used && fit.offered && fit.height >= 5000 && fit.height <= 400000,
); );
await sleep(2200); await whenSettled(page);
check( check(
'ZOOM IN lands at requested height', 'ZOOM IN lands at requested height',
await page.evaluate( await page.evaluate(
@@ -455,7 +455,7 @@ try {
window.__gevRecentImagery.layer.clear(); window.__gevRecentImagery.layer.clear();
await window.__godsEyeView.dataManager.setEnabled('recent-imagery', false); await window.__godsEyeView.dataManager.setEnabled('recent-imagery', false);
}); });
await sleep(1000); await whenSettled(page);
report.disabled = await page.evaluate(() => report.disabled = await page.evaluate(() =>
window.__gevRecentImagery.layer.diagnostics(), 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 { fromDegrees } from '../src/geo/cartesian3.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
@@ -94,6 +95,11 @@ await strict.run({}, async (b) => {
}, },
fromDegrees(record.lon, record.lat), 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.mouse.click(screen.x, screen.y);
await p.waitForFunction( await p.waitForFunction(
() => window.qaSelections.at(-1) === 'local-datacenters:342664739', () => window.qaSelections.at(-1) === 'local-datacenters:342664739',
@@ -119,6 +125,11 @@ await strict.run({}, async (b) => {
}, },
fromDegrees(dam.lon, dam.lat), 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.mouse.click(screen.x, screen.y);
await p await p
.waitForFunction( .waitForFunction(
@@ -160,7 +171,7 @@ await strict.run({}, async (b) => {
}); });
render.request('qa-home'); render.request('qa-home');
}, start); }, start);
await new Promise((resolve) => setTimeout(resolve, 2000)); await whenSettled(p);
}; };
const counts = { ...reads }; const counts = { ...reads };
for (const kind of ['navara', 'cesium', 'navara']) { for (const kind of ['navara', 'cesium', 'navara']) {
@@ -174,7 +185,7 @@ await strict.run({}, async (b) => {
{ timeout: 15000 }, { timeout: 15000 },
); );
} }
await new Promise((r) => setTimeout(r, 1500)); await whenSettled(p);
const result = await p.evaluate( const result = await p.evaluate(
(ids) => (ids) =>
ids.map((id, i) => { 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 { assertKeylessPreview } from './qa-strict-harness.mjs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createStrict, localPreview } from './qa-strict-harness.mjs'; import { createStrict, localPreview } from './qa-strict-harness.mjs';
@@ -107,19 +108,21 @@ await strict.run({}, async (browser) => {
aircraftId, aircraftId,
); );
await page.goto(url.href, { waitUntil: 'domcontentloaded' }); await page.goto(url.href, { waitUntil: 'domcontentloaded' });
const ready = (kind) => await waitForEngine(page, 'navara');
page.waitForFunction( // Cockpit mode deliberately holds camera inputs throughout these handoffs.
(engine) => const attached = async (kind) => {
window.__godsEyeView?.engine?.kind === engine && await page.waitForFunction(
!document.querySelector( (kind) =>
'#globe-engine:is([disabled], [aria-busy="true"])', window.__godsEyeView?.engine?.kind === kind &&
) && document.querySelector('#globe-engine')?.value === kind &&
document.querySelector('#loading-screen.hidden') && !document.querySelector('#globe-engine:is([disabled], [aria-busy="true"])'),
!document.querySelector('.engine-switch-frame'),
{ timeout: 90000 }, { timeout: 90000 },
kind, kind,
); );
await ready('navara'); await page.evaluate(
() => window.__godsEyeView.styleManager.initialRestorePromise,
);
};
await page.evaluate( await page.evaluate(
() => window.__godsEyeView.styleManager.initialRestorePromise, () => window.__godsEyeView.styleManager.initialRestorePromise,
); );
@@ -165,7 +168,7 @@ await strict.run({}, async (browser) => {
const historyLength = await page.evaluate(() => history.length); const historyLength = await page.evaluate(() => history.length);
await strict.selectEngine(page, 'cesium', HIDDEN_PICKER); await strict.selectEngine(page, 'cesium', HIDDEN_PICKER);
await page.waitForFunction(() => window.__refusedContext === true); 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 // A failed switch leaves the session usable on the engine that worked and
// adds no history entry. // adds no history entry.
const survived = (kind) => const survived = (kind) =>
@@ -201,7 +204,7 @@ await strict.run({}, async (browser) => {
for (const kind of ['cesium', 'navara']) { for (const kind of ['cesium', 'navara']) {
await strict.selectEngine(page, kind, HIDDEN_PICKER); await strict.selectEngine(page, kind, HIDDEN_PICKER);
await ready(kind); await attached(kind);
const result = await page.evaluate(() => { const result = await page.evaluate(() => {
const { dataManager, engine, styleManager } = window.__godsEyeView; const { dataManager, engine, styleManager } = window.__godsEyeView;
const before = window.__handoff; 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 // Polling goes on after the switch: the same timer ticks and each tick
// reads the source once (no tick may be skipped, none added). // 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 entry = window.__godsEyeView.dataManager.layers.get(layerId);
const every = return {
Number(entry.module.refreshInterval) || layerId,
Number(entry.module.updateInterval); every:
const start = { Number(entry.module.refreshInterval) ||
Number(entry.module.updateInterval),
polls: window.__flightPolls, polls: window.__flightPolls,
ticks: window.__handoff.ticks, ticks: window.__handoff.ticks,
stamp: entry.module.getStats().lastUpdate, stamp: entry.module.getStats().lastUpdate,
}; };
const deadline = Date.now() + 2.5 * every + 5000; }, layerId);
while (window.__handoff.ticks - start.ticks < 2 && Date.now() < deadline) await page.waitForFunction(
await new Promise((resolve) => setTimeout(resolve, 250)); (start) => {
// Let the last tick's request land. const entry = window.__godsEyeView.dataManager.layers.get(start.layerId);
await new Promise((resolve) => setTimeout(resolve, 1500)); 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 { return {
every, every: start.every,
ticks: window.__handoff.ticks - start.ticks, ticks: window.__handoff.ticks - start.ticks,
polls: window.__flightPolls - start.polls, polls: window.__flightPolls - start.polls,
updated: entry.module.getStats().lastUpdate !== start.stamp, updated: entry.module.getStats().lastUpdate !== start.stamp,
sameTimer: entry.intervalId === window.__handoff.timer, sameTimer: entry.intervalId === window.__handoff.timer,
}; };
}, layerId); }, pollStart);
await strict.check( await strict.check(
page, page,
layerId, layerId,
@@ -308,7 +323,7 @@ await strict.run({}, async (browser) => {
window.__parkedNavara = window.__godsEyeView.engine; window.__parkedNavara = window.__godsEyeView.engine;
}); });
await strict.selectEngine(page, 'cesium', HIDDEN_PICKER); await strict.selectEngine(page, 'cesium', HIDDEN_PICKER);
await ready('cesium'); await attached('cesium');
const lengthOnCesium = await page.evaluate(() => { const lengthOnCesium = await page.evaluate(() => {
const request = window.__parkedNavara.render.request; const request = window.__parkedNavara.render.request;
window.__parkedNavara.render.request = function (...args) { window.__parkedNavara.render.request = function (...args) {
@@ -320,7 +335,7 @@ await strict.run({}, async (browser) => {
}); });
await strict.selectEngine(page, 'navara', HIDDEN_PICKER); await strict.selectEngine(page, 'navara', HIDDEN_PICKER);
await page.waitForFunction(() => window.__refusedResume === true); await page.waitForFunction(() => window.__refusedResume === true);
await ready('cesium'); await attached('cesium');
assert.ok( assert.ok(
(await page.evaluate( (await page.evaluate(
(length) => history.length === length, (length) => history.length === length,
@@ -349,7 +364,7 @@ await strict.run({}, async (browser) => {
window.__parkedCesium = window.__godsEyeView.engine; window.__parkedCesium = window.__godsEyeView.engine;
}); });
await strict.selectEngine(page, 'navara', HIDDEN_PICKER); await strict.selectEngine(page, 'navara', HIDDEN_PICKER);
await ready('navara'); await attached('navara');
assert.ok( assert.ok(
await page.evaluate(() => { await page.evaluate(() => {
const { engine, styleManager } = window.__godsEyeView; const { engine, styleManager } = window.__godsEyeView;
@@ -439,7 +454,7 @@ await strict.run({}, async (browser) => {
await page.click( await page.click(
'#loading-screen .engine-switch-retry [data-engine="cesium"]', '#loading-screen .engine-switch-retry [data-engine="cesium"]',
); );
await ready('cesium'); await attached('cesium');
assert.ok( assert.ok(
await page.evaluate(() => { await page.evaluate(() => {
const { dataManager, styleManager } = window.__godsEyeView; const { dataManager, styleManager } = window.__godsEyeView;
@@ -482,17 +497,25 @@ await strict.run({}, async (browser) => {
).intervalId; ).intervalId;
}); });
await strict.pick(page, 'navara'); await strict.pick(page, 'navara');
await ready('navara'); await waitForEngine(page, 'navara');
const after = await page.evaluate(async () => { 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 { dataManager } = window.__godsEyeView;
const entry = dataManager.layers.get(window.__qaAircraft.layerId); 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 { return {
same: same:
dataManager === window.__handoff.dataManager && dataManager === window.__handoff.dataManager &&
+1
View File
@@ -0,0 +1 @@
[]
+9 -6
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
import { whenSettled } from './qa/ready.mjs';
// Annotations on both engines, under the strict network policy: the same // 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 // 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 // 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 strict = createStrict({ origin: base.origin, layers: ['annotations'] });
const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-annotations'; const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-annotations';
fs.mkdirSync(SHOTS, { recursive: true }); 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. // The area: a box around the view centre, clear of the panels.
const RING = [ const RING = [
[LON - 0.012, LAT - 0.009], [LON - 0.012, LAT - 0.009],
@@ -53,7 +53,7 @@ await strict.run(
content: '.gev-screen-whiteboard{visibility:hidden!important}', content: '.gev-screen-whiteboard{visibility:hidden!important}',
}); });
await page.evaluate(() => window.__godsEyeView.requestRender?.('qa')); await page.evaluate(() => window.__godsEyeView.requestRender?.('qa'));
await wait(700); await whenSettled(page);
const { data, info } = await sharp( const { data, info } = await sharp(
await page.screenshot({ await page.screenshot({
clip: { x: 340, y: 180, width: 600, height: 500 }, clip: { x: 340, y: 180, width: 600, height: 500 },
@@ -155,7 +155,7 @@ await strict.run(
) { ) {
await strict.switchTo(page, kind); await strict.switchTo(page, kind);
} }
await wait(2500); await whenSettled(page);
const state = await page.evaluate(() => ({ const state = await page.evaluate(() => ({
same: window.__gevAnnotations === window.qaBoard, same: window.__gevAnnotations === window.qaBoard,
count: window.qaBoard.count(), count: window.qaBoard.count(),
@@ -210,9 +210,12 @@ await strict.run(
const before = engine.camera.height(); const before = engine.camera.height();
const area = window.qaBoard.list().find((m) => m.label === 'QA Area'); const area = window.qaBoard.list().find((m) => m.label === 'QA Area');
const ok = window.qaBoard.frame(area.id); const ok = window.qaBoard.frame(area.id);
await new Promise((r) => setTimeout(r, 2600)); return { ok, before };
return { ok, before, after: engine.camera.height() };
}); });
await whenSettled(page);
flight.after = await page.evaluate(() =>
window.__godsEyeView.engine.camera.height(),
);
await strict.check( await strict.check(
page, page,
'annotations', 'annotations',
@@ -224,9 +227,9 @@ await strict.run(
const hidden = await page.evaluate(async () => { const hidden = await page.evaluate(async () => {
for (const mark of window.qaBoard.list()) for (const mark of window.qaBoard.list())
window.qaBoard.setHidden(mark.id, true); window.qaBoard.setHidden(mark.id, true);
await new Promise((r) => setTimeout(r, 600));
return window.qaBoard.list().every((m) => m.hidden); return window.qaBoard.list().every((m) => m.hidden);
}); });
await whenSettled(page);
const afterHide = await board(); const afterHide = await board();
await strict.check( await strict.check(
page, 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. // Clouds grey the frame: the blue sky and the map's coloured lines go.
const saturation = (pixels) => stats(pixels).saturation; 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) { async function converged(page, name) {
await page.evaluate(async () => { await page.evaluate(async () => {
const { engine } = window.__godsEyeView; const { engine } = window.__godsEyeView;
engine.render.setContinuous(true); engine.render.setContinuous(true);
await new Promise((resolve) => setTimeout(resolve, 3000)); try {
engine.render.setContinuous(false); 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); const pixels = await capture(page);
save(name, pixels); save(name, pixels);
+12 -21
View File
@@ -1,4 +1,6 @@
#!/usr/bin/env node #!/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 // UTILITIES ▸ Draw on Navara under the strict network policy, with real
// clicks and keys: Draw takes the pointer, clicks on the globe are vertices // 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 // 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 strict = createStrict({ origin: base.origin, layers: ['draw'] });
const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-draw'; const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-draw';
fs.mkdirSync(SHOTS, { recursive: true }); 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. // An area's corners, clear of the open UTILITIES panel and the rails.
const AREA = [ const AREA = [
[560, 330], [560, 330],
@@ -44,18 +45,8 @@ await strict.run(
page.on('pageerror', (error) => errors.push(error.message)); page.on('pageerror', (error) => errors.push(error.message));
await page.goto(base.href); await page.goto(base.href);
const ready = async (kind) => { const ready = async (kind) => {
await page.waitForFunction( await waitForEngine(page, kind);
(k) => await page.waitForFunction(() => window.__gevDrawTool?.attached);
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 page.evaluate( await page.evaluate(
() => window.__godsEyeView.styleManager.initialRestorePromise, () => window.__godsEyeView.styleManager.initialRestorePromise,
); );
@@ -84,7 +75,7 @@ await strict.run(
await page.addStyleTag({ await page.addStyleTag({
content: '.gev-screen-whiteboard{visibility:hidden!important}', content: '.gev-screen-whiteboard{visibility:hidden!important}',
}); });
await wait(800); await whenSettled(page);
const { data, info } = await sharp( const { data, info } = await sharp(
await page.screenshot({ await page.screenshot({
clip: { x: 520, y: 300, width: 280, height: 230 }, clip: { x: 520, y: 300, width: 280, height: 230 },
@@ -124,10 +115,10 @@ await strict.run(
}, shape); }, shape);
for (const [x, y] of points) { for (const [x, y] of points) {
await page.mouse.click(x, y); await page.mouse.click(x, y);
await wait(120); await whenSettled(page);
} }
await finish(); await finish();
await wait(800); await whenSettled(page);
}; };
await openUtilities(); await openUtilities();
@@ -152,10 +143,10 @@ await strict.run(
{ timeout: 3000 }, { timeout: 3000 },
index + 1, index + 1,
); );
await wait(150); await whenSettled(page);
} }
await page.mouse.move(...AREA[3]); await page.mouse.move(...AREA[3]);
await wait(300); await whenSettled(page);
const sketch = await tool(); const sketch = await tool();
await page.screenshot({ path: `${SHOTS}/navara-sketch.png` }); await page.screenshot({ path: `${SHOTS}/navara-sketch.png` });
await strict.check( await strict.check(
@@ -173,7 +164,7 @@ await strict.run(
await page.waitForFunction(() => window.__gevAnnotations.count() === 1, { await page.waitForFunction(() => window.__gevAnnotations.count() === 1, {
timeout: 10000, timeout: 10000,
}); });
await wait(800); await whenSettled(page);
const drawn = await amber(); const drawn = await amber();
await page.screenshot({ path: `${SHOTS}/navara-area.png` }); await page.screenshot({ path: `${SHOTS}/navara-area.png` });
const area = await page.evaluate(() => const area = await page.evaluate(() =>
@@ -241,7 +232,7 @@ await strict.run(
assert.equal((await tool()).vertices, 2); assert.equal((await tool()).vertices, 2);
await strict.pick(page, 'cesium'); await strict.pick(page, 'cesium');
await ready('cesium'); await ready('cesium');
await wait(1500); await whenSettled(page);
const switched = await tool(); const switched = await tool();
await page.screenshot({ path: `${SHOTS}/cesium-after-switch.png` }); await page.screenshot({ path: `${SHOTS}/cesium-after-switch.png` });
await strict.check( await strict.check(
@@ -280,7 +271,7 @@ await strict.run(
); );
await strict.pick(page, 'navara'); await strict.pick(page, 'navara');
await ready('navara'); await ready('navara');
await wait(2000); await whenSettled(page);
const back = await tool(); const back = await tool();
const backAmber = await amber(); const backAmber = await amber();
await page.screenshot({ path: `${SHOTS}/navara-back.png` }); await page.screenshot({ path: `${SHOTS}/navara-back.png` });
+12 -16
View File
@@ -1,4 +1,6 @@
#!/usr/bin/env node #!/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, // UTILITIES ▸ Import on Navara under the strict network policy: GeoJSON,
// KML, KMZ (a packed icon and a ground overlay) and GPX through the real // 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 // 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 strict = createStrict({ origin: base.origin, layers: ['geo-import'] });
const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-geo-import'; const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-geo-import';
fs.mkdirSync(SHOTS, { recursive: true }); fs.mkdirSync(SHOTS, { recursive: true });
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const TRAP = 'gev-qa-trap.invalid'; const TRAP = 'gev-qa-trap.invalid';
/* ── fixtures (as scripts/qa-geo-import.mjs) ───────────────────────────── */ /* ── fixtures (as scripts/qa-geo-import.mjs) ───────────────────────────── */
@@ -148,17 +149,9 @@ await strict.run(
page.on('pageerror', (error) => errors.push(error.message)); page.on('pageerror', (error) => errors.push(error.message));
await page.goto(base.href); await page.goto(base.href);
const ready = async (kind) => { const ready = async (kind) => {
await waitForEngine(page, kind);
await page.waitForFunction( await page.waitForFunction(
(k) => () => window.__gevGeoImport?.diagnostics().attached,
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,
); );
await page.evaluate( await page.evaluate(
() => window.__godsEyeView.styleManager.initialRestorePromise, () => window.__godsEyeView.styleManager.initialRestorePromise,
@@ -182,7 +175,7 @@ await strict.run(
/** Pixels near the first file's colour (#39d0ff) on the canvas. */ /** Pixels near the first file's colour (#39d0ff) on the canvas. */
const cyan = async () => { const cyan = async () => {
await page.evaluate(() => window.__godsEyeView.requestRender?.('qa')); await page.evaluate(() => window.__godsEyeView.requestRender?.('qa'));
await wait(900); await whenSettled(page);
const { data, info } = await sharp( const { data, info } = await sharp(
await page.screenshot({ await page.screenshot({
clip: { x: 400, y: 260, width: 480, height: 300 }, clip: { x: 400, y: 260, width: 480, height: 300 },
@@ -257,7 +250,7 @@ await strict.run(
{ pins: imported.pins }, { pins: imported.pins },
); );
// The first file is flown to after the batch; settle there. // 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` }); await page.screenshot({ path: `${SHOTS}/navara-imported.png` });
for (const kind of ['navara', 'cesium', 'navara']) { for (const kind of ['navara', 'cesium', 'navara']) {
@@ -270,7 +263,7 @@ await strict.run(
() => window.__gevGeoImport.diagnostics().drawn === 4, () => window.__gevGeoImport.diagnostics().drawn === 4,
{ timeout: 60000 }, { timeout: 60000 },
); );
await wait(2500); await whenSettled(page);
await page.screenshot({ path: `${SHOTS}/${kind}-after-switch.png` }); await page.screenshot({ path: `${SHOTS}/${kind}-after-switch.png` });
} }
const shown = await cyan(); const shown = await cyan();
@@ -302,11 +295,14 @@ await strict.run(
const before = engine.camera.height(); const before = engine.camera.height();
const [first] = window.__gevGeoImport.list(); const [first] = window.__gevGeoImport.list();
const ok = window.__gevGeoImport.flyTo(first.id); const ok = window.__gevGeoImport.flyTo(first.id);
await new Promise((r) => setTimeout(r, 2500)); return { ok, before };
return { ok, before, after: engine.camera.height() };
}, },
fromDegrees(-100, 40, 3e6), fromDegrees(-100, 40, 3e6),
); );
await whenSettled(page);
flight.after = await page.evaluate(() =>
window.__godsEyeView.engine.camera.height(),
);
await strict.check( await strict.check(
page, page,
'geo-import', 'geo-import',
+6 -5
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
import { whenSettled } from './qa/ready.mjs';
// The DISPLAY scope mask and the celestial ring on Navara, under the strict // 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 // 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 // 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'; const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-scope';
fs.mkdirSync(SHOTS, { recursive: true }); 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. // 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 GLOBE = fromDegrees(-122.4194, 37.7749, 20_000_000);
const CITY = fromDegrees(-122.4194, 37.7749, 3000); const CITY = fromDegrees(-122.4194, 37.7749, 3000);
@@ -47,7 +48,7 @@ await strict.run(
await page.goto(base.href); await page.goto(base.href);
await waitForEngine(page, 'navara'); await waitForEngine(page, 'navara');
const edgeLuminance = async () => { const edgeLuminance = async () => {
await wait(900); await whenSettled(page);
const { data, info } = await sharp(await page.screenshot({ clip: EDGE })) const { data, info } = await sharp(await page.screenshot({ clip: EDGE }))
.removeAlpha() .removeAlpha()
.raw() .raw()
@@ -75,7 +76,7 @@ await strict.run(
) { ) {
await strict.switchTo(page, kind); await strict.switchTo(page, kind);
} }
await wait(2500); await whenSettled(page);
const mask = await page.evaluate(() => { const mask = await page.evaluate(() => {
const canvas = document.getElementById('scope-mask'); const canvas = document.getElementById('scope-mask');
const { engine } = window.__godsEyeView; const { engine } = window.__godsEyeView;
@@ -133,7 +134,7 @@ await strict.run(
engine.camera.setView({ destination }); engine.camera.setView({ destination });
styleManager.setCelestialRingEnabled(true); styleManager.setCelestialRingEnabled(true);
}, GLOBE); }, GLOBE);
await wait(2500); await whenSettled(page);
const ring = await page.evaluate(() => { const ring = await page.evaluate(() => {
const overlay = document.getElementById('celestial-ring-overlay'); const overlay = document.getElementById('celestial-ring-overlay');
const canvases = [...(overlay?.querySelectorAll('canvas') ?? [])]; const canvases = [...(overlay?.querySelectorAll('canvas') ?? [])];
@@ -165,7 +166,7 @@ await strict.run(
styleManager.setCelestialRingEnabled(false); styleManager.setCelestialRingEnabled(false);
engine.camera.setView({ destination }); engine.camera.setView({ destination });
}, CITY); }, CITY);
await wait(1000); await whenSettled(page);
} }
assert.deepEqual(errors, []); assert.deepEqual(errors, []);
console.log('PASS scope mask and celestial ring on both engines'); 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 // 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 // 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 // floor, by each contact's place on screen exactly as the CPU sampler of
@@ -85,15 +86,23 @@ const read = (page, places) =>
}, places); }, places);
// Frames the engine draws in `ms` without being asked. // Frames the engine draws in `ms` without being asked.
const framesIn = (page, ms) => const framesIn = async (page, ms) => {
page.evaluate(async (ms) => { await page.evaluate(() => {
const { engine } = window.__godsEyeView; const sample = (window.__qaSonarFrames = { frames: 0, at: performance.now() });
let frames = 0; sample.off = window.__godsEyeView.engine.frames.postRender.addEventListener(
const off = engine.frames.postRender.addEventListener(() => frames++); () => sample.frames++,
await new Promise((resolve) => setTimeout(resolve, ms)); );
off(); });
return frames; await page.waitForFunction(
}, ms); (ms) => performance.now() - window.__qaSonarFrames.at >= ms,
{},
ms,
);
return page.evaluate(() => {
window.__qaSonarFrames.off();
return window.__qaSonarFrames.frames;
});
};
const mean = (values) => const mean = (values) =>
values.reduce((sum, value) => sum + value, 0) / Math.max(1, values.length); values.reduce((sum, value) => sum + value, 0) / Math.max(1, values.length);
@@ -165,8 +174,16 @@ await strict.run({}, async (browser) => {
); );
const sweeps = []; const sweeps = [];
for (const wait of [0, 1300, 1300]) { for (let sample = 0; sample < 3; sample++) {
await new Promise((resolve) => setTimeout(resolve, wait)); 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 { luma, sonar } = await read(page, places);
const expected = places.map(({ x, y }) => const expected = places.map(({ x, y }) =>
sonarFactorAt(x, y, WIDTH, HEIGHT, sonar), sonarFactorAt(x, y, WIDTH, HEIGHT, sonar),
@@ -219,7 +236,7 @@ await strict.run({}, async (browser) => {
const spread = const spread =
Math.max(...neutral.luma.slice(0, 32)) - Math.max(...neutral.luma.slice(0, 32)) -
Math.min(...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); const idle = await framesIn(page, 1500);
check( check(
'with the sweep off every contact takes the neutral factor and the scene rests', '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 sharp from 'sharp';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
@@ -89,7 +90,7 @@ await strict.run({}, async (b) => {
}); });
}); });
const sample = async () => { const sample = async () => {
await new Promise((r) => setTimeout(r, 500)); await whenSettled(p);
return sharp(await p.screenshot()) return sharp(await p.screenshot())
.extract({ left: 490, top: 370, width: 25, height: 25 }) .extract({ left: 490, top: 370, width: 25, height: 25 })
.removeAlpha() .removeAlpha()
+4 -3
View File
@@ -1,3 +1,4 @@
import { whenSettled } from './qa/ready.mjs';
import { fromDegrees } from '../src/geo/cartesian3.js'; import { fromDegrees } from '../src/geo/cartesian3.js';
import sharp from 'sharp'; import sharp from 'sharp';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -52,7 +53,7 @@ await strict.run({}, async (b) => {
assert.equal(stats.count, 1); assert.equal(stats.count, 1);
assert.equal(params.presentation, 'bhote-koshi-path-overview'); assert.equal(params.presentation, 'bhote-koshi-path-overview');
// Look for the cyan river in a scene crop away from the UI rails. // 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 () => const crop = async () =>
sharp(await p.screenshot()) sharp(await p.screenshot())
.extract({ left: 400, top: 320, width: 230, height: 180 }) .extract({ left: 400, top: 320, width: 230, height: 180 })
@@ -63,7 +64,7 @@ await strict.run({}, async (b) => {
await p.evaluate(() => await p.evaluate(() =>
window.qaLocator.setParams({ presentation: 'nepal-context' }), window.qaLocator.setParams({ presentation: 'nepal-context' }),
); );
await new Promise((resolve) => setTimeout(resolve, 500)); await whenSettled(p);
const baseline = await crop(); const baseline = await crop();
let added = 0; let added = 0;
for (let i = 0; i < rendered.length; i += 3) { for (let i = 0; i < rendered.length; i += 3) {
@@ -103,7 +104,7 @@ await strict.run({}, async (b) => {
preview: true, preview: true,
}); });
}); });
await new Promise((resolve) => setTimeout(resolve, 2500)); await whenSettled(p);
// The last of three quick presentation changes is the one that stays. // The last of three quick presentation changes is the one that stays.
const last = await p.evaluate(() => ({ const last = await p.evaluate(() => ({
params: window.qaLocator.getParams(), params: window.qaLocator.getParams(),
+2 -1
View File
@@ -1,3 +1,4 @@
import { whenSettled } from './qa/ready.mjs';
import sharp from 'sharp'; import sharp from 'sharp';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
@@ -151,7 +152,7 @@ await strict.run({}, async (b) => {
const deadline = Date.now() + 20000; const deadline = Date.now() + 20000;
let on = await sample(); let on = await sample();
while (!test(on, off) && Date.now() < deadline) { while (!test(on, off) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 500)); await whenSettled(p);
on = await sample(); on = await sample();
} }
return on; 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 { assertKeylessPreview } from './qa-strict-harness.mjs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { launchFixture } from '../src/bench/launchFixture.js'; import { launchFixture } from '../src/bench/launchFixture.js';
@@ -58,11 +59,7 @@ await strict.run({}, async (b) => {
SATELLITE_FIXTURE_EPOCH, SATELLITE_FIXTURE_EPOCH,
); );
await p.goto(url.href); await p.goto(url.href);
await p.waitForFunction( await waitForEngine(p, 'navara');
() =>
window.__godsEyeView?.engine?.kind === 'navara' &&
document.querySelector('#loading-screen.hidden'),
);
await assertKeylessPreview(p); await assertKeylessPreview(p);
await p.evaluate(async (missions) => { await p.evaluate(async (missions) => {
const { dataManager, styleManager } = window.__godsEyeView; 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 assert from 'node:assert/strict';
import { import {
createStrict, createStrict,
@@ -74,8 +75,9 @@ await strict.run({}, async (browser) => {
await until(() => window.qaStreet.getUIState().coverage.count > 0); await until(() => window.qaStreet.getUIState().coverage.count > 0);
// The fixture drive passes under the middle of the canvas. Click its actual picked line. // The fixture drive passes under the middle of the canvas. Click its actual picked line.
const pickLine = async () => { const pickLine = async () => {
const xy = await p.evaluate( const xy = await whenPickable(
(prefix) => { p,
() => {
const { engine } = window.__godsEyeView, const { engine } = window.__godsEyeView,
canvas = engine.host.canvas, canvas = engine.host.canvas,
rect = canvas.getBoundingClientRect(); rect = canvas.getBoundingClientRect();
@@ -84,14 +86,17 @@ await strict.run({}, async (browser) => {
const at = { x: rect.width / 2 + dx, y: rect.height / 2 + dy }, const at = { x: rect.width / 2 + dx, y: rect.height / 2 + dy },
pick = engine.picker.pick(at), pick = engine.picker.pick(at),
id = pick?.id?.id ?? pick?.id; 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 { x: at.x + rect.x, y: at.y + rect.y, id };
} }
return null; 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); console.log('pick', xy);
await p.mouse.click(xy.x, xy.y); await p.mouse.click(xy.x, xy.y);
await until( await until(
@@ -186,7 +191,6 @@ await strict.run({}, async (browser) => {
!window.qaStreet.getUIState().coverage.loading && !window.qaStreet.getUIState().coverage.loading &&
window.qaStreet.getUIState().coverage.count > 0, window.qaStreet.getUIState().coverage.count > 0,
); );
await new Promise((resolve) => setTimeout(resolve, 1000));
await pickLine(); await pickLine();
assert.deepEqual(errors, []); assert.deepEqual(errors, []);
assert.deepEqual(fixture.unknown, []); 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 // Contract: a cold start loads only the engine it starts. From the network
// log of a fresh page: // log of a fresh page:
// - a cold Navara start requests nothing of the Cesium library (/cesium/…: // - a cold Navara start requests nothing of the Cesium library (/cesium/…:
@@ -52,7 +53,8 @@ await strict.run({}, async (b) => {
await installSessionFixtures(p); await installSessionFixtures(p);
await openApp(p, base.origin, start); await openApp(p, base.origin, start);
// Let lazy loads that follow the start (workers, idle prefetch) happen. // 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 before = [...urls];
const other = start === 'navara' ? 'cesium' : 'navara'; const other = start === 'navara' ? 'cesium' : 'navara';
await strict.pick(p, other); await strict.pick(p, other);
+23 -5
View File
@@ -70,12 +70,14 @@ await strict.run({}, async (b) => {
!document.querySelector( !document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])', '#globe-engine:is([disabled], [aria-busy="true"])',
) && ) &&
!document.querySelector('.engine-switch-frame') && !document.querySelector('.engine-switch-frame')) ||
document.querySelector('#loading-screen.hidden')) ||
document.querySelector('.engine-switch-retry'), document.querySelector('.engine-switch-retry'),
{ polling: 100, timeout: 90000 }, { 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) => const intact = (state) =>
state.running && state.running &&
@@ -166,7 +168,7 @@ await strict.run({}, async (b) => {
const outcome = after.engine === to ? 'switched' : 'rolled back'; const outcome = after.engine === to ? 'switched' : 'rolled back';
if (after.running) await rememberEngine(p); if (after.running) await rememberEngine(p);
const drawn = after.running ? await draws(after.engine) : false; 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 // 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. // samples of the last frame differ from the first one.
const noFrame = const noFrame =
@@ -202,7 +204,23 @@ await strict.run({}, async (b) => {
() => count.watched++, () => 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?.()); offs.forEach((off) => off?.());
return { return {
...count, ...count,
+151 -25
View File
@@ -29,6 +29,30 @@ import {
import { createMissLoader, tileCacheFile } from './qa/tileCache.mjs'; import { createMissLoader, tileCacheFile } from './qa/tileCache.mjs';
import { closeBrowser, diagnoseExit, exitAfterFlush } from './qa/browser.mjs'; import { closeBrowser, diagnoseExit, exitAfterFlush } from './qa/browser.mjs';
import { DAYTIME, shiftTime } from './qa/time.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']); export const ENGINES = Object.freeze(['cesium', 'navara']);
/** What the Globe Engine picker offers: the engines and Navara HD. */ /** 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) else if (ledger.cesiumBeforeLoad.length < 50)
ledger.cesiumBeforeLoad.push({ stack, count: 1 }); 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.enable');
await session.send('Runtime.addBinding', { name: monitorBinding });
await session.send('Runtime.addBinding', { name: binding }); await session.send('Runtime.addBinding', { name: binding });
await session.send('Page.enable'); await session.send('Page.enable');
await session.send('Page.addScriptToEvaluateOnNewDocument', { await session.send('Page.addScriptToEvaluateOnNewDocument', {
source: `(${shiftTime})(${JSON.stringify(time)}); source: `(${shiftTime})(${JSON.stringify(time)});
(${installContractMonitors})(${createContractMonitor});
(() => { (() => {
if (window.top !== window) return; if (window.top !== window) return;
const seen = new Set(); 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. */ /** A new, empty network ledger. */
export function networkLedger() { export function networkLedger() {
return { return {
@@ -416,6 +525,7 @@ export function networkLedger() {
violations: [], violations: [],
setupErrors: [], setupErrors: [],
cesiumBeforeLoad: [], cesiumBeforeLoad: [],
contractMonitors: [],
}; };
} }
@@ -532,6 +642,9 @@ export async function pickEngine(
'globe-engine', 'globe-engine',
'the click focuses the picker', '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]); await page.keyboard.type(kind[0]);
// Whatever the person does next, right after the key (no round trip to // Whatever the person does next, right after the key (no round trip to
// the page in between). // the page in between).
@@ -555,12 +668,13 @@ export async function pickEngine(
(window.__qaPick.busy && (window.__qaPick.busy &&
!document.querySelector( !document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])', '#globe-engine:is([disabled], [aria-busy="true"])',
) && )) || document.querySelector('.engine-switch-retry'),
document.querySelector('#loading-screen.hidden') &&
!document.querySelector('.engine-switch-frame')) ||
document.querySelector('.engine-switch-retry'),
{ timeout: 120000, polling: 50 }, { 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()); await page.evaluate(() => window.__qaPickObserver?.disconnect());
Object.assign( Object.assign(
how, how,
@@ -583,23 +697,22 @@ export async function pickEngine(
/** Wait until `kind` runs, its picker is idle and its view is restored. */ /** Wait until `kind` runs, its picker is idle and its view is restored. */
export async function waitForEngine(page, kind, { timeout = 120000 } = {}) { export async function waitForEngine(page, kind, { timeout = 120000 } = {}) {
const started = Date.now();
await page.waitForFunction( await page.waitForFunction(
(expected) => (expected) =>
// Navara HD runs the Navara engine.
window.__godsEyeView?.engine?.kind === window.__godsEyeView?.engine?.kind ===
(expected === 'cesium' ? 'cesium' : 'navara') && (expected === 'cesium' ? 'cesium' : 'navara') &&
document.querySelector('#globe-engine')?.value === expected && document.querySelector('#globe-engine')?.value === expected &&
!document.querySelector( !document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])', '#globe-engine:is([disabled], [aria-busy="true"])',
) && ),
!document.querySelector('.engine-switch-frame') &&
document.querySelector('#loading-screen.hidden'),
{ timeout }, { timeout },
kind, kind,
); );
await page.evaluate( await whenSettled(page, {
() => window.__godsEyeView.styleManager.initialRestorePromise, timeoutMs: Math.max(1, timeout - (Date.now() - started)),
); });
await finishDrawComparison(page);
await assertKeylessPreview(page); await assertKeylessPreview(page);
} }
@@ -668,6 +781,7 @@ export function createStrict({
const ledger = networkLedger(); const ledger = networkLedger();
const assertions = []; const assertions = [];
const picks = []; const picks = [];
const draws = [];
const timings = []; const timings = [];
const extra = {}; const extra = {};
let finished = false; let finished = false;
@@ -718,6 +832,29 @@ export function createStrict({
await closeBrowser(browser); await closeBrowser(browser);
throw error; 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; return browser;
}, },
/** Launch with `options`, run `body(browser)`, close, then finish(). */ /** Launch with `options`, run `body(browser)`, close, then finish(). */
@@ -859,6 +996,8 @@ export function createStrict({
assertions, assertions,
network, network,
timings, timings,
drawLedgers: draws,
contractMonitors: ledger.contractMonitors,
...extra, ...extra,
}; };
if (args.evidence) { if (args.evidence) {
@@ -937,18 +1076,5 @@ export async function clickEmptyGround(
* inputs off, and a click does not cut it short. * inputs off, and a click does not cut it short.
*/ */
export async function waitForCameraAtRest(page, { timeout = 15000 } = {}) { export async function waitForCameraAtRest(page, { timeout = 15000 } = {}) {
await page.evaluate(() => { await whenSettled(page, { timeoutMs: timeout });
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 },
);
} }
+537
View File
@@ -10,7 +10,9 @@ import { DAYTIME, daytimeNear, shiftTime } from './qa/time.mjs';
import { import {
createStrict, createStrict,
installStrictNetwork, installStrictNetwork,
installContractMonitors,
networkLedger, networkLedger,
pickEngine,
} from './qa-strict-harness.mjs'; } from './qa-strict-harness.mjs';
function fakeBrowser(t, close = async () => {}) { 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/); 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 // node scripts/qa-strict-hidden-switch.mjs http://127.0.0.1:4208
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createStrict, localPreview } from './qa-strict-harness.mjs'; import {
createStrict,
localPreview,
waitForEngine,
} from './qa-strict-harness.mjs';
import { import {
assertSession, assertSession,
installFirstFrameProbe, installFirstFrameProbe,
@@ -76,8 +80,7 @@ await strict.run({}, async (b) => {
!document.querySelector( !document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])', '#globe-engine:is([disabled], [aria-busy="true"])',
) && ) &&
!document.querySelector('.engine-switch-frame') && !document.querySelector('.engine-switch-frame'),
document.querySelector('#loading-screen.hidden'),
{ polling: 250, timeout: 60000 }, { polling: 250, timeout: 60000 },
kind, kind,
); );
@@ -86,6 +89,8 @@ await strict.run({}, async (b) => {
hide: window.__qaHidden.shown.find((e) => e.state === 'hidden') ?? null, hide: window.__qaHidden.shown.find((e) => e.state === 'hidden') ?? null,
pick: { at: window.__qaPick.at, idleAt: window.__qaPick.idleAt }, pick: { at: window.__qaPick.at, idleAt: window.__qaPick.idleAt },
drawn: window.__qaHidden.drawn ?? null, drawn: window.__qaHidden.drawn ?? null,
coverPath:
window.__godsEyeView.engine.host.canvas.dataset.firstFrameOutcome,
})); }));
await assertSession( await assertSession(
p, p,
@@ -111,10 +116,12 @@ await strict.run({}, async (b) => {
{ polling: 250, timeout: 30000 }, { polling: 250, timeout: 30000 },
reads, reads,
); );
await waitForEngine(p, kind);
await rememberEngine(p); await rememberEngine(p);
const row = { const row = {
kind, kind,
temperature, temperature,
coverPath: hidden.coverPath,
hiddenWhileBusy: Boolean(hidden.hide?.busy), hiddenWhileBusy: Boolean(hidden.hide?.busy),
settledWhileHidden: hidden.state === 'hidden', settledWhileHidden: hidden.state === 'hidden',
// From the tab being hidden to the picker being idle again. // From the tab being hidden to the picker being idle again.
@@ -131,9 +138,9 @@ await strict.run({}, async (b) => {
results.push(row); results.push(row);
assert.ok(row.hiddenWhileBusy, 'the tab is hidden while the switch runs'); assert.ok(row.hiddenWhileBusy, 'the tab is hidden while the switch runs');
assert.ok(row.settledWhileHidden, 'the switch settles in the hidden tab'); 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( assert.ok(
row.settleAfterHideMs !== null && row.settleAfterHideMs < 9000, ['frame', 'hidden'].includes(row.coverPath),
JSON.stringify(row), JSON.stringify(row),
); );
assert.equal(row.history, 1, 'one history entry'); 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 // 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 // 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 // switch back builds a new engine on the same session that draws a
@@ -21,9 +21,9 @@ import {
startSession, startSession,
} from './qa-strict-session.mjs'; } 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]); 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 other = (kind) => (kind === 'navara' ? 'cesium' : 'navara');
const strict = createStrict({ origin: base.origin, layers: [] }); const strict = createStrict({ origin: base.origin, layers: [] });
await strict.run({}, async (b) => { await strict.run({}, async (b) => {
@@ -70,7 +70,6 @@ await strict.run({}, async (b) => {
]), ]),
kind, kind,
); );
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
for (const parked of ['navara', 'cesium']) { for (const parked of ['navara', 'cesium']) {
const running = other(parked); const running = other(parked);
@@ -82,7 +81,6 @@ await strict.run({}, async (b) => {
const parkedAt = await pick(running); const parkedAt = await pick(running);
await rememberEngine(p); await rememberEngine(p);
// A switch back in time is warm. // A switch back in time is warm.
await wait(1000);
const backAt = await pick(parked); const backAt = await pick(parked);
const early = await engines(parked); const early = await engines(parked);
await assertSession(p, parked, `a quick switch back to ${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. // Parked past the delay: released, and the next switch back is cold.
await pick(running); 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); const released = await engines(parked);
contract( contract(
`${parked} parked past the delay is released: its context is lost`, `${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 // 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` // 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 // (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. // 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'; const other = start === 'cesium' ? 'navara' : 'cesium';
await strict.pick(page, other); await strict.pick(page, other);
await waitForEngine(page, other, { timeout: 90000 }); 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 }; const row = { start, switchedTo: other, entry: entry.scripts, errors };
results.push(row); results.push(row);
console.log(errors.length ? 'FAIL' : 'PASS', JSON.stringify(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 // 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 // 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 // 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(); history.back();
}); });
await waitForEngine(p, 'navara'); await waitForEngine(p, 'navara');
await new Promise((resolve) => setTimeout(resolve, 1500)); await whenSettled(p);
await assertSession(p, 'navara', 'Forward supersedes Back'); await assertSession(p, 'navara', 'Forward supersedes Back');
const visited = await sessionState(p); const visited = await sessionState(p);
const shown = await engines(); const shown = await engines();
@@ -145,7 +146,7 @@ await strict.run({}, async (b) => {
}); });
await strict.pick(p, 'cesium', { settle: false }); await strict.pick(p, 'cesium', { settle: false });
await waitForEngine(p, 'cesium', { timeout: 30000 }); await waitForEngine(p, 'cesium', { timeout: 30000 });
await new Promise((resolve) => setTimeout(resolve, 1500)); await whenSettled(p);
await assertSession(p, 'cesium', 'Back supersedes the pick'); await assertSession(p, 'cesium', 'Back supersedes the pick');
const after = await sessionState(p); const after = await sessionState(p);
const backShown = await engines(); const backShown = await engines();
+32 -19
View File
@@ -1,6 +1,8 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; 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 { quantile, slope } from './qa-strict-session.mjs';
import { strictArguments, waitForCameraAtRest } from './qa-strict-harness.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 // Each render request draws the next scripted camera sample through the fake engine.
// state per waitForFunction poll; `polls()` counts the polls taken.
function cameraPage(t, positions, inputs, globals = {}) { function cameraPage(t, positions, inputs, globals = {}) {
let frame = 0; let frame = -1;
const engine = { const engine = createFakeEngine();
camera: { engine.input.cameraInputsEnabled = () => inputs[Math.max(0, frame)];
pose: () => ({ position: { x: positions[frame], y: 0, z: 6.4e6 } }), engine.camera.flightActive = () => !inputs[Math.max(0, frame)];
}, engine.render.request = () =>
input: { cameraInputsEnabled: () => inputs[frame] }, 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; const previous = globalThis.window;
globalThis.window = { __godsEyeView: { engine }, ...globals }; globalThis.window = {
__godsEyeView: {
engine,
styleManager: { initialRestorePromise: Promise.resolve() },
},
...globals,
};
t.after(() => { t.after(() => {
globalThis.window = previous; globalThis.window = previous;
}); });
const page = { const page = {
evaluate: async (fn) => fn(), evaluate: async (fn) =>
typeof fn === 'string' ? runInThisContext(fn) : fn(),
async waitForFunction(fn) { async waitForFunction(fn) {
for (; frame < positions.length; frame++) if (fn()) return; assert.ok(fn());
throw new Error('timed out');
}, },
}; };
return { page, polls: () => frame }; 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. // still moves one more sample before it stays put.
const { page, polls } = cameraPage( const { page, polls } = cameraPage(
t, t,
[0, 400, 900, 900, 1250, 1250, 1250], [0, 400, 900, 900, 1250, 1250, 1250, 1250],
[false, false, false, false, true, true, true], [false, false, false, false, true, true, true, true],
); );
await waitForCameraAtRest(page); await waitForCameraAtRest(page);
// Not while the flight holds the inputs (sample 3 repeats a pose), nor // Not while the flight holds the inputs (sample 3 repeats a pose), nor
// while the camera moves (sample 4). // 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) => { 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. // A rest the scenario recorded before the click is no rest of the flight.
const { page, polls } = cameraPage( const { page, polls } = cameraPage(
t, t,
[0, 0, 100, 500, 500], [0, 0, 100, 500, 500, 500, 500],
[false, false, false, true, true], [false, false, false, true, true, true, true],
{ {
__qaWildPose: { key: '0.0,0.0,6400000.0', at: performance.now() - 5000 }, __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;`, )}\nreturn still;`,
)(page, waitForCameraAtRest); )(page, waitForCameraAtRest);
await still(); 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 { fromDegrees } from '../src/geo/cartesian3.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
@@ -90,7 +91,7 @@ await strict.run({}, async (b) => {
}, },
fromDegrees(10.7995, 55.3241, 25000), fromDegrees(10.7995, 55.3241, 25000),
); );
await new Promise((resolve) => setTimeout(resolve, 2500)); await whenSettled(p);
const screen = await p.evaluate( const screen = await p.evaluate(
(world) => { (world) => {
const { engine } = window.__godsEyeView; const { engine } = window.__godsEyeView;
@@ -100,6 +101,9 @@ await strict.run({}, async (b) => {
}, },
fromDegrees(10.799480647390464, 55.324102015186114), 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.mouse.click(screen.x, screen.y);
await p.waitForFunction( await p.waitForFunction(
() => { () => {
+5 -4
View File
@@ -1,3 +1,4 @@
import { whenSettled } from './qa/ready.mjs';
import { trafficFixtureResponse } from './traffic-fixtures.mjs'; import { trafficFixtureResponse } from './traffic-fixtures.mjs';
import sharp from 'sharp'; import sharp from 'sharp';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -156,14 +157,14 @@ await strict.run({}, async (b) => {
'traffic did not settle with 12 vehicles in view: ' + 'traffic did not settle with 12 vehicles in view: ' +
JSON.stringify({ loading, inView: rows.length }), 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 // 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 // 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 () => { const settledPopulation = async () => {
await new Promise((resolve) => setTimeout(resolve, 2200)); await whenSettled(p);
await settled(); await settled();
return p.evaluate(() => window.qaTraffic.getStats().count); return p.evaluate(() => window.qaTraffic.getStats().count);
}; };
@@ -251,7 +252,7 @@ await strict.run({}, async (b) => {
origin: 'user', origin: 'user',
}), }),
); );
await new Promise((resolve) => setTimeout(resolve, 600)); await whenSettled(p);
const hidden = await raw(); const hidden = await raw();
const { width, height } = shown.info; const { width, height } = shown.info;
const scale = width / 1024; 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 assert from 'node:assert/strict';
import { fromDegrees } from '../src/geo/cartesian3.js'; import { fromDegrees } from '../src/geo/cartesian3.js';
import { import {
@@ -50,7 +51,7 @@ await strict.run({}, async (b) => {
.length; .length;
if (bus) { if (bus) {
// Outside every feed region the layer waits and reads no feed. // 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()); const outside = await p.evaluate(() => window.qaUrban.getStats());
await strict.check( await strict.check(
p, 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 // Play one server function call on the fake data channel and wait for the
// client's function_call_output. // client's function_call_output.
function callTool(page, name, args, { beforeCall = null } = {}) { async function callTool(page, name, args, { beforeCall = null } = {}) {
return page.evaluate( const call = await page.evaluate(
async (name, args, beforeCall) => { (name, args, beforeCall) => {
const evidence = window.__qaVoice; const evidence = window.__qaVoice;
const callId = `qa-call-${(window.__qaCalls = (window.__qaCalls ?? 0) + 1)}`; const callId = `qa-call-${(window.__qaCalls = (window.__qaCalls ?? 0) + 1)}`;
const responseId = `qa-response-${callId}`; const responseId = `qa-response-${callId}`;
@@ -54,30 +54,41 @@ function callTool(page, name, args, { beforeCall = null } = {}) {
name, name,
arguments: JSON.stringify(args), arguments: JSON.stringify(args),
}); });
const started = performance.now(); return { callId, responseId };
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));
}
}, },
name, name,
args, args,
beforeCall, 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) => 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 { fromDegrees } from '../src/geo/cartesian3.js';
import sharp from 'sharp'; import sharp from 'sharp';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -182,7 +183,7 @@ await strict.run({}, async (b) => {
time, time,
); );
const sample = async () => { const sample = async () => {
await new Promise((r) => setTimeout(r, 500)); await whenSettled(p);
const crop = await sharp(await p.screenshot()) const crop = await sharp(await p.screenshot())
.extract({ left: 535, top: 388, width: 12, height: 12 }) .extract({ left: 535, top: 388, width: 12, height: 12 })
.removeAlpha() .removeAlpha()
+2 -1
View File
@@ -1,3 +1,4 @@
import { whenSettled } from './qa/ready.mjs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { fromDegrees } from '../src/geo/cartesian3.js'; import { fromDegrees } from '../src/geo/cartesian3.js';
import { fromCartesian } from '../src/geo/cartographic.js'; import { fromCartesian } from '../src/geo/cartographic.js';
@@ -161,7 +162,7 @@ await strict.run({}, async (b) => {
(badge) => badge.picked?.id === `wildlife:${badge.id}`, (badge) => badge.picked?.id === `wildlife:${badge.id}`,
); );
if (ok || Date.now() > deadline) return drawn; if (ok || Date.now() > deadline) return drawn;
await new Promise((resolve) => setTimeout(resolve, 500)); await whenSettled(p);
} }
}; };
const degrees = (position) => { const degrees = (position) => {
+3 -2
View File
@@ -1,3 +1,4 @@
import { whenSettled } from './qa/ready.mjs';
import sharp from 'sharp'; import sharp from 'sharp';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
@@ -95,7 +96,7 @@ await strict.run({}, async (b) => {
window.qaWind.getRowControls().summary.reading?.speed === 10, window.qaWind.getRowControls().summary.reading?.speed === 10,
{ timeout: 90000 }, { timeout: 90000 },
); );
await new Promise((resolve) => setTimeout(resolve, 800)); await whenSettled(p);
const check = await p.evaluate(() => { const check = await p.evaluate(() => {
const { dataManager } = window.__godsEyeView; const { dataManager } = window.__godsEyeView;
const mod = dataManager.layers.get('wind').module; const mod = dataManager.layers.get('wind').module;
@@ -161,7 +162,7 @@ await strict.run({}, async (b) => {
const frozen = await p.evaluate( const frozen = await p.evaluate(
() => window.qaWind.getDiagnostics().gpu.phaseSeconds, () => window.qaWind.getDiagnostics().gpu.phaseSeconds,
); );
await new Promise((resolve) => setTimeout(resolve, 250)); await whenSettled(p);
await strict.check( await strict.check(
p, p,
'wind', '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() // following() { label, native } of engine.camera.following()
// movers() engine.movers.list() // movers() engine.movers.list()
// framesDrawn() frames drawn since the probe started // framesDrawn() frames drawn since the probe started
// settled({ frames? }) readiness conditions over drawn frames
const plain = (position) => const plain = (position) =>
position ? { x: position.x, y: position.y, z: position.z } : null; 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 {{ * @param {{
* engine: import('../globe/contract.js').GlobeEngine, * engine: import('../globe/contract.js').GlobeEngine,
@@ -27,6 +106,7 @@ const plain = (position) =>
* }} options * }} options
*/ */
export function createQaProbe({ engine, dataManager }) { export function createQaProbe({ engine, dataManager }) {
const readiness = createSettledState(engine);
let frames = 0; let frames = 0;
const removeFrames = engine.frames.postRender.addEventListener(() => { const removeFrames = engine.frames.postRender.addEventListener(() => {
frames++; frames++;
@@ -84,6 +164,10 @@ export function createQaProbe({ engine, dataManager }) {
}, },
movers: () => engine.movers.list(), movers: () => engine.movers.list(),
framesDrawn: () => frames, 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, 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) => { return new Promise((resolve) => {
let finished = false;
let timer = null; let timer = null;
let remove = () => {}; let remove = () => {};
const finish = (drawn) => { const finish = (path) => {
if (finished) return;
finished = true;
outcome(path);
remove(); remove();
clearTimer(timer); clearTimer(timer);
document?.removeEventListener?.('visibilitychange', hidden); document?.removeEventListener?.('visibilitychange', hidden);
resolve(drawn); resolve(path === 'frame');
}; };
function hidden() { function hidden() {
if (document.hidden) finish(false); if (document.hidden) finish('hidden');
} }
remove = engine.frames.postRender.addEventListener(() => finish(true)); remove = engine.frames.postRender.addEventListener(() => finish('frame'));
timer = setTimer(() => finish(false), timeoutMs); timer = setTimer(() => finish('timeout'), timeoutMs);
document?.addEventListener?.('visibilitychange', hidden); document?.addEventListener?.('visibilitychange', hidden);
engine.render.request('engine-switch'); engine.render.request('engine-switch');
}); });