mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +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
@@ -10,7 +10,9 @@ import { DAYTIME, daytimeNear, shiftTime } from './qa/time.mjs';
|
||||
import {
|
||||
createStrict,
|
||||
installStrictNetwork,
|
||||
installContractMonitors,
|
||||
networkLedger,
|
||||
pickEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
function fakeBrowser(t, close = async () => {}) {
|
||||
@@ -303,3 +305,538 @@ test('strict.run flushes large output and exits despite active resources on succ
|
||||
if (fail) assert.match(result.stderr, /Error: body failed/);
|
||||
}
|
||||
});
|
||||
|
||||
// Production readiness runs the same state observer as the dev-only probe.
|
||||
import { createFakeEngine } from '../src/globe/testing/fakeEngine.js';
|
||||
import { createSettledState } from '../src/app/qaProbe.js';
|
||||
import {
|
||||
clickUntilSelected,
|
||||
waitForSettled,
|
||||
whenPickable,
|
||||
whenSettled,
|
||||
} from './qa/ready.mjs';
|
||||
import { drawLedger, compareLedgers } from './qa/ledger.mjs';
|
||||
import { createContractMonitor } from '../src/globe/testing/contractMonitor.js';
|
||||
import { QA_HARNESS_METRICS } from './report-qa-harness.mjs';
|
||||
import { waitForFirstFrame } from '../src/standalone/engineSwitch.js';
|
||||
|
||||
test('readiness counts drawn frames and resets on orientation, flight, and input changes', async () => {
|
||||
const engine = createFakeEngine();
|
||||
const restore = Promise.withResolvers();
|
||||
const state = createSettledState(engine, () => restore.promise);
|
||||
assert.deepEqual(state.settled().unmet, [
|
||||
'initialViewRestored',
|
||||
'frameDrawn',
|
||||
'cameraPoseStable',
|
||||
]);
|
||||
engine.fake.frames(4);
|
||||
assert.deepEqual(state.settled().unmet, ['initialViewRestored']);
|
||||
restore.resolve();
|
||||
await restore.promise;
|
||||
assert.equal(state.settled().settled, true);
|
||||
engine.fake.setPose({ heading: 0.2 });
|
||||
assert.equal(state.settled().settled, false);
|
||||
engine.fake.frames(3);
|
||||
assert.equal(state.settled().settled, false);
|
||||
engine.fake.frame();
|
||||
assert.equal(state.settled().settled, true);
|
||||
const resume = engine.input.suspendCameraInputs();
|
||||
engine.fake.frame();
|
||||
assert.ok(state.settled().unmet.includes('cameraInputsEnabled'));
|
||||
resume();
|
||||
engine.camera.flyTo({ destination: { x: 7000000, y: 0, z: 0 }, duration: 1 });
|
||||
engine.fake.frames(4);
|
||||
assert.ok(state.settled().unmet.includes('noFlight'));
|
||||
engine.camera.cancelFlight();
|
||||
engine.fake.frames(4);
|
||||
assert.equal(state.settled().settled, true);
|
||||
state.destroy();
|
||||
assert.equal(engine.frames.postRender.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
test('readiness follows a replaced or rejected restore promise', async () => {
|
||||
const engine = createFakeEngine();
|
||||
let promise = Promise.resolve();
|
||||
const state = createSettledState(engine, () => promise);
|
||||
await promise;
|
||||
engine.fake.frames(4);
|
||||
assert.equal(state.settled().settled, true);
|
||||
promise = Promise.reject(new Error('restore failed'));
|
||||
state.settled();
|
||||
await promise.catch(() => {});
|
||||
assert.deepEqual(state.settled().unmet, ['initialViewRestored']);
|
||||
assert.match(state.settled().restoreError, /restore failed/);
|
||||
state.destroy();
|
||||
});
|
||||
|
||||
test('readiness requests real frames and cleans up on success and timeout', async (t) => {
|
||||
const engine = createFakeEngine();
|
||||
globalThis.window = {
|
||||
__godsEyeView: {
|
||||
engine,
|
||||
styleManager: { initialRestorePromise: Promise.resolve() },
|
||||
},
|
||||
};
|
||||
t.after(() => {
|
||||
delete globalThis.window;
|
||||
});
|
||||
engine.render.request = () => setImmediate(() => engine.fake.frame());
|
||||
const result = await waitForSettled(createSettledState, {
|
||||
frames: 3,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.equal(result.settled, true);
|
||||
assert.equal(result.framesDrawn, 4);
|
||||
assert.equal(engine.frames.postRender.numberOfListeners, 0);
|
||||
engine.render.request = () => {};
|
||||
await assert.rejects(
|
||||
waitForSettled(createSettledState, { frames: 3, timeoutMs: 10 }),
|
||||
/frameDrawn, cameraPoseStable/,
|
||||
);
|
||||
assert.equal(engine.frames.postRender.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
test('readiness reports missing startup conditions instead of a selector timeout', async () => {
|
||||
await assert.rejects(
|
||||
whenSettled(
|
||||
{
|
||||
waitForFunction() {
|
||||
throw new Error('selector');
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 1 },
|
||||
),
|
||||
/engineStarted, initialViewRestored/,
|
||||
);
|
||||
});
|
||||
|
||||
test('readiness reports destruction without reading a released camera', async () => {
|
||||
const engine = createFakeEngine();
|
||||
const state = createSettledState(engine, () => Promise.resolve());
|
||||
engine.destroy();
|
||||
engine.camera.pose = () => {
|
||||
throw new Error('camera released');
|
||||
};
|
||||
assert.ok(state.settled().unmet.includes('engineStarted'));
|
||||
state.destroy();
|
||||
});
|
||||
|
||||
test('pick readiness waits beyond camera rest for the intended target and requests frames', async (t) => {
|
||||
const engine = createFakeEngine();
|
||||
const restore = Promise.resolve();
|
||||
const state = createSettledState(engine, () => restore);
|
||||
await restore;
|
||||
engine.fake.frames(4);
|
||||
assert.equal(state.settled().settled, true);
|
||||
state.destroy();
|
||||
const window = { __godsEyeView: { engine } };
|
||||
engine.host.canvas.getBoundingClientRect = () => ({ left: 40, top: 25 });
|
||||
const request = t.mock.method(engine.render, 'request');
|
||||
let picked;
|
||||
t.mock.method(engine.picker, 'pick', (position) => {
|
||||
assert.deepEqual({ ...position }, { x: 60, y: 55 });
|
||||
return picked;
|
||||
});
|
||||
const dispose = t.mock.fn();
|
||||
const position = { x: 100, y: 80 };
|
||||
const result = await whenPickable(
|
||||
{
|
||||
async waitForFunction(source, options) {
|
||||
assert.deepEqual(options, { polling: 50, timeout: 30000 });
|
||||
const poll = () => vm.runInNewContext(source, { window });
|
||||
assert.equal(poll(), false);
|
||||
engine.fake.frame();
|
||||
picked = { id: 'another-vessel' };
|
||||
assert.equal(poll(), false);
|
||||
engine.fake.frame();
|
||||
picked = { id: '123456789' };
|
||||
picked.object = picked;
|
||||
const value = poll();
|
||||
return { jsonValue: () => JSON.parse(JSON.stringify(value)), dispose };
|
||||
},
|
||||
},
|
||||
position,
|
||||
(picked) => picked?.id === '123456789',
|
||||
);
|
||||
assert.deepEqual(result, position);
|
||||
assert.equal(request.mock.callCount(), 3);
|
||||
assert.ok(
|
||||
request.mock.calls.every(
|
||||
({ arguments: args }) => args[0] === 'qa-pick-readiness',
|
||||
),
|
||||
);
|
||||
assert.equal(dispose.mock.callCount(), 1);
|
||||
assert.equal(engine.frames.postRender.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
test('pick readiness retries a page-side locator and keeps its target identity', async (t) => {
|
||||
const engine = createFakeEngine();
|
||||
engine.host.canvas.getBoundingClientRect = () => ({ left: 0, top: 0 });
|
||||
const window = { __godsEyeView: { engine }, target: null };
|
||||
const pick = t.mock.method(engine.picker, 'pick', () => ({ id: 'line' }));
|
||||
const dispose = t.mock.fn();
|
||||
const result = await whenPickable(
|
||||
{
|
||||
async waitForFunction(source) {
|
||||
const poll = () => vm.runInNewContext(source, { window });
|
||||
assert.equal(poll(), false);
|
||||
assert.equal(pick.mock.callCount(), 0);
|
||||
window.target = { x: 12, y: 34, id: 'line' };
|
||||
return { jsonValue: () => poll(), dispose };
|
||||
},
|
||||
},
|
||||
() => window.target,
|
||||
(picked, position) => picked?.id === position.id,
|
||||
);
|
||||
assert.deepEqual(result, window.target);
|
||||
assert.equal(dispose.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
test('pick readiness reports a bounded timeout for a missing target or unavailable engine', async (t) => {
|
||||
for (const kind of ['missing target', 'missing engine', 'destroyed engine']) {
|
||||
await t.test(kind, async (t) => {
|
||||
const engine = createFakeEngine();
|
||||
engine.host.canvas.getBoundingClientRect = () => ({ left: 0, top: 0 });
|
||||
if (kind === 'destroyed engine') engine.destroy();
|
||||
const window = {
|
||||
__godsEyeView: kind === 'missing engine' ? undefined : { engine },
|
||||
};
|
||||
const pick = t.mock.method(engine.picker, 'pick', () => undefined);
|
||||
const timeout = new Error('waiting failed');
|
||||
timeout.name = 'TimeoutError';
|
||||
await assert.rejects(
|
||||
whenPickable(
|
||||
{
|
||||
async waitForFunction(source, options) {
|
||||
assert.equal(options.timeout, 10);
|
||||
assert.equal(vm.runInNewContext(source, { window }), false);
|
||||
throw timeout;
|
||||
},
|
||||
},
|
||||
{ x: 1, y: 2 },
|
||||
(picked) => picked?.id === 'vessel',
|
||||
{ timeoutMs: 10 },
|
||||
),
|
||||
(error) =>
|
||||
error.message === 'Readiness timeout: pickable target' &&
|
||||
error.cause === timeout,
|
||||
);
|
||||
assert.equal(pick.mock.callCount(), kind === 'missing target' ? 1 : 0);
|
||||
assert.equal(engine.frames.postRender.numberOfListeners, 0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('pick readiness preserves picker errors and releases handles after a failed read', async () => {
|
||||
const failure = new Error('pick failed');
|
||||
await assert.rejects(
|
||||
whenPickable(
|
||||
{
|
||||
waitForFunction: async () => {
|
||||
throw failure;
|
||||
},
|
||||
},
|
||||
{ x: 1, y: 2 },
|
||||
() => true,
|
||||
),
|
||||
(error) => error === failure,
|
||||
);
|
||||
let disposed = false;
|
||||
await assert.rejects(
|
||||
whenPickable(
|
||||
{
|
||||
waitForFunction: async () => ({
|
||||
jsonValue: async () => {
|
||||
throw failure;
|
||||
},
|
||||
dispose: () => {
|
||||
disposed = true;
|
||||
},
|
||||
}),
|
||||
},
|
||||
{ x: 1, y: 2 },
|
||||
() => true,
|
||||
),
|
||||
(error) => error === failure,
|
||||
);
|
||||
assert.equal(disposed, true);
|
||||
});
|
||||
|
||||
test('selection readiness retries lost clicks and stops as soon as selection succeeds', async (t) => {
|
||||
for (const succeedsAt of [1, 2, 5]) {
|
||||
await t.test(`selection on click ${succeedsAt}`, async (t) => {
|
||||
let clicks = 0;
|
||||
let selected = null;
|
||||
const selection = () => selected?.mmsi === '123456789';
|
||||
const dispose = t.mock.fn();
|
||||
await clickUntilSelected(
|
||||
{
|
||||
async waitForFunction(predicate, options) {
|
||||
assert.equal(predicate, selection);
|
||||
assert.deepEqual(options, { timeout: 2000 });
|
||||
if (predicate()) return { dispose };
|
||||
const timeout = new Error('no selection');
|
||||
timeout.name = 'TimeoutError';
|
||||
throw timeout;
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
clicks++;
|
||||
if (clicks === succeedsAt) selected = { mmsi: '123456789' };
|
||||
},
|
||||
selection,
|
||||
);
|
||||
assert.equal(clicks, succeedsAt);
|
||||
assert.equal(dispose.mock.callCount(), 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('selection readiness fails after five unselected clicks', async () => {
|
||||
let clicks = 0;
|
||||
let waits = 0;
|
||||
const timeout = new Error('no selection');
|
||||
timeout.name = 'TimeoutError';
|
||||
await assert.rejects(
|
||||
clickUntilSelected(
|
||||
{
|
||||
async waitForFunction(predicate, options) {
|
||||
waits++;
|
||||
assert.equal(predicate(), false);
|
||||
assert.deepEqual(options, { timeout: 2000 });
|
||||
throw timeout;
|
||||
},
|
||||
},
|
||||
async () => clicks++,
|
||||
() => false,
|
||||
),
|
||||
(error) =>
|
||||
error.message === 'Readiness timeout: selection after 5 clicks' &&
|
||||
error.cause === timeout,
|
||||
);
|
||||
assert.equal(clicks, 5);
|
||||
assert.equal(waits, 5);
|
||||
});
|
||||
|
||||
test('selection readiness does not retry click or page errors', async (t) => {
|
||||
for (const failureAt of ['click', 'selection']) {
|
||||
await t.test(failureAt, async () => {
|
||||
let clicks = 0;
|
||||
const failure = new Error('page closed');
|
||||
await assert.rejects(
|
||||
clickUntilSelected(
|
||||
{
|
||||
async waitForFunction() {
|
||||
throw failure;
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
clicks++;
|
||||
if (failureAt === 'click') throw failure;
|
||||
},
|
||||
() => false,
|
||||
),
|
||||
(error) => error === failure,
|
||||
);
|
||||
assert.equal(clicks, 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('draw ledgers normalize all five families and compare absences and visibility', async (t) => {
|
||||
const engine = createFakeEngine();
|
||||
engine.movers.createSet({
|
||||
id: 'point',
|
||||
kind: 'point',
|
||||
capacity: 1,
|
||||
point: { sizePx: 4, color: [1, 1, 1, 1] },
|
||||
});
|
||||
engine.lines.createLine({ id: 'line', color: [1, 1, 1, 1], widthPx: 1 });
|
||||
engine.ground.createSet({ id: 'ground', kind: 'disc' });
|
||||
engine.imagery.list = () => [{ id: 'tiles', kind: 'xyz', shown: true }];
|
||||
engine.meshes.list = () => [{ id: 'model', shown: true }];
|
||||
globalThis.window = { __godsEyeView: { engine } };
|
||||
t.after(() => {
|
||||
delete globalThis.window;
|
||||
});
|
||||
const before = await drawLedger({ evaluate: (fn) => fn() });
|
||||
assert.ok(before.every((row) => row.shown === true));
|
||||
assert.deepEqual(
|
||||
before.map((row) => row.family),
|
||||
['ground', 'imagery', 'lines', 'meshes', 'movers'],
|
||||
);
|
||||
const after = before
|
||||
.filter((row) => row.id !== 'line')
|
||||
.map((row) => ({ ...row, shown: row.id !== 'point' }));
|
||||
after.push({ id: 'overlay', family: 'imagery' });
|
||||
const comparison = compareLedgers(before, after, [
|
||||
{ id: 'overlay', reason: 'engine overlay' },
|
||||
]);
|
||||
assert.deepEqual(comparison.onlyBefore, [{ id: 'line', family: 'lines' }]);
|
||||
assert.deepEqual(comparison.onlyAfter, []);
|
||||
assert.deepEqual(comparison.shown, [
|
||||
{ id: 'point', family: 'movers', before: true, after: false },
|
||||
]);
|
||||
assert.equal(comparison.allowed[0].reason, 'engine overlay');
|
||||
assert.deepEqual(
|
||||
compareLedgers(
|
||||
[{ id: 'same', family: 'movers' }],
|
||||
[{ id: 'same', family: 'movers', shown: true }],
|
||||
).shown,
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('the strict scenario set has no private launches, sleeps, or loading-screen readiness', () => {
|
||||
const runner = fs.readFileSync(
|
||||
new URL('./qa-all-layer-switches.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const runs = vm.runInNewContext(
|
||||
runner.match(/const runs = ([\s\S]*?);\n/)[1],
|
||||
{ soakRuns: [], longRun: '' },
|
||||
);
|
||||
const files = [...new Set(runs.map(([, script]) => script))].map((name) => ({
|
||||
file: `qa-${name}.mjs`,
|
||||
text: fs.readFileSync(new URL(`./qa-${name}.mjs`, import.meta.url), 'utf8'),
|
||||
}));
|
||||
for (const metric of QA_HARNESS_METRICS.filter(({ id }) =>
|
||||
['ownLaunch', 'fixedSleeps', 'loadingScreenReadiness'].includes(id),
|
||||
)) {
|
||||
assert.deepEqual(
|
||||
files.filter((file) => metric.measure([file])).map(({ file }) => file),
|
||||
[],
|
||||
metric.id,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the injected monitor follows debug-handle attachments and streams evidence before close', async () => {
|
||||
const rows = [];
|
||||
const first = createFakeEngine({ kind: 'navara' });
|
||||
const second = createFakeEngine({ kind: 'cesium' });
|
||||
const context = vm.createContext({
|
||||
document: {},
|
||||
MutationObserver: class {
|
||||
observe() {}
|
||||
},
|
||||
first,
|
||||
second,
|
||||
queueMicrotask,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
report: (json) => rows.push(JSON.parse(json)),
|
||||
});
|
||||
vm.runInContext(
|
||||
`
|
||||
globalThis.window = globalThis;
|
||||
window.top = window;
|
||||
window.addEventListener = () => {};
|
||||
window.__gevStrictMonitor = report;
|
||||
(${installContractMonitors})(${createContractMonitor});
|
||||
const debug = { engine: first };
|
||||
window.__godsEyeView = debug;
|
||||
delete window.__godsEyeView;
|
||||
debug.engine = null;
|
||||
debug.engine = second;
|
||||
window.__godsEyeView = debug;
|
||||
`,
|
||||
context,
|
||||
);
|
||||
assert.deepEqual(
|
||||
rows.map(({ kind }) => kind),
|
||||
['navara', 'cesium'],
|
||||
);
|
||||
first.destroy();
|
||||
first.fake.emit('camera.changed');
|
||||
second.input.suspendCameraInputs();
|
||||
await vm.runInContext('window.__qaFinishMonitors()', context);
|
||||
await vm.runInContext('window.__qaFinishMonitors()', context);
|
||||
assert.deepEqual(
|
||||
rows.filter(({ record }) => record).map(({ record }) => record.invariant),
|
||||
['event-after-destroy', 'final-inputs-disabled'],
|
||||
);
|
||||
assert.equal(rows.filter(({ finished }) => finished).length, 2);
|
||||
});
|
||||
|
||||
test('the first-frame outcome names the actual frame, visibility, or timeout path', async () => {
|
||||
for (const path of ['frame', 'hidden', 'timeout']) {
|
||||
const engine = createFakeEngine({ canvas: { dataset: {} } });
|
||||
let timer;
|
||||
const result = waitForFirstFrame(engine, {
|
||||
document: { hidden: path === 'hidden' },
|
||||
setTimer: (callback) => {
|
||||
timer = callback;
|
||||
},
|
||||
clearTimer() {},
|
||||
});
|
||||
if (path === 'frame') {
|
||||
engine.fake.frame();
|
||||
timer();
|
||||
}
|
||||
if (path === 'timeout') timer();
|
||||
assert.equal(await result, path === 'frame');
|
||||
assert.equal(engine.host.canvas.dataset.firstFrameOutcome, path);
|
||||
}
|
||||
});
|
||||
|
||||
test('picker readiness also settles the surviving engine after a rollback', async (t) => {
|
||||
const engine = createFakeEngine({ kind: 'navara' });
|
||||
const element = {
|
||||
value: 'navara',
|
||||
tagName: 'SELECT',
|
||||
id: 'globe-engine',
|
||||
className: '',
|
||||
closest: () => element,
|
||||
getAttribute: () => 'true',
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 30 }),
|
||||
addEventListener() {},
|
||||
};
|
||||
globalThis.window = {
|
||||
__godsEyeView: {
|
||||
engine,
|
||||
styleManager: { initialRestorePromise: Promise.resolve() },
|
||||
},
|
||||
};
|
||||
globalThis.document = {
|
||||
activeElement: element,
|
||||
querySelector: (selector) =>
|
||||
selector === '#globe-engine' ? element : null,
|
||||
elementFromPoint: () => element,
|
||||
};
|
||||
globalThis.MutationObserver = class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
};
|
||||
t.after(() => {
|
||||
delete globalThis.window;
|
||||
delete globalThis.document;
|
||||
delete globalThis.MutationObserver;
|
||||
});
|
||||
engine.render.request = () => setImmediate(() => engine.fake.frame());
|
||||
const page = {
|
||||
$: async () => null,
|
||||
$eval: async (selector, fn) => fn(element),
|
||||
$$eval: async (selector, fn) =>
|
||||
fn([{ value: 'cesium' }, { value: 'navara' }]),
|
||||
evaluate: async (fn) =>
|
||||
typeof fn === 'string' ? vm.runInThisContext(fn) : fn(),
|
||||
waitForSelector: async () => {},
|
||||
waitForFunction: async (fn, options, arg) => assert.ok(fn(arg)),
|
||||
mouse: { click: async () => {} },
|
||||
keyboard: {
|
||||
type: async () => {
|
||||
// A failed attempt leaves the original engine and picker value intact.
|
||||
Object.assign(window.__qaPick, {
|
||||
changed: true,
|
||||
busy: true,
|
||||
at: 1,
|
||||
idleAt: 2,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await pickEngine(page, 'cesium');
|
||||
assert.equal(result.idleAt, 2);
|
||||
assert.equal(engine.frames.postRender.numberOfListeners, 0);
|
||||
});
|
||||
Reference in new issue
Block a user