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:
parent
c1eb08108b
commit
252aea245f
1 changed files with 66 additions and 24 deletions
|
|
@ -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,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue