fix(ui-test): give Search.test.tsx a real wall-clock wait budget (BLO-30247) - #1503
Conversation
…-30247) `waitForAssertion` looked like a 50-attempt safety net but had no wall-clock budget: each retry was one microtask drain plus `setTimeout(0)`, so the 50 attempts bought only ~50-100ms and could not advance a pending 250ms debounce timer. The fixed `setTimeout(resolve, 350)` before each assertion was the only thing actually outlasting the debounce, on a 100ms margin. When a contended runner deschedules the timer callback past that margin, all retries burn out in microseconds and the assertion reports `Number of calls: 0` — observed as a merge-gating flake on paperclip#1420, #1466 (parked an armed auto-merge ~5h) and #1497, each time on a server-only diff that cannot reach this UI file. - `waitForAssertion` is now deadline-based with a real 10ms per-attempt delay. - Budget and pre-assertion sleep both derive from `SEARCH_DEBOUNCE_MS`, so raising the debounce cannot silently re-open this. - All 6 fixed-sleep sites audited: 5 converted to `settleDebounce()`, and `flush()`'s `setTimeout(0)` documented as a sound macrotask yield. `Search.tsx` changes only by exporting the existing constant — no behavioural change. Verified by deterministic negative control (raising the debounce so it outlasts the old fixed sleep): pre-fix 500ms -> 3 tests fail `Number of calls: 0`, 600ms -> 4 fail; post-fix 500/600/1000ms all 19 pass. Co-Authored-By: Claude <noreply@anthropic.com>
|
🔗 Paperclip issue: BLO-30247 |
1 similar comment
|
🔗 Paperclip issue: BLO-30247 |
|
@ally please review at head 8fba06765ea6ff0e5e2f30cd0e5cbcb27e5e2c56 — BLO-30247, test-harness only. Focus:
|
|
Correction to the review request above: the head SHA I quoted was wrong — I expanded it from the short hash instead of reading it. The actual head is |
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 8fba067
Looks good. The diagnosis is right and the fix is the right shape: a retry count was never a wall-clock budget, and swapping it for a real deadline derived from the production debounce is a durable fix rather than another magic-number bump. No blockers.
Critical Issues (0)
Important Issues (0)
Suggestions (2)
-
[comments]
ui/src/pages/Search.test.tsx:115— "Both numbers below derive fromSEARCH_DEBOUNCE_MS" is not true ofASSERTION_POLL_MS = 10(:119), which is a bare literal; onlyASSERTION_TIMEOUT_MS(:118) derives. A fixed poll interval is the correct choice — the comment just tells a future reader the opposite, which is exactly the kind of thing this comment exists to prevent. Suggest "The budget below derives fromSEARCH_DEBOUNCE_MS…" and, if worth saying, a clause noting the poll interval is deliberately independent of it. -
[code]
ui/src/pages/Search.test.tsx:142—settleDebounce()may be worth deleting entirely rather than shortening. The request is only issued after the debounce setscommittedQueryand React re-renders and react-query runs, so a sleep of exactlySEARCH_DEBOUNCE_MSis never sufficient on its own; at all five call sites the followingwaitForAssertionis what actually finishes the wait. It buys ~25 skipped assertion evaluations in exchange for a second wait concept in the file and a hard 250ms floor per call site. LettingwaitForAssertionpoll from t=0 would be simpler and no slower. The:139comment already concedes it is not load-bearing — that is a good argument for removing it.
Strengths
- The root-cause narrative at
:110-116is accurate and verifiable from the diff:flush()is one microtask drain plus asetTimeout(0), soattempts = 50bought roughly 50-100ms and the fixed 350ms sleep was carrying the test. Fixing the budget rather than raising the sleep is the correct call. - Deriving
ASSERTION_TIMEOUT_MSfrom the productionSEARCH_DEBOUNCE_MScouples the budget to the thing it must outlast, so raising the debounce cannot silently re-open this flake. That is the part that makes the fix durable. - The signature change from
attempts = 50totimeoutMs = ASSERTION_TIMEOUT_MSis safe: all 27 call sites pass a single argument, so no caller silently reinterprets an attempt count as milliseconds. - The loop always makes one final attempt at or after the deadline (
:124-131) before throwing, andlastErroris always assigned beforebreak— no window where it throwsundefined. - Shortening 350ms to 250ms cannot weaken the negative assertions:
expect(searchApiMock.search).not.toHaveBeenCalled()at:536is synchronous and runs beforesettleDebounce(), so it still asserts the debounce has not fired. - Nor can it cause a stale pass in the
mock.calls.at(-1)tests at:778and:824— the expected shape differs from the preceding call, so a not-yet-fired debounce fails the assertion and retries rather than matching the previous call. - Exporting
SEARCH_DEBOUNCE_MSis purely additive, and the module already exports a non-component helper (buildSearchUrl) that the test consumes. The repo ships no ESLint config, so there is noreact-refresh/only-export-componentsexposure to weigh here. ui/vitest.config.tssetstestTimeout: 30_000, which comfortably absorbs the new 2s worst case per assertion. A genuine regression will still surface the real assertion error rather than being masked by a suite timeout — worth confirming, since a larger budget under the stock 5s cap would have degraded diagnosability.
Recommended Action
- No Critical issues.
- No Important issues.
- Consider the two Suggestions opportunistically; neither blocks merge.
Thinking Path
Linked Issues or Issue Description
CopyTextauto-dismiss race in BLO-21603, but a different file and a different timer. Dedup search over open PRs found no other PR touchingui/src/pages/Search*.What Changed
waitForAssertionis now deadline-based with a real 10 ms per-attempt delay, replacing the attempt-count loop that had no wall-clock budget.ASSERTION_TIMEOUT_MS(SEARCH_DEBOUNCE_MS * 8) and the pre-assertion sleep both derive from the source constant, so raising the debounce cannot silently re-open this.setTimeout(resolve, 350)calls becamesettleDebounce();flush()'ssetTimeout(resolve, 0)is documented as a sound macrotask yield (a yield, not a wait for a specific timer).ui/src/pages/Search.tsx: the existingSEARCH_DEBOUNCE_MSconstant is now importable, plus a comment. No behavioural change.Verification
CPU contention could not serve as the negative control: at 48 spinners on 32 cores, vitest's worker startup times out (
Tests no tests) before the debounce is perturbed, so it never reproduces the assertion failure. I therefore reproduced the mechanism deterministically by raisingSEARCH_DEBOUNCE_MSso it outlasts the old fixed 350 ms sleep. If the helper had a real budget, these would still pass.SEARCH_DEBOUNCE_MS237500ca2)Number of calls: 0Number of calls: 0The pre-fix break between 400 and 500 quantifies the real budget: 350 ms sleep plus only ~50–100 ms of retry drain, against a 250 ms debounce. The failing signature matches the field failures exactly.
Note for the issue author (me): both commands in BLO-30247's Verifying signal —
--poolOptions.threads.singleThreadand--repeat— are rejected by vitest 4.1.8 as unknown options. The supported equivalents are above; I am correcting the issue.No UI change, so no screenshots.
Risks
Low. Test-harness only;
Search.tsxgains no runtime behaviour.Two things worth a reviewer's eye:
waitForAssertion's signature changed fromattempts = 50totimeoutMs. No call site passed the second argument (verified), so this is source-compatible — but it is a behavioural change for any future caller that assumes a count.ASSERTION_TIMEOUT_MS(2 s) to surface instead of failing in microseconds, so a real regression in this file reports slightly slower. Since the helper returns on first success, the longer budget cannot change which assertions pass — only how long a true failure takes to report.Model Used
Claude Opus 4.5 (
claude-opus-4-5), extended thinking, via Claude Code / Claude Agent SDK with tool use and code execution.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template🤖 Generated with Claude Code