You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Web unit tests that assert absence by letting a Testing Library wait expire #2335
Found while doing #2323, and it is why that issue's B1 shipped as "state the
value" rather than "raise it".
What was measured
#2323 proposed raising Testing Library's asyncUtilTimeout from its 1000ms
default to 5000 for the web unit project, on the reasoning that it governs 788 waitFor / findBy* sites and becomes the binding bound on every async
assertion once the per-test budget rises. Three arms of three full unit runs
each, same worktree, same machine, comparable load:
The failing set was different every run, none of the files were touched by the
diff, and every one passed in about a second in isolation — the shape of CPU
starvation, not of a slow assertion.
Why raising it costs anything at all
asyncUtilTimeout is not only a ceiling. A wait that is meant to expire
spends the whole budget on the happy path:
a test asserting something never appears, written as a waitFor that is
expected to reject;
any poll deliberately allowed to run out.
For those, a 5x raise is a 5x cost on a passing run. Enough of them and the
worker pool saturates, which is what starves unrelated tests.
The actual defect
Asserting absence by waiting for a timeout is the anti-pattern. It is slow by
construction, it scales with a global nobody expects to be load-bearing, and it
is the same objection #1596 makes to timing-dependent assertions. The direct
form costs nothing:
Find them. Enumerate the waitFor / findBy* sites in clients/web (and
the story files) whose success depends on the wait expiring — rejects, try/catch around a wait, a helper that treats a timeout as "absent".
Rewrite each as a direct negative query, or as a wait on the positive
condition that must hold once the absent thing would have appeared.
Investigated: there are no such tests — recommending dismissal
Worked on a worktree at v2/main @ 4a522ebc (after the #2339 lease and the #2336 decision), Vitest 4.1.11, real npm install. Three kinds of evidence,
each of which would have found an expiring wait on its own; all three come up
empty.
1. Enumeration by grep — every shape the issue names, plus the ones it implies
Over clients/web/src/**/*.{test.ts,test.tsx,stories.tsx} (unit, core/
tests, integration, and the story play functions):
a helper named for absence (expectNever, assertAbsent, waitForAbsence, …)
0
expect(await screen.findBy…) used with .not / toBeNull
0
.rejects anywhere with a findBy/waitFor in the 8 lines above it
2, both custom helpers with their own explicit budgets, not Testing Library: src/test/core/mcp/state/waitForChangeEvent.test.ts:33 (unit-tests the helper's own 25 ms timeout — that is the behaviour under test) and src/test/integration/mcp/remote/servers-events.test.ts:438 (sink.waitFor(1, 400) — integration project, a hand-rolled SSE sink, 400 ms, no watcher attached so there is no positive condition to await)
The 56 files carrying .rejects are all promise-rejection tests of the code
under test (callTool, load(), requestElicitation, …), not waits. The many await waitFor(() => expect(x).not.toBeInTheDocument()) sites are not the
defect: waitFor returns the first time its callback does not throw, so a
negative predicate that already holds settles on the synchronous first check.
Nothing in this tree is governed by asyncUtilTimeoutand passes by reaching
it.
2. Enumeration by measurement — per-test durations
A test whose passing path lets a 1000 ms wait expire cannot take less than
1000 ms. Full unit run through npm run test with the JSON reporter: 345 files,
6461 tests, wall 46 s, and 4 tests ≥ 900 ms, 0 ≥ 2000 ms. All four are
accounted for, none by a Testing Library budget:
ms
test
what it spends
1300
ServerImportJsonModal › auto-collapses the File Contents disclosure
the component's own real timers (VALIDATE_DEBOUNCE_MS 300 + HIGHLIGHT_DURATION_MS 250 + COLLAPSE_DELAY_MS 1000) under a positive waitFor that returns the moment aria-expanded flips
ResourceTemplatePanel › passes sibling variables as completion context
two fixed setTimeout(r, 400) sleeps past a 300 ms debounce (see the note below)
3. The issue's own three-arm protocol, re-run — 5000 is a no-op on the passing path
If nothing spends asyncUtilTimeout on the happy path, raising it must be
invisible. Arms interleaved per round (A = v2/main, B = configure({ asyncUtilTimeout: 5000 }), C = reverted), three rounds, same worktree, no
other Vitest on the box (checked per run), ambient load 14 → 36 from sibling
agent sessions and drifting upward across the campaign:
round
A (1000)
B (5000)
C (1000, reverted)
1
45 s, 0 failures
50 s, 0 failures*
50 s, 0 failures
2
51 s, 0 failures
51 s, 0 failures*
53 s, 0 failures
3
53 s, 0 failures
55 s, 0 failures*
54 s, 0 failures
* the one red test in every B run is src/test/asyncUtilTimeout.test.ts
asserting the pinned value — the guard doing its job, and expected.
Tests ≥ 5000 ms in the B arm: 0, in all three rounds. An expiring wait
would have shown up there by construction.
Tests ≥ 1000 ms: 3 in every run of every arm — the same three.
Wall time tracks the load average, not the arm: C at 1000 was as slow as B.
So the causal story in the issue — "raising it was expensive because tests
spend the budget on the happy path" — does not hold; there is nothing to spend
it. That also bears on #2323's original 3/3 → 1, 4, 0 → 3/3 result: those
arms were run before the #2339 lease and not interleaved, and the failing sets
were different each time and unrelated to the diff — which is the fingerprint
of ambient load the arms did not share, not of the mechanism named. I am
stating that as an inference from this data, not a re-measurement of that run.
The AppRenderer theme-flip case (#2336's one starved test)
Read as the orchestrator asked. Its 5 s inner waitFor is a positive
predicate (sendHostContextChange called with theme: "dark") that returns
on its first synchronous check when the call has landed, so it spends nothing
on the passing path — not aspect 3. The whole delivery chain is microtasks:
happy-dom's MutationObserver reports via window.queueMicrotask, scheduleDispose is microtask-deferred and cancelled synchronously by the
StrictMode re-setup, and flushAsync's two ticks settle the synchronous
factory's Promise.resolve(...).then(...). Nothing in it waits on a timer that
load could stretch, so a "0 calls at 5017 ms" is not explained by this test's
structure. I could not reproduce it either: 15/15 passes running that test
beside a concurrent full unit run (14 workers, load peaking at 39). Left as is;
if it recurs, the thing to capture is whether initialized was ever observed
(initializedRef), since that is the only gate between the flip and the call.
What I did find, and deliberately did not touch
Fixed real-timer sleeps in the unit project, all past a debounce or
asserting nothing happened within a window: ResourceTemplatePanel.test.tsx
(8 × 350–400 ms) and src/test/core/react/useServers.test.tsx (2 × 200 ms,
2 × 100 ms). They are the "do not scale a fixed sleep" class from Test-gate timeouts,
not this issue's: they are bounded by no shared budget, so they do not make
any raise more expensive, and the rule is to replace one when it actually
flakes — none has. Total ≈ 3.7 s of worker time in a ~380 s-CPU suite.
Recommendation
Dismiss. Acceptance item 1 holds vacuously (no web test's success depends on a
Testing Library wait expiring); item 2 is satisfied by the table above with the
budget left stated at 1000 — the #2336 measurement already showed 0 failures
in 18 runs at that value on the leased baseline, and this campaign shows 5000
buys nothing on the passing path, so there is no evidence on which to raise it.
One loose end for whoever closes this: clients/web/src/test/setup.ts (the
comment above configure) and src/test/asyncUtilTimeout.test.ts both
explain the pin by pointing at "the tests that assert absence by letting a
wait expire … #2335; revisit when they are gone." That premise is now known
to be false. Both comments should be corrected to say the value is kept
because raising it was measured to buy nothing — a two-comment edit that
belongs in the next PR touching that file rather than a PR of its own.
Not changed: no code, no board card, issue left open for the maintainer's
call.
Activity
fix: address Copilot review round 2, and drop the asyncUtilTimeout raise
cliffhall commentedon Sep 13, 2026
Investigated: there are no such tests — recommending dismissal
Worked on a worktree at
v2/main@4a522ebc(after the #2339 lease and the#2336 decision), Vitest 4.1.11, real
npm install. Three kinds of evidence,each of which would have found an expiring wait on its own; all three come up
empty.
1. Enumeration by grep — every shape the issue names, plus the ones it implies
Over
clients/web/src/**/*.{test.ts,test.tsx,stories.tsx}(unit,core/tests, integration, and the story play functions):
expect(findBy*/findAllBy*/waitFor(...)).rejects(multiline)waitForElementToBeRemovedtry { … findBy/waitFor … } catch(tests andsrc/test/helpers)findBy/waitFor(...).catch(expectNever,assertAbsent,waitForAbsence, …)expect(await screen.findBy…)used with.not/toBeNull.rejectsanywhere with afindBy/waitForin the 8 lines above itsrc/test/core/mcp/state/waitForChangeEvent.test.ts:33(unit-tests the helper's own 25 ms timeout — that is the behaviour under test) andsrc/test/integration/mcp/remote/servers-events.test.ts:438(sink.waitFor(1, 400)— integration project, a hand-rolled SSE sink, 400 ms, no watcher attached so there is no positive condition to await)The 56 files carrying
.rejectsare all promise-rejection tests of the codeunder test (
callTool,load(),requestElicitation, …), not waits. The manyawait waitFor(() => expect(x).not.toBeInTheDocument())sites are not thedefect:
waitForreturns the first time its callback does not throw, so anegative predicate that already holds settles on the synchronous first check.
Nothing in this tree is governed by
asyncUtilTimeoutand passes by reachingit.
2. Enumeration by measurement — per-test durations
A test whose passing path lets a 1000 ms wait expire cannot take less than
1000 ms. Full unit run through
npm run testwith the JSON reporter: 345 files,6461 tests, wall 46 s, and 4 tests ≥ 900 ms, 0 ≥ 2000 ms. All four are
accounted for, none by a Testing Library budget:
ServerImportJsonModal› auto-collapses the File Contents disclosureVALIDATE_DEBOUNCE_MS300 +HIGHLIGHT_DURATION_MS250 +COLLAPSE_DELAY_MS1000) under a positivewaitForthat returns the momentaria-expandedflipsViewHeader› the two #1450 crossfade casesrenderWithMantineTransitions' documented settle (HEADER_ANIM_MS + RAF_SLACK_MS)ResourceTemplatePanel› passes sibling variables as completion contextsetTimeout(r, 400)sleeps past a 300 ms debounce (see the note below)3. The issue's own three-arm protocol, re-run — 5000 is a no-op on the passing path
If nothing spends
asyncUtilTimeouton the happy path, raising it must beinvisible. Arms interleaved per round (A =
v2/main, B =configure({ asyncUtilTimeout: 5000 }), C = reverted), three rounds, same worktree, noother Vitest on the box (checked per run), ambient load 14 → 36 from sibling
agent sessions and drifting upward across the campaign:
* the one red test in every B run is
src/test/asyncUtilTimeout.test.tsasserting the pinned value — the guard doing its job, and expected.
would have shown up there by construction.
So the causal story in the issue — "raising it was expensive because tests
spend the budget on the happy path" — does not hold; there is nothing to spend
it. That also bears on #2323's original 3/3 → 1, 4, 0 → 3/3 result: those
arms were run before the #2339 lease and not interleaved, and the failing sets
were different each time and unrelated to the diff — which is the fingerprint
of ambient load the arms did not share, not of the mechanism named. I am
stating that as an inference from this data, not a re-measurement of that run.
The
AppRenderertheme-flip case (#2336's one starved test)Read as the orchestrator asked. Its 5 s inner
waitForis a positivepredicate (
sendHostContextChangecalled withtheme: "dark") that returnson its first synchronous check when the call has landed, so it spends nothing
on the passing path — not aspect 3. The whole delivery chain is microtasks:
happy-dom's
MutationObserverreports viawindow.queueMicrotask,scheduleDisposeis microtask-deferred and cancelled synchronously by theStrictMode re-setup, and
flushAsync's two ticks settle the synchronousfactory's
Promise.resolve(...).then(...). Nothing in it waits on a timer thatload could stretch, so a "0 calls at 5017 ms" is not explained by this test's
structure. I could not reproduce it either: 15/15 passes running that test
beside a concurrent full unit run (14 workers, load peaking at 39). Left as is;
if it recurs, the thing to capture is whether
initializedwas ever observed(
initializedRef), since that is the only gate between the flip and the call.What I did find, and deliberately did not touch
Fixed real-timer sleeps in the unit project, all past a debounce or
asserting nothing happened within a window:
ResourceTemplatePanel.test.tsx(8 × 350–400 ms) and
src/test/core/react/useServers.test.tsx(2 × 200 ms,2 × 100 ms). They are the "do not scale a fixed sleep" class from
Test-gate timeouts,
not this issue's: they are bounded by no shared budget, so they do not make
any raise more expensive, and the rule is to replace one when it actually
flakes — none has. Total ≈ 3.7 s of worker time in a ~380 s-CPU suite.
Recommendation
Dismiss. Acceptance item 1 holds vacuously (no web test's success depends on a
Testing Library wait expiring); item 2 is satisfied by the table above with the
budget left stated at 1000 — the #2336 measurement already showed 0 failures
in 18 runs at that value on the leased baseline, and this campaign shows 5000
buys nothing on the passing path, so there is no evidence on which to raise it.
One loose end for whoever closes this:
clients/web/src/test/setup.ts(thecomment above
configure) andsrc/test/asyncUtilTimeout.test.tsbothexplain the pin by pointing at "the tests that assert absence by letting a
wait expire … #2335; revisit when they are gone." That premise is now known
to be false. Both comments should be corrected to say the value is kept
because raising it was measured to buy nothing — a two-comment edit that
belongs in the next PR touching that file rather than a PR of its own.
Not changed: no code, no board card, issue left open for the maintainer's
call.
docs: address Copilot review round 1
Merge pull request #2352 from modelcontextprotocol/v2/docs/2347-async…
docs: stop justifying the asyncUtilTimeout pin by pointing at modelco…