mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
474d44c4db
commit
12b8fd0c1d
1 file changed
+135
-117
+135
-117
@@ -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(
|
||||||
|
|||||||
Reference in new issue
Block a user