Wait for the application, not the clock, in the strict QA set

The probe exposes settled() and the first-frame outcome; scripts/qa/ready.mjs
waits for drawn frames with the camera at rest and for a pick at the click
position, with a deadline and its reason in the error. The strict scenarios
use these instead of fixed pauses and loading-screen polling, and the
hidden-switch scenario checks how the loading screen was lifted instead of
how long it took.

Each switch scenario now records a draw ledger per engine and runs a
contract monitor; both write to the scenario's evidence file only, with an
empty allowlist until browser runs show what differs on purpose.
This commit is contained in:
German Khokhlov committed 2026-11-10 15:46:26 +04:00
1 parent 9c2a2d6951
commit 60f9de8511
47 files changed
+1788 -312

No files matched your search

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