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

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

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

No files matched your search

+85 -1
View File
@@ -16,10 +16,89 @@
// following() { label, native } of engine.camera.following()
// movers() engine.movers.list()
// framesDrawn() frames drawn since the probe started
// settled({ frames? }) readiness conditions over drawn frames
const plain = (position) =>
position ? { x: position.x, y: position.y, z: position.z } : null;
/** Frame-based readiness, also serialized by the production QA harness. */
export function createSettledState(
engine,
getRestore = () =>
globalThis.window?.__godsEyeView?.styleManager?.initialRestorePromise,
) {
let drawn = 0;
let stable = 0;
let previous = null;
let restore;
let restored = false;
let restoreError = null;
const observeRestore = () => {
const next = getRestore();
if (!next || next === restore) return;
restore = next;
restored = false;
restoreError = null;
Promise.resolve(next).then(
() => {
if (restore === next) restored = true;
},
(error) => {
if (restore === next) restoreError = String(error);
},
);
};
const pose = () => {
const value = engine.camera.pose();
return [
...['position', 'direction', 'up'].flatMap((key) =>
['x', 'y', 'z'].map((axis) => value[key][axis]),
),
value.fovy,
value.aspectRatio,
];
};
const same = (a, b) =>
a && a.every((n, i) => Math.abs(n - b[i]) <= (i < 3 ? 1e-6 : 1e-10));
const remove = engine.frames.postRender.addEventListener(() => {
observeRestore();
drawn++;
const current = pose();
stable =
same(previous, current) &&
!engine.camera.flightActive() &&
engine.input.cameraInputsEnabled()
? stable + 1
: 0;
previous = current;
});
observeRestore();
return {
settled({ frames = 3 } = {}) {
observeRestore();
const started = !engine.isDestroyed();
const conditions = {
engineStarted: started,
initialViewRestored: restored,
frameDrawn: drawn > 0,
noFlight: started && !engine.camera.flightActive(),
cameraInputsEnabled: started && engine.input.cameraInputsEnabled(),
cameraPoseStable: started && stable >= frames && same(previous, pose()),
};
const unmet = Object.keys(conditions).filter((key) => !conditions[key]);
return {
settled: !unmet.length,
conditions,
unmet,
framesDrawn: drawn,
stableFrames: stable,
restoreError,
};
},
destroy: remove,
};
}
/**
* @param {{
* engine: import('../globe/contract.js').GlobeEngine,
@@ -27,6 +106,7 @@ const plain = (position) =>
* }} options
*/
export function createQaProbe({ engine, dataManager }) {
const readiness = createSettledState(engine);
let frames = 0;
const removeFrames = engine.frames.postRender.addEventListener(() => {
frames++;
@@ -84,6 +164,10 @@ export function createQaProbe({ engine, dataManager }) {
},
movers: () => engine.movers.list(),
framesDrawn: () => frames,
destroy: () => removeFrames(),
settled: (options) => readiness.settled(options),
destroy() {
removeFrames();
readiness.destroy();
},
};
}
+186
View File
@@ -0,0 +1,186 @@
/** Observe a started engine without importing runtime or SDK modules. */
export function createContractMonitor(engine, report = () => {}) {
const records = [];
const flights = [];
const removers = [];
const restorers = [];
let destroyed = false;
let moving = false;
let frame = 0;
let inFrame = false;
let requestedInFrame = null;
const record = (invariant, detail = {}) => {
const row = { invariant, frame, ...detail };
records.push(row);
report(row);
};
const wrap = (owner, name, decorate) => {
const original = owner[name];
const wrapped = decorate(original);
owner[name] = wrapped;
restorers.push(() => {
if (owner[name] === wrapped) owner[name] = original;
});
};
const listen = (event, name, callback = () => {}) => {
if (!event) return;
removers.push(
event.addEventListener(() => {
if (destroyed) record('event-after-destroy', { event: name });
else callback();
}),
);
};
for (const name of ['changed', 'commanded', 'followChanged'])
listen(engine.camera[name], `camera.${name}`);
listen(engine.camera.moveStart, 'camera.moveStart', () => {
if (moving) record('move-alternation', { event: 'moveStart' });
moving = true;
});
listen(engine.camera.moveEnd, 'camera.moveEnd', () => {
if (!moving) record('move-alternation', { event: 'moveEnd' });
moving = false;
});
listen(engine.frames.preUpdate, 'frames.preUpdate', () => {
frame++;
inFrame = true;
// Request-mode engines can update without drawing a frame.
queueMicrotask(() => {
inFrame = false;
});
});
listen(engine.frames.preRender, 'frames.preRender', () => {
inFrame = true;
});
listen(engine.frames.postRender, 'frames.postRender', () => {
if (requestedInFrame !== null && frame > requestedInFrame)
requestedInFrame = null;
// Include requests made by listeners registered after the monitor.
queueMicrotask(() => {
inFrame = false;
});
});
listen(engine.maps.errors, 'maps.errors');
for (const type of ['click', 'doubleClick', 'move', 'down', 'up']) {
removers.push(
engine.input.on(type, () => {
if (destroyed)
record('event-after-destroy', { event: `input.${type}` });
}),
);
}
wrap(
engine.render,
'request',
(original) =>
function (...args) {
if (inFrame) requestedInFrame = frame;
return original.apply(this, args);
},
);
const missing = () => {
for (const flight of flights) {
if (!flight.ends && !flight.reported) {
flight.reported = true;
record('flight-terminal', { flight: flight.id, ends: 0 });
}
}
};
for (const name of ['flyTo', 'flyToSphere']) {
wrap(
engine.camera,
name,
(original) =>
function (...args) {
const index = name === 'flyTo' ? 0 : 1;
const options = args[index] ?? {};
const flight = { id: flights.length + 1, ends: 0 };
flights.push(flight);
const observed = { ...options };
for (const outcome of ['complete', 'cancel']) {
observed[outcome] = function (...values) {
flight.ends++;
if (flight.ends !== 1)
record('flight-terminal', {
flight: flight.id,
ends: flight.ends,
outcome,
});
if (outcome === 'complete' && engine.camera.flightActive())
record('flight-active', { flight: flight.id, at: 'complete' });
return options[outcome]?.apply(this, values);
};
}
args[index] = observed;
try {
return original.apply(this, args);
} catch (error) {
// Invalid commands do not promise a flight or a terminal callback.
flights.splice(flights.indexOf(flight), 1);
throw error;
}
},
);
}
for (const name of ['cancelFlight', 'completeFlight']) {
wrap(
engine.camera,
name,
(original) =>
function (...args) {
const result = original.apply(this, args);
if (engine.camera.flightActive())
record('flight-active', { at: name });
missing();
return result;
},
);
}
wrap(
engine,
'destroy',
(original) =>
function (...args) {
const result = original.apply(this, args);
destroyed = true;
inFrame = false;
requestedInFrame = null;
missing();
return result;
},
);
return {
records,
async finish({ active = true } = {}) {
missing();
if (!destroyed && active) {
if (!engine.input.cameraInputsEnabled())
record('final-inputs-disabled');
if (engine.camera.flightActive()) record('final-flight-active');
if (requestedInFrame !== null) {
const requested = requestedInFrame;
// A request on the last observed frame still deserves its next frame.
await new Promise((resolve) => {
const remove = engine.frames.postRender.addEventListener(() => {
if (frame <= requested) return;
clearTimeout(timer);
remove();
resolve();
});
const timer = setTimeout(() => {
remove();
if (!destroyed)
record('render-request-lost', { requestedInFrame: requested });
resolve();
}, 2000);
});
}
}
return records;
},
dispose() {
for (const remove of removers) remove();
for (const restore of restorers.reverse()) restore();
},
};
}
+184
View File
@@ -0,0 +1,184 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createFakeEngine } from './fakeEngine.js';
import { createContractMonitor } from './contractMonitor.js';
const destination = { x: 7000000, y: 1000000, z: 0 };
const violations = (monitor) => monitor.records.map((row) => row.invariant);
test('normal flights, moves, in-frame requests and destruction preserve the contract', async () => {
const engine = createFakeEngine();
const monitor = createContractMonitor(engine);
engine.camera.flyTo({ destination, duration: 0 });
engine.camera.flyTo({ destination, duration: 1 });
engine.camera.cancelFlight();
engine.camera.flyToSphere({ center: destination, radius: 100 });
engine.camera.completeFlight();
let remove = engine.frames.postRender.addEventListener(() => {
remove();
engine.render.request();
});
engine.fake.frames(3);
await Promise.resolve();
await monitor.finish();
engine.destroy();
assert.deepEqual(monitor.records, []);
monitor.dispose();
});
test('terminal callbacks are counted even without caller callbacks', () => {
for (const endings of [
[],
['complete', 'cancel'],
['complete', 'complete'],
]) {
const engine = createFakeEngine();
engine.camera.flyTo = (options) =>
endings.forEach((name) => options[name]());
const monitor = createContractMonitor(engine);
engine.camera.flyTo({ destination });
monitor.finish();
assert.ok(violations(monitor).includes('flight-terminal'));
monitor.dispose();
}
});
test('complete callbacks and explicit flight termination must see an inactive flight', () => {
for (const at of ['complete', 'cancelFlight', 'completeFlight']) {
const engine = createFakeEngine();
if (at === 'complete')
engine.camera.flyTo = (options) => options.complete();
else engine.camera[at] = () => {};
engine.camera.flightActive = () => true;
const monitor = createContractMonitor(engine);
if (at === 'complete') engine.camera.flyTo({ destination });
else engine.camera[at]();
assert.ok(
monitor.records.some(
(row) => row.invariant === 'flight-active' && row.at === at,
),
);
monitor.dispose();
}
});
test('a cancelled flight that loses its callback is reported at cancellation', () => {
const engine = createFakeEngine();
engine.camera.flyTo = () => {};
const monitor = createContractMonitor(engine);
engine.camera.flyTo({ destination });
engine.camera.cancelFlight();
assert.ok(violations(monitor).includes('flight-terminal'));
});
test('moveStart and moveEnd must alternate', () => {
for (const events of [
['moveEnd'],
['moveStart', 'moveStart'],
['moveStart', 'moveEnd', 'moveEnd'],
]) {
const engine = createFakeEngine();
const monitor = createContractMonitor(engine);
for (const name of events) engine.fake.emit(`camera.${name}`);
assert.ok(violations(monitor).includes('move-alternation'));
}
});
test('a swallowed in-frame render request leaves a missing next frame', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const engine = createFakeEngine();
engine.render.request = () => {};
const monitor = createContractMonitor(engine);
engine.frames.postRender.addEventListener(() => engine.render.request());
engine.fake.frame();
const finish = monitor.finish();
t.mock.timers.tick(2000);
await finish;
assert.ok(violations(monitor).includes('render-request-lost'));
});
test('a request in the next frame must also draw a later frame', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const engine = createFakeEngine();
engine.render.request = () => {};
const monitor = createContractMonitor(engine);
engine.frames.preRender.addEventListener(() => engine.render.request());
engine.fake.frames(2);
const finish = monitor.finish();
t.mock.timers.tick(2000);
await finish;
assert.ok(violations(monitor).includes('render-request-lost'));
});
test('an update without a draw does not mark later requests as in-frame', async () => {
const engine = createFakeEngine();
const monitor = createContractMonitor(engine);
engine.fake.emit('frames.preUpdate');
await Promise.resolve();
engine.render.request();
await monitor.finish();
assert.deepEqual(monitor.records, []);
});
test('the active engine must finish with inputs enabled and without a flight', () => {
const engine = createFakeEngine();
const monitor = createContractMonitor(engine);
engine.input.suspendCameraInputs();
engine.camera.flyTo({ destination });
monitor.finish();
assert.ok(violations(monitor).includes('final-inputs-disabled'));
assert.ok(violations(monitor).includes('final-flight-active'));
});
test('events emitted after destroy remain observable until disposal', () => {
const engine = createFakeEngine();
const monitor = createContractMonitor(engine);
engine.destroy();
engine.fake.emit('camera.changed');
engine.fake.emit('frames.postRender');
assert.deepEqual(violations(monitor), [
'event-after-destroy',
'event-after-destroy',
]);
monitor.dispose();
engine.fake.emit('camera.changed');
assert.equal(monitor.records.length, 2);
});
test('wrappers preserve callback arguments, receivers, returns, and thrown commands', () => {
const engine = createFakeEngine();
const receiver = {};
engine.camera.flyTo = (options) => options.complete.call(receiver, 7);
const original = engine.camera.flyTo;
const monitor = createContractMonitor(engine);
assert.equal(
engine.camera.flyTo({
complete(value) {
assert.equal(this, receiver);
return value;
},
}),
7,
);
monitor.dispose();
assert.equal(engine.camera.flyTo, original);
const real = createFakeEngine();
const other = createContractMonitor(real);
assert.throws(() => real.camera.flyTo({}), /destination/);
other.finish();
assert.deepEqual(other.records, []);
});
test('a final in-frame request may draw its next frame before evidence closes', async () => {
const engine = createFakeEngine();
const monitor = createContractMonitor(engine);
let remove = engine.frames.postRender.addEventListener(() => {
remove();
engine.render.request();
});
engine.fake.frame();
const finish = monitor.finish();
engine.fake.frame();
await finish;
assert.deepEqual(monitor.records, []);
});
+18 -6
View File
@@ -121,21 +121,33 @@ export function waitForFirstFrame(
clearTimer = clearTimeout,
} = {},
) {
if (document?.hidden) return Promise.resolve(false);
const outcome = (path) => {
if (engine.host?.canvas?.dataset)
engine.host.canvas.dataset.firstFrameOutcome = path;
};
if (document?.hidden) {
outcome('hidden');
return Promise.resolve(false);
}
outcome('pending');
return new Promise((resolve) => {
let finished = false;
let timer = null;
let remove = () => {};
const finish = (drawn) => {
const finish = (path) => {
if (finished) return;
finished = true;
outcome(path);
remove();
clearTimer(timer);
document?.removeEventListener?.('visibilitychange', hidden);
resolve(drawn);
resolve(path === 'frame');
};
function hidden() {
if (document.hidden) finish(false);
if (document.hidden) finish('hidden');
}
remove = engine.frames.postRender.addEventListener(() => finish(true));
timer = setTimer(() => finish(false), timeoutMs);
remove = engine.frames.postRender.addEventListener(() => finish('frame'));
timer = setTimer(() => finish('timeout'), timeoutMs);
document?.addEventListener?.('visibilitychange', hidden);
engine.render.request('engine-switch');
});