Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 85 additions & 4 deletions src/react/components/ui/code-block.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,35 @@ async function settle(): Promise<void> {
* `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<void> {
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<void> {
// `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}`);
}

/**
Expand All @@ -96,6 +116,67 @@ async function unmount(root: Root): Promise<void> {
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(
Expand Down