diff --git a/src/react/components/ui/code-block.test.tsx b/src/react/components/ui/code-block.test.tsx index 89a27b5d9f..246cca3c22 100644 --- a/src/react/components/ui/code-block.test.tsx +++ b/src/react/components/ui/code-block.test.tsx @@ -71,15 +71,35 @@ async function settle(): Promise { * `clipboard.writeText` falls back to `execCommand`, and only once that returns * false does the failed state get set. That chain is several ticks long, so on * a loaded machine the assertion could run against the pre-failure render. Poll - * for the state the test is actually about instead of guessing a tick count. + * for the state the test is actually about, against a wall-clock deadline + * rather than an iteration count: under load each poll costs more, so a fixed + * number of attempts is an arbitrary proxy for how long the test is willing to + * wait. */ -async function waitFor(predicate: () => boolean, description: string): Promise { - for (let attempt = 0; attempt < 100; attempt += 1) { +const WAIT_FOR_TIMEOUT_MS = 2_000; + +async function waitFor( + predicate: () => boolean, + description: string, + timeoutMs: number = WAIT_FOR_TIMEOUT_MS, +): Promise { + // `performance.now()` rather than `Date.now()`: a wall clock corrected mid-run + // by NTP or a VM host can jump backwards, holding the loop open past its + // bound, or forwards, timing out a test that was about to pass. Elapsed time + // is what this is measuring, so measure it monotonically. + const deadline = performance.now() + timeoutMs; + + for (;;) { flushSync(() => {}); if (predicate()) return; + // Checked after the predicate, so a state that lands exactly on the + // deadline still counts, and before the sleep, so a failed final check + // does not pay for a tick it will never use. + if (performance.now() >= deadline) { + throw new Error(`Timed out after ${timeoutMs}ms waiting for ${description}`); + } await new Promise((resolve) => setTimeout(resolve, 1)); } - throw new Error(`Timed out waiting for ${description}`); } /** @@ -96,6 +116,67 @@ async function unmount(root: Root): Promise { await new Promise((resolve) => setTimeout(resolve, 0)); } +describe("waitFor", () => { + // The helper decides whether the clipboard assertions run against a settled + // render, so a version that resolved early or swallowed a timeout would make + // those tests pass without checking anything. + + it("returns as soon as the predicate holds", async () => { + let polls = 0; + await waitFor(() => ++polls >= 3, "the third poll", 1_000); + assertEquals(polls, 3, "stops on the poll that succeeds rather than running to the deadline"); + }); + + it("succeeds on a state that lands within the deadline", async () => { + let ready = false; + setTimeout(() => (ready = true), 5); + await waitFor(() => ready, "a state that arrives late", 1_000); + assertEquals(ready, true); + }); + + it("throws a named error naming its timeout", async () => { + let message = ""; + try { + await waitFor(() => false, "something that never happens", 20); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + assertEquals(message, "Timed out after 20ms waiting for something that never happens"); + }); + + it("does not sleep after the check that gives up", async () => { + // The contract is predicate, then deadline, then sleep: every poll except + // the last is followed by a sleep. A trailing sleep would mean waiting a + // tick the loop can never use. + const realSetTimeout = globalThis.setTimeout; + let sleeps = 0; + let polls = 0; + (globalThis as { setTimeout: typeof setTimeout }).setTimeout = (( + handler: TimerHandler, + timeout?: number, + ) => { + sleeps += 1; + return realSetTimeout(handler as () => void, timeout); + }) as typeof setTimeout; + + try { + await waitFor( + () => { + polls += 1; + return false; + }, + "never", + 20, + ).catch(() => {}); + } finally { + (globalThis as { setTimeout: typeof setTimeout }).setTimeout = realSetTimeout; + } + + assert(polls > 1, "the loop polls more than once before giving up"); + assertEquals(sleeps, polls - 1, "one sleep between polls, none after the last"); + }); +}); + describe("CodeBlock renderer boundary", () => { it("keeps the standalone CodeSurface renderer optional", () => { const html = renderToString(