fix(qa-cctv-v2): poll waits from Node so a hidden window can't stall them

page.waitForFunction polls on requestAnimationFrame by default. In a
minimised or hidden headful window rAF stops while timers keep running, so
each 120 s slice only checked the predicate when it started: a drain that
finished in the last slice was recorded as a FAIL on the sign-off path.

- waitForPredicate replaces waitForFunctionSliced and the other three
  waitForFunction calls: one short page.evaluate per tick (1 s for the
  drain, 250 ms elsewhere), a monotonic performance.now() deadline, retry
  across a context swap, and a throwing predicate fails the run instead of
  silently spending the budget.
- One in-page window.__qaRenderFrames(count, capMs) helper serves both the
  gizmo-drag and the sibling-owner frame waits.
- The sibling pick counts as a stalled renderer only at 0 of 3 frames, so a
  slow SwiftShader frame still reports a real miss; the record flattens and
  prints the frame count once.
- Review screenshots log and continue when a capture fails.
- Launch with pipe: true (no localhost CDP port) and protocolTimeout
  300_000 like the other harnesses; drop the occlusion flag that puppeteer
  already passes by default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-19 22:07:34 +01:00
1 parent 474d44c4db
commit 12b8fd0c1d
1 file changed
+135 -117
+135 -117
View File
@@ -139,38 +139,53 @@ function record(name, ok, detail) {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Every page.waitForFunction / page.evaluate is a single CDP call that // Each page.evaluate is bounded by puppeteer's `protocolTimeout` (default
// puppeteer aborts after `protocolTimeout` (default 180s). The headful drain // 180s). Waits never come close: they poll from Node (below). The headroom is
// budget is uncapped and reaches ~50 minutes on the full ~3,700-camera // for the few long evaluates, such as enabling the full ~3,700-camera catalog
// catalog, so long waits are polled in slices of at most WAIT_SLICE_MS and // on a real GPU, and matches the other qa-*.mjs harnesses.
// protocolTimeout is set with headroom above the largest slice. const PROTOCOL_TIMEOUT_MS = 300_000;
const WAIT_SLICE_MS = 120000;
const PROTOCOL_TIMEOUT_MS = 2 * WAIT_SLICE_MS;
/** /**
* page.waitForFunction with an overall budget that may exceed * Polls an in-page predicate from Node, one short page.evaluate per tick,
* protocolTimeout: waits in WAIT_SLICE_MS slices until the predicate holds or * until it holds or the budget runs out. Used instead of page.waitForFunction
* the budget runs out. * for two reasons: its default rAF polling stops in a minimised or hidden
* headful window (timers keep running, rAF does not), and its result is one
* CDP call that protocolTimeout cuts off, while the headful drain budget
* reaches ~50 minutes. A predicate that throws fails the run instead of
* silently spending the budget.
* @param {import('puppeteer').Page} page * @param {import('puppeteer').Page} page
* @param {Function} predicate - In-page predicate. * @param {Function} predicate - In-page predicate.
* @param {number} budgetMs - Overall budget. * @param {object} opts
* @param {number} opts.timeoutMs - Overall budget.
* @param {number} [opts.intervalMs=250] - Delay between checks.
* @param {unknown[]} [opts.args=[]] - Arguments passed to the predicate.
* The predicate must return a boolean: its result crosses CDP by value.
* @returns {Promise<boolean>} True if the predicate held within the budget. * @returns {Promise<boolean>} True if the predicate held within the budget.
*/ */
async function waitForFunctionSliced(page, predicate, budgetMs) { async function waitForPredicate(
const deadline = Date.now() + budgetMs; page,
predicate,
{ timeoutMs, intervalMs = 250, args = [] },
) {
// performance.now() is monotonic and doesn't advance while the machine
// sleeps, so neither a clock change nor a laptop sleep eats the budget.
const deadline = performance.now() + timeoutMs;
for (;;) { for (;;) {
const remaining = deadline - Date.now(); try {
if (remaining <= 0) return false; if (await page.evaluate(predicate, ...args)) return true;
const ok = await page } catch (err) {
.waitForFunction(predicate, { // A navigation or Vite reload replaces the execution context: poll again.
timeout: Math.min(remaining, WAIT_SLICE_MS), if (
}) !/Execution context was destroyed|Cannot find context/.test(
.then(() => true) err?.message ?? '',
.catch((err) => { )
if (err?.name === 'TimeoutError') return false; ) {
throw err; throw err;
}); }
if (ok) return true; }
const remaining = deadline - performance.now();
if (remaining <= 0) return false;
await sleep(Math.min(intervalMs, remaining));
} }
} }
@@ -190,29 +205,58 @@ async function positionPointerForGizmoDrag(page, point, frameCount = 3) {
gev.dataManager.layers gev.dataManager.layers
.get('cctv') .get('cctv')
.module.setParams({ calibrationMode: true }); .module.setParams({ calibrationMode: true });
const scene = gev.viewer.scene; return (await window.__qaRenderFrames(count, 5000)) >= count;
let rendered = 0; }, frameCount);
return new Promise((resolve) => { }
let settled = false;
const finish = (complete) => { /**
if (settled) return; * Saves a screenshot for human review. These are never a gate, so a capture
settled = true; * that fails (for example when the renderer stalls in a hidden headful window)
clearTimeout(timer); * is logged instead of ending the run.
remove(); * @param {import('puppeteer').Page} page
resolve(complete); * @param {string} file
}; * @param {string} label
const remove = scene.postRender.addEventListener(() => { */
rendered += 1; async function saveReviewShot(page, file, label) {
if (rendered >= count) { try {
finish(true); await page.screenshot({ path: file });
return; console.log(` screenshot (${label}) → ${path.relative(REPO_ROOT, file)}`);
} } catch (err) {
console.warn(
` screenshot skipped (${label}): ${path.relative(REPO_ROOT, file)}: ${err?.message ?? err}`,
);
}
}
/**
* Installs window.__qaRenderFrames(count, capMs) in the page: requests renders
* until `count` frames have passed scene.postRender or capMs runs out, and
* resolves with the number of frames rendered. The cap keeps a stalled
* renderer (headful window asleep or hidden) from hanging the CDP call.
* @param {import('puppeteer').Page} page
*/
function installRenderFramesHelper(page) {
return page.evaluate(() => {
window.__qaRenderFrames = (count, capMs) =>
new Promise((resolve) => {
const scene = window.__godsEyeView.viewer.scene;
let frames = 0;
const finish = () => {
clearTimeout(timer);
remove();
resolve(frames);
};
const remove = scene.postRender.addEventListener(() => {
if (++frames >= count) {
finish();
return;
}
scene.requestRender();
});
const timer = setTimeout(finish, capMs);
scene.requestRender(); scene.requestRender();
}); });
const timer = setTimeout(() => finish(false), 5000); });
scene.requestRender();
});
}, frameCount);
} }
// In-page helper (stringified and re-defined inside each evaluate that needs // In-page helper (stringified and re-defined inside each evaluate that needs
@@ -315,23 +359,21 @@ const BEARING_SHIFT_SRC = `
* @returns {Promise<boolean>} True if tilesLoaded was observed within the timeout. * @returns {Promise<boolean>} True if tilesLoaded was observed within the timeout.
*/ */
function waitForTilesLoaded(page, timeoutMs = 15000) { function waitForTilesLoaded(page, timeoutMs = 15000) {
return page return waitForPredicate(
.waitForFunction( page,
() => { () => {
const scene = window.__godsEyeView.viewer.scene; const scene = window.__godsEyeView.viewer.scene;
const prims = scene.primitives; const prims = scene.primitives;
for (let i = 0; i < prims.length; i++) { for (let i = 0; i < prims.length; i++) {
const p = prims.get(i); const p = prims.get(i);
if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') { if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') {
return p.tilesLoaded === true; return p.tilesLoaded === true;
}
} }
return true; // no tileset in the scene — nothing to wait for }
}, return true; // no tileset in the scene — nothing to wait for
{ timeout: timeoutMs }, },
) { timeoutMs },
.then(() => true) );
.catch(() => false);
} }
/** /**
@@ -400,6 +442,9 @@ async function main() {
const browser = await puppeteer.launch({ const browser = await puppeteer.launch({
headless: HEADFUL ? false : 'new', headless: HEADFUL ? false : 'new',
protocolTimeout: PROTOCOL_TIMEOUT_MS, protocolTimeout: PROTOCOL_TIMEOUT_MS,
// Talk CDP over a pipe, so no debugging port stays open on localhost for
// the length of a headful run.
pipe: true,
...(chromeExecutable ? { executablePath: chromeExecutable } : {}), ...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
args: [ args: [
'--no-sandbox', '--no-sandbox',
@@ -409,9 +454,6 @@ async function main() {
'--disable-web-security', '--disable-web-security',
'--disable-background-timer-throttling', '--disable-background-timer-throttling',
'--disable-renderer-backgrounding', '--disable-renderer-backgrounding',
// Puppeteer's defaults already include this; listed so the headful
// run keeps rendering when covered even if those defaults change.
'--disable-backgrounding-occluded-windows',
'--window-size=1280,800', '--window-size=1280,800',
], ],
}); });
@@ -436,13 +478,18 @@ async function main() {
}); });
console.log('Loading app...'); console.log('Loading app...');
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction( const appReady = await waitForPredicate(
page,
() => () =>
window.__godsEyeView && Boolean(
window.__godsEyeView.viewer && window.__godsEyeView &&
window.__godsEyeView.dataManager, window.__godsEyeView.viewer &&
{ timeout: 60000 }, window.__godsEyeView.dataManager,
),
{ timeoutMs: 60000 },
); );
if (!appReady) throw new Error('app did not initialise within 60s');
await installRenderFramesHelper(page);
// Let the initial fly-to Austin and first tiles settle. // Let the initial fly-to Austin and first tiles settle.
await sleep(4000); await sleep(4000);
@@ -514,14 +561,14 @@ async function main() {
console.log( console.log(
`Waiting for geometry-load queue to drain (N=${camCount}, budget ${Math.round(drainBudgetMs / 1000)}s)...`, `Waiting for geometry-load queue to drain (N=${camCount}, budget ${Math.round(drainBudgetMs / 1000)}s)...`,
); );
const drained = await waitForFunctionSliced( const drained = await waitForPredicate(
page, page,
() => { () => {
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
const ui = mod.getUIState(); const ui = mod.getUIState();
return ui.loading && ui.loading.active === false; return Boolean(ui.loading) && ui.loading.active === false;
}, },
drainBudgetMs, { timeoutMs: drainBudgetMs, intervalMs: 1000 },
); );
record( record(
`geometry-load queue drains within ${Math.round(drainBudgetMs / 1000)}s (N=${camCount})`, `geometry-load queue drains within ${Math.round(drainBudgetMs / 1000)}s (N=${camCount})`,
@@ -615,14 +662,11 @@ async function main() {
const tilesSeenLoaded = await waitForTilesLoaded(page, 45000); const tilesSeenLoaded = await waitForTilesLoaded(page, 45000);
let sampleFloorOk = false; let sampleFloorOk = false;
if (tilesSeenLoaded) { if (tilesSeenLoaded) {
sampleFloorOk = await page sampleFloorOk = await waitForPredicate(
.waitForFunction( page,
(base) => window.__qaCounters.sampleHeight - base >= 1, (base) => window.__qaCounters.sampleHeight - base >= 1,
{ timeout: 30000 }, { timeoutMs: 30000, args: [c0.sampleHeight] },
c0.sampleHeight, );
)
.then(() => true)
.catch(() => false);
} }
const cFloor = await readCounters(); const cFloor = await readCounters();
record( record(
@@ -1394,25 +1438,7 @@ async function main() {
}, },
}); });
window.__qaCctvSiblingOwner = owner; window.__qaCctvSiblingOwner = owner;
// Bounded: if the browser stops producing frames (headful window const framesRendered = await window.__qaRenderFrames(3, 10000);
// asleep or hidden), report it instead of hanging the CDP call.
const framesRendered = await new Promise((resolve) => {
let frames = 0;
const finish = () => {
clearTimeout(timer);
remove();
resolve(frames);
};
const remove = scene.postRender.addEventListener(() => {
if (++frames < 3) {
scene.requestRender();
return;
}
finish();
});
const timer = setTimeout(finish, 10000);
scene.requestRender();
});
const rect = scene.canvas.getBoundingClientRect(); const rect = scene.canvas.getBoundingClientRect();
const picked = scene.pick(canvasPoint); const picked = scene.pick(canvasPoint);
const pickedId = picked?.id?.id ?? picked?.id ?? null; const pickedId = picked?.id?.id ?? picked?.id ?? null;
@@ -1435,22 +1461,20 @@ async function main() {
const siblingOwnsPick = const siblingOwnsPick =
siblingTarget?.pickedId === 'qa-cctv-sibling-owner' && siblingTarget?.pickedId === 'qa-cctv-sibling-owner' &&
siblingTarget.ownsExactObject; siblingTarget.ownsExactObject;
// A missed pick after fewer than 3 frames means the renderer stalled, not // No frame at all within the 10s cap points at a stalled renderer rather
// that picking regressed: fail it on the real-GPU sign-off path, leave it // than at picking, though the harness can't rule out both. Fail it on the
// inconclusive under headless GL. // real-GPU sign-off path; leave it inconclusive under headless GL. A slow
// SwiftShader frame (1-2 of 3) still counts as a real miss.
const siblingRendererStalled = const siblingRendererStalled =
siblingTarget && !siblingOwnsPick && siblingTarget.framesRendered < 3; siblingTarget && !siblingOwnsPick && siblingTarget.framesRendered === 0;
record( record(
'sibling test object owns its canvas pick', 'sibling test object owns its canvas pick',
!siblingTarget !siblingTarget
? null ? null
: siblingRendererStalled : siblingOwnsPick ||
? HEADFUL (siblingRendererStalled && !HEADFUL ? null : false),
? false
: null
: siblingOwnsPick,
siblingTarget siblingTarget
? `${siblingRendererStalled ? `renderer stalled: ${siblingTarget.framesRendered}/3 frames in 10s; ` : ''}picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta} frames=${siblingTarget.framesRendered}/3` ? `${siblingRendererStalled ? 'renderer stalled: ' : ''}frames=${siblingTarget.framesRendered}/3 picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta}`
: 'no previously verified empty canvas ray was available', : 'no previously verified empty canvas ray was available',
); );
if (siblingOwnsPick) { if (siblingOwnsPick) {
@@ -1677,10 +1701,7 @@ async function main() {
); );
const viewshedShot = path.join(SHOTS_DIR, 'cctv-v3-viewshed.png'); const viewshedShot = path.join(SHOTS_DIR, 'cctv-v3-viewshed.png');
await page.screenshot({ path: viewshedShot }); await saveReviewShot(page, viewshedShot, 'visual review only');
console.log(
` screenshot (visual review only) → ${path.relative(REPO_ROOT, viewshedShot)}`,
);
// ========================================================================= // =========================================================================
// Group 7: calibration gizmo (v3 design §3c — ADJUST mode + drag) // Group 7: calibration gizmo (v3 design §3c — ADJUST mode + drag)
@@ -2014,10 +2035,7 @@ async function main() {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
await sleep(1000); await sleep(1000);
const shot = path.join(SHOTS_DIR, 'cctv-v2-active.png'); const shot = path.join(SHOTS_DIR, 'cctv-v2-active.png');
await page.screenshot({ path: shot }); await saveReviewShot(page, shot, 'visual review only, not a gate');
console.log(
` screenshot (visual review only, not a gate) → ${path.relative(REPO_ROOT, shot)}`,
);
// No console errors during the whole exercise. // No console errors during the whole exercise.
record( record(