From cf887a65a179f7b2f93f3ea1fc7cc638d01744ac Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:14:26 -0500 Subject: [PATCH] test(web): the stepped-loops and pinned-header tests wait for frames, not a fixed time Both tests gave the browser a short, fixed time to draw. A busy CI machine draws a frame or two in half a second, so they failed with nothing to check. steppedLoops watched a loop's positions over a fixed 500 ms and wanted more than two poses ("expected 1 to be greater than 2" on #919). It now watches each drawn frame until the run shows what the test is about, capped at 10 s, and asserts on the whole run as before. With the page's thread held busy 450 ms of every 490 ms, the old test fails that way and this one passes. The pinned-header test waited the default second for a scroll-state query, which only updates when a frame is drawn (failed once on #916). Its waits are now 10 s. That failure did not reproduce locally, so this one is inferred. --- apps/web/src/lib/steppedLoops.browser.tsx | 62 +++++++++++++++++------ apps/web/src/theme/themeStore.browser.tsx | 7 ++- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/apps/web/src/lib/steppedLoops.browser.tsx b/apps/web/src/lib/steppedLoops.browser.tsx index e514f40c5..364bdd3af 100644 --- a/apps/web/src/lib/steppedLoops.browser.tsx +++ b/apps/web/src/lib/steppedLoops.browser.tsx @@ -43,15 +43,41 @@ function loopOf(name: string): { element: HTMLElement; animation: Animation } { const isStepped = (animation: Animation) => String(animation.effect?.getTiming().easing).startsWith("steps("); -/** Where a loop's box stands on every frame drawn over `ms`, in px from where it starts. */ -async function positionsOver(name: string, ms: number): Promise { - const { element } = loopOf(name); - const origin = element.parentElement!.getBoundingClientRect().left; - const positions: number[] = []; - const stop = readAfterEachPaint(() => - positions.push(element.getBoundingClientRect().left - origin), - ); - await new Promise((resolve) => setTimeout(resolve, ms)); +/** The longest a run of frames is watched for what a test is about. */ +const WATCH_CAP_MS = 10_000; + +/** + * Where each loop's box stands on every frame drawn, in px from where it + * starts, until the run shows what the test is about (`enough`). It is not a + * fixed stretch of time: a busy machine draws a frame or two in half a + * second, which shows one pose and says nothing. A loop that never shows it + * ends the run at the cap, and fails on what it did show. + */ +async function positionsUntil( + names: ReadonlyArray, + enough: (positions: Record) => boolean, +): Promise> { + const positions = Object.fromEntries(names.map((name) => [name, [] as number[]])) as Record< + Name, + number[] + >; + const loops = names.map((name) => { + const { element } = loopOf(name); + return { name, element, origin: element.parentElement!.getBoundingClientRect().left }; + }); + let stop = () => {}; + await new Promise((resolve) => { + const cap = setTimeout(resolve, WATCH_CAP_MS); + stop = readAfterEachPaint(() => { + for (const loop of loops) { + positions[loop.name].push(loop.element.getBoundingClientRect().left - loop.origin); + } + if (enough(positions)) { + clearTimeout(cap); + resolve(); + } + }); + }); stop(); return positions; } @@ -103,10 +129,10 @@ describe("installSteppedLoops", () => { expect(isStepped(loopOf("stepped").animation)).toBe(true); expect(isStepped(loopOf("late").animation)).toBe(true); }); - const [plain, stepped] = await Promise.all([ - positionsOver("plain", 500), - positionsOver("stepped", 500), - ]); + const { plain, stepped } = await positionsUntil( + ["plain", "stepped"], + (seen) => new Set(seen.stepped).size > 2 && offGrid(seen.plain, 10).length > 0, + ); expect(new Set(stepped).size).toBeGreaterThan(2); expect(offGrid(stepped, 10)).toEqual([]); expect(offGrid(plain, 10).length).toBeGreaterThan(0); @@ -131,12 +157,18 @@ describe("installSteppedLoops", () => { await vi.waitFor(() => expect(isStepped(loopOf("stepped").animation)).toBe(true)); setInFront(false); - const behind = await positionsOver("stepped", 500); + const { stepped: behind } = await positionsUntil( + ["stepped"], + (seen) => new Set(seen.stepped).size > 1, + ); expect(new Set(behind).size).toBeGreaterThan(1); expect(offGrid(behind, 300 / UNATTENDED_LOOP_FPS)).toEqual([]); setInFront(true); - const inFront = await positionsOver("stepped", 500); + const { stepped: inFront } = await positionsUntil( + ["stepped"], + (seen) => offGrid(seen.stepped, 300 / UNATTENDED_LOOP_FPS).length > 0, + ); expect(offGrid(inFront, 10)).toEqual([]); expect(offGrid(inFront, 300 / UNATTENDED_LOOP_FPS).length).toBeGreaterThan(0); } finally { diff --git a/apps/web/src/theme/themeStore.browser.tsx b/apps/web/src/theme/themeStore.browser.tsx index c8597a077..2b60a3e2c 100644 --- a/apps/web/src/theme/themeStore.browser.tsx +++ b/apps/web/src/theme/themeStore.browser.tsx @@ -164,10 +164,13 @@ describe("theme store, applied", () => { expect(visibility()).toBe("visible"); root().setAttribute("data-glass", ""); expect(visibility()).toBe("hidden"); + // Whether the header is pinned is read when a frame is drawn, and a busy + // machine can take longer than the default second to draw one. + const onceDrawn = { timeout: 10_000 }; screen.getByTestId("scroller").element().scrollTop = 200; - await vi.waitFor(() => expect(visibility()).toBe("visible")); + await vi.waitFor(() => expect(visibility()).toBe("visible"), onceDrawn); screen.getByTestId("scroller").element().scrollTop = 0; - await vi.waitFor(() => expect(visibility()).toBe("hidden")); + await vi.waitFor(() => expect(visibility()).toBe("hidden"), onceDrawn); } finally { root().removeAttribute("data-glass"); await screen.unmount();