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