Skip to content

CI: real-timer userEvent tests time out in SearchPicker/HouseholdItemPicker, blocking promotion #2076

Description

@steilerDev

CORRECTION (posted after diagnosis). The "Likely cause" section as originally filed blamed the @testing-library/user-event 14.6.1 -> 14.6.7 bump. That is wrong and has been refuted twice. The corrected diagnosis is below. Do not re-run the dependency bisection — it has now been done twice with a negative result.

Problem

Quality Gates fails on the beta -> main promotion PR (#2075). 11 tests fail, all of them 60-second timeouts, confined to two files:

  • client/src/components/SearchPicker/SearchPicker.test.tsx — 9 failures
  • client/src/components/HouseholdItemPicker/HouseholdItemPicker.breadcrumb.test.tsx — 2 failures

All 16 E2E shards pass. No assertion failures anywhere — purely wall-clock exhaustion.

Evidence

Shard wall-clock times across consecutive runs:

Run Shards 1-4 Shard 5 Shard 6
PR #2071 (merged 06:19) 7-10 min 8 min PASS 7 min PASS
PR #2070 (dev-deps bump, merged 11:19) 9-11 min 26 min PASS needed a retry to pass
PR #2075 (promotion) 9-12 min 45 min FAIL 22 min FAIL

Shards 1-4 were healthy on the same run where 5 and 6 blew up, so the CI runner as a whole was fine.

Root cause (corrected)

Not a dependency regression. Not a production bug. Not a bad assertion. This is real-timer scheduling latency in the test harness.

Under real timers, every userEvent sub-event awaits wait(config) — a real setTimeout(..., 0) inside the jsdom vm context. A single user.click() dispatches the full pointer/focus/click sequence, each step yielding a macrotask, each running pointerEventsCheck: EachApiCall (an ancestor walk calling getComputedStyle per level). So:

cost = (number of macrotask yields) x (event-loop scheduling latency)

On a contended runner with 6 Jest workers the latency term dominates. That is precisely why the timeline above worsens monotonically (8 -> 26 -> 45 min) with no code change between runs — a dependency-caused regression would have stepped once and stayed flat.

The decisive evidence is a control group inside the failing file itself. SearchPicker.test.tsx lines 79 and 100 use the fake-timer idiom. Those two tests type into the same input, flip the same isOpen, and mount the same FloatingPortal for the first time — and they are fast and have never failed. The cost therefore is not the portal mount's CPU work; it is the real-timer scheduling around it. Fake timers collapse every yield into a synchronous advanceTimersByTime, which is why the control group is immune.

Why the dependency bump was ruled out

  1. Bisection on PR chore(deps-dev): bump the dev-dependencies group across 1 directory with 20 updates #2070: a scratch install of @testing-library/react@16.3.2 @testing-library/user-event@14.6.1 jest@30.4.2 jest-environment-jsdom@30.4.1 reproduced identical failures — same test names, same shape — on the old versions. This result is already recorded in the jest.config.ts comment committed by chore(deps-dev): bump the dev-dependencies group across 1 directory with 20 updates #2070.
  2. Binary diff of the two published user-event tarballs (normalizing transpiler churn): the only substantive changes in dist/esm are a key-repeat flag in keyboard/index.js and a property-descriptor form change in document/patchFocus.js. utils/misc/wait.js is byte-identical and delay: 0 is unchanged in setup/setup.js. No hot-path change capable of a 5x regression.

A separate earlier investigation on PR #2073 had already excluded i18next/react-i18next by controlled swap.

Why raising the timeout again is the wrong fix

PR #2070 responded to this by raising the global testTimeout to 60000. We are now past that ceiling too. Raising it a third time keeps a 45-minute shard in the critical path of every promotion.

Fix

Convert all 57 real-timer userEvent.setup() call sites in the two files (47 + 10) to the fake-timer idiom already proven by the in-file control group, via a local per-file helper:

function setupUser() {
  jest.useFakeTimers();
  return userEvent.setup({ advanceTimers: jest.advanceTimersByTime.bind(jest) });
}

Partial conversion will not work. The failing set is not determined by test content — line 307 fails while the near-identical line 323 passes; line 164 fails while line 141 with the same shape passes. There is no property separating them, so any "just the marginal ones" list is a snapshot of one run's luck. Converting 9 of 47 also leaves the file's wall clock dominated by the other 38, missing the shard-time criterion.

Scope note on testTimeout

testTimeout: 60000 stays in this fix. There are ~649 real-timer userEvent.setup() call sites across the client suite; this change converts 57. The remaining ~592 still depend on the raised ceiling. Lowering it now would turn a scoped 2-file hotfix into a repo-wide firefight during a blocked promotion. Lowering belongs to the suite-wide follow-up.

Acceptance criteria

  • SearchPicker.test.tsx and HouseholdItemPicker.breadcrumb.test.tsx pass reliably in CI (run twice — the failure is probabilistic)
  • Shard 5 and shard 6 wall-clock times return toward the ~10-minute range
  • Per-test before/after timings captured, so the mechanism is evidenced rather than assumed
  • testTimeout value stays exactly 60000 (comment amended, number untouched)
  • No production code changes
  • No assertion is weakened — any test that goes green by asserting less is a regression, not a fix

Blocking

This blocks the v2.15.0 promotion (PR #2075).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions