test(screenshots): hybrid render-settle — fast rAF converge, then wide confirmations

waitForRenderStable now runs two phases (still entirely in-page, still just the
canvas hash — no CDP screenshots):

  1. Fast per-rAF convergence — hash every animation frame until stableFrames are
     identical (~48ms). Catches high-frequency motion, cheap.
  2. Wide confirmation — then re-hash confirmFrames times, each `interval` ms apart
     (default 2 × 250ms ≈ 500ms). A slow async repaint (e.g. a file list arriving
     after an asyncify readdir) that a few 16ms frames would sail past now forces a
     reset back to phase 1.

This closes the hole where three ~16ms samples could declare "settled" ~200ms
before a delayed paint lands. Genuinely-animating states still never converge and
are captured at the timeout, as before.

Cost (local, 4 workers): e2e 62s->103s, kicad 155s->212s — pure waiting, no extra
CPU. Both suites green (e2e 340, kicad 92). Tunable via confirmFrames/interval.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVX1pHMvRPYHdp6ZfEawrk
This commit is contained in:
Viktor Vaczi 2026-07-07 12:33:14 +02:00
commit 252aea245f

View file

@ -1813,10 +1813,15 @@ export async function waitForCanvasStable(
*/ */
type StableShotOptions = { type StableShotOptions = {
fullPage?: boolean; fullPage?: boolean;
/** Stabilization budget (ms). Animating states never settle: we proceed and capture anyway. */ /** Overall stabilization budget (ms). Animating states never settle: we proceed and capture anyway. */
timeout?: number; timeout?: number;
/** Consecutive identical animation frames required before the render is "settled". */ /** Consecutive identical frames for the fast (per-rAF) convergence pass. */
stableFrames?: number; stableFrames?: number;
/** Wider-spaced confirmations required AFTER fast convergence (each `interval` ms apart), to catch a
* slow async repaint that a few ~16ms frames would miss. */
confirmFrames?: number;
/** ms between the wide confirmations. */
interval?: number;
/** Canvas whose pixels signal render activity (the wx main surface). Default '#canvas'. */ /** Canvas whose pixels signal render activity (the wx main surface). Default '#canvas'. */
canvas?: string; canvas?: string;
}; };
@ -1829,17 +1834,22 @@ const SHOT_OPTS = { scale: 'css', animations: 'disabled', caret: 'hide' } as con
* toHaveScreenshot, decoupled so pixel COMPARISON stays in the offline tools/screenshots gate * toHaveScreenshot, decoupled so pixel COMPARISON stays in the offline tools/screenshots gate
* (which diffs test-results/ against tests/baseline-screenshots/ on CI's deterministic Linux render). * (which diffs test-results/ against tests/baseline-screenshots/ on CI's deterministic Linux render).
* *
* Stabilization runs entirely IN-PAGE (one page.evaluate): it hashes a 64px downscale of the wx * Runs entirely IN-PAGE (one page.evaluate), hashing a 64px downscale of the wx canvas — NO repeated
* canvas once per animation frame and resolves after `stableFrames` consecutive identical frames, * CDP screenshots (an earlier screenshot-probe version saturated CDP on the heavy kicad canvases and
* or when `timeout` elapses. There are NO repeated CDP screenshots — an earlier screenshot-probe * timed the whole suite out). Two-phase HYBRID:
* version saturated CDP on the heavy kicad canvases and timed the whole suite out. Genuinely- * 1. Fast rAF convergence — hash every animation frame until `stableFrames` are identical (~48ms):
* animating states (timers, mid-slide) never converge: we resolve at the deadline and capture the * cheap, catches high-frequency motion (animations).
* current frame, exactly as the old raw screenshots did. With no canvas we just wait `stableFrames` * 2. Wide confirmation — then re-hash `confirmFrames` times, each `interval` ms apart (~500ms), so a
* paint frames — the spec's deterministic waits have already reached the target state. * SLOW async repaint (e.g. a file list arriving after an asyncify readdir) that a few 16ms frames
* would sail past forces a reset back to phase 1.
* Resolves once both phases pass, or when `timeout` elapses — genuinely-animating states (timers,
* mid-slide) never converge and are captured at the deadline, exactly as the old raw screenshots did.
* With no canvas we just wait `stableFrames` paint frames (the spec's deterministic waits already
* reached the target state).
*/ */
export async function waitForRenderStable(page: Page, options: StableShotOptions = {}): Promise<void> { export async function waitForRenderStable(page: Page, options: StableShotOptions = {}): Promise<void> {
await page.evaluate( await page.evaluate(
({ sel, need, timeout }: { sel: string; need: number; timeout: number }) => ({ sel, need, confirmN, interval, timeout }: { sel: string; need: number; confirmN: number; interval: number; timeout: number }) =>
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
const el = document.querySelector(sel) as HTMLCanvasElement | null; const el = document.querySelector(sel) as HTMLCanvasElement | null;
if (!el || typeof el.getContext !== 'function') { if (!el || typeof el.getContext !== 'function') {
@ -1854,29 +1864,61 @@ export async function waitForRenderStable(page: Page, options: StableShotOptions
off.height = size; off.height = size;
const ctx = off.getContext('2d', { willReadFrequently: true }); const ctx = off.getContext('2d', { willReadFrequently: true });
const start = performance.now(); const start = performance.now();
let prev = NaN; const hash = (): number => {
let stable = 0;
const tick = () => {
let sig = prev;
try { try {
ctx!.drawImage(el, 0, 0, size, size); ctx!.drawImage(el, 0, 0, size, size);
const d = ctx!.getImageData(0, 0, size, size).data; const d = ctx!.getImageData(0, 0, size, size).data;
sig = 0; let sig = 0;
for (let i = 0; i < d.length; i += 4) sig = (sig * 31 + d[i] + d[i + 1] * 7 + d[i + 2] * 13) | 0; for (let i = 0; i < d.length; i += 4) sig = (sig * 31 + d[i] + d[i + 1] * 7 + d[i + 2] * 13) | 0;
return sig;
} catch { } catch {
/* canvas not yet readable — treat as unchanged */ return NaN; // canvas not yet readable
} }
if (sig === prev) stable += 1;
else {
prev = sig;
stable = 1;
}
if (stable >= need || performance.now() - start > timeout) resolve();
else requestAnimationFrame(tick);
}; };
requestAnimationFrame(tick); const expired = () => performance.now() - start > timeout;
let prev = NaN;
let stable = 0;
let confirms = 0;
// Phase 2: wide-spaced confirmations. A differing/unreadable sample => a slow change is still
// in flight, so restart the fast pass.
const confirm = () => {
if (expired()) return resolve();
const cur = hash();
if (cur === prev && !Number.isNaN(cur)) {
if (++confirms >= confirmN) return resolve();
setTimeout(confirm, interval);
} else {
prev = cur;
stable = Number.isNaN(cur) ? 0 : 1;
confirms = 0;
requestAnimationFrame(fast);
}
};
// Phase 1: fast per-frame convergence.
const fast = () => {
if (expired()) return resolve();
const cur = hash();
if (cur === prev && !Number.isNaN(cur)) {
if (++stable >= need) {
confirms = 0;
setTimeout(confirm, interval);
return;
}
} else {
prev = cur;
stable = Number.isNaN(cur) ? 0 : 1;
}
requestAnimationFrame(fast);
};
requestAnimationFrame(fast);
}), }),
{ sel: options.canvas ?? '#canvas', need: options.stableFrames ?? 3, timeout: options.timeout ?? 3000 } {
sel: options.canvas ?? '#canvas',
need: options.stableFrames ?? 3,
confirmN: options.confirmFrames ?? 2,
interval: options.interval ?? 250,
timeout: options.timeout ?? 3000,
}
); );
} }