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