Web unit tests that assert absence by letting a Testing Library wait expire · Issue #2335 · modelcontextprotocol/inspector · GitHub
Skip to content

Web unit tests that assert absence by letting a Testing Library wait expire #2335

Closed as not planned

Description

@cliffhall

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:

Arm Result
unmodified v2/main 3/3 green
#2323 with asyncUtilTimeout: 5000 1, 4 and 0 files timing out at 15000ms
the same change with that one line reverted 3/3 green

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:

expect(screen.queryByText("…")).not.toBeInTheDocument();

Work

  1. 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".
  2. 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.
  3. Then revisit the budget. With the expiring waits gone, raising
    asyncUtilTimeout costs only failing assertions, which is the trade Test-gate timeouts are still library defaults — the unit, tui and launcher projects and all 788 Testing Library waits fail correct tests under concurrent-worktree load #2323
    described and could not get. Re-run the three-arm measurement before changing
    it; verify:test-timeouts pins the current value
    (EXPECTED_ASYNC_UTIL_TIMEOUT), so the change is deliberate by construction.

Not in scope

Raising asyncUtilTimeout on its own. That was measured and rejected; this
issue is about removing the thing that made it expensive.

Acceptance

  • No web test's success depends on a Testing Library wait expiring, or each
    remaining one is listed here with why it cannot be a direct query.
  • The three-arm measurement is re-run and recorded, and the budget is either
    raised deliberately with that evidence or left stated at its current value.

Activity

  1. added this to the v2.8.0 milestone on Sep 12, 2026
  2. added
    v2Issues and PRs for v2
    choreMaintenance: deps, build tooling, CI, cleanup — no user-facing behavior change
    on Sep 12, 2026
  3. modified the milestones: v2.8.0, v2.7.0 on Sep 12, 2026
  4. self-assigned this
    on Sep 12, 2026
  5. cliffhall commented on Sep 13, 2026

    @cliffhall
    MemberAuthor

    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):

    Shape Hits
    expect(findBy*/findAllBy*/waitFor(...)).rejects (multiline) 0 against Testing Library
    waitForElementToBeRemoved 0
    try { … findBy/waitFor … } catch (tests and src/test/ helpers) 0
    findBy/waitFor(...).catch( 0
    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 asyncUtilTimeout and 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
    1182 / 1147 ViewHeader › the two #1450 crossfade cases renderWithMantineTransitions' documented settle (HEADER_ANIM_MS + RAF_SLACK_MS)
    936 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.

  6. removed this from the v2.7.0 milestone on Sep 13, 2026
  7. added 2 commits that reference this issue on Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

choreMaintenance: deps, build tooling, CI, cleanup — no user-facing behavior changetestsUnit, smoke (e2e), Storybookv2Issues and PRs for v2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions