fix(web): remove the wall-clock race in the import-JSON debounce guard test - #2255
Conversation
…d test `ServerImportJsonModal` > "guards against a live edit made before the debounce re-validates" opened its window on the real clock: it pasted invalid JSON and clicked Add Server, relying on less than VALIDATE_DEBOUNCE_MS (300ms) of wall time elapsing in between. When more did, the debounce landed first, `canAdd` went false, and the click hit a disabled button — a no-op that sets no submit error, so the `findByText(/Fix the validation errors/)` timed out. The first assertion (`onAddServer` not called) still passed, which is why the failure read as mysterious rather than as a disabled button. Run the test on fake timers end to end instead. The first validation is landed explicitly with `advanceTimersByTimeAsync(VALIDATE_DEBOUNCE_MS)` — imported from the hook, so the test cannot drift from the value it depends on — and after the second paste the timers are simply not advanced. The pending re-validation therefore cannot land at all, the window stays open by construction, and the final assertion becomes a synchronous `getByText`. Verified with a throwaway probe holding both shapes side by side with a deterministic 400ms stall injected between the paste and the click: the old shape fails exactly as reported, the new one passes. No timeout was widened and no production code changed. Closes #2250 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQgwZ1kGzhkdkMJ81JVg42 Signed-off-by: cliffhall <cliff@futurescale.com>
There was a problem hiding this comment.
🟢 Approval recommended
The focused test-only change deterministically exercises the intended pre-debounce submission window and safely restores real timers.
Pull request overview
Makes the import-JSON debounce guard test deterministic by controlling timer advancement.
Changes:
- Uses fake timers for the race-sensitive test.
- Advances the shared debounce duration explicitly.
- Replaces the asynchronous error lookup with a synchronous assertion.
File summaries
| File | Description |
|---|---|
clients/web/src/components/groups/ServerImportJsonModal/ServerImportJsonModal.test.tsx |
Removes reliance on wall-clock timing in the debounce guard test. |
Review details
- Files reviewed: 1/1 changed files
- Comments generated: 0
- Review effort level: Balanced
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
Determinism evidenceThe acceptance criterion asks for the test to pass inside a full parallel run, over several consecutive runs — not just in isolation. Five consecutive full
Four of the five ran at load 149–171 — higher than any run in the issue's evidence table (which topped out around 150, and where run 2 failed at load 50–126). The box was genuinely contended throughout: two other worktrees were running their own gates concurrently. Plus a full A note on the first gate attempt, since it is instructiveThe first Five tests failed: four with Non-causation is settled by the diff rather than by argument: this PR changes one file, a test file, and none of the five failing tests live in it. The On the second suspect named in the issueThe issue asked to check What the first gate run above adds is a useful data point in the other direction: under load, the |
Closes #2250
ServerImportJsonModal› guards against a live edit made before the debounce re-validates was a genuine timing race, not merely a slow-machine symptom, and it is fixed by removing the race rather than by widening a timeout.The race
The test needs the window between an edit and the
VALIDATE_DEBOUNCE_MS(300 ms) debounce that re-disables Add Server — that is the window the submit-time guard inuseServerJsonImport.submit()exists for. It opened that window on the real clock:If more than 300 ms of wall clock elapses between those two lines, the debounce lands first,
canAddgoes false, the button is disabled, andfireEvent.clickon a disabled button is a no-op.onAddServeris still not called — so the first assertion passes and hides the problem — butsubmit()never runs, nosubmitErroris set, andfindByText(/Fix the validation errors/)times out. That is exactly the reported failure, and it explains why run 4 in the issue failed on a quiet machine with the test itself taking only 1.6 s: it only needs one 300 ms scheduling gap, which parallel workers make likely without needing sustained load.Proof, before and after
A throwaway probe held both shapes side by side with a deterministic 400 ms stall injected between the paste and the click:
findByText(/Fix the validation errors/)times outThe old shape reproduces the reported failure exactly; the new shape is indifferent to the same stall, because the debounce cannot advance at all unless the test advances it.
The fix
Run that one test on fake timers end to end:
vi.useFakeTimers()before the render, restored in afinally, so no other test in the file is affected.await act(async () => vi.advanceTimersByTimeAsync(VALIDATE_DEBOUNCE_MS))rather than awaitForon the real clock —VALIDATE_DEBOUNCE_MSis imported from the hook, so the test cannot drift from the value it depends on.findByTextbecomes a synchronousgetByTextbecause there is nothing left to wait for.No timeout was widened and no production code changed — the guard being tested is unchanged.
On the second suspect
The issue asked to check
ServerSettingsModal› maps the OAuth insufficient-scope policy into settings (SEP-2350) before assuming the two are independent. They are independent: that test has no debounce and no timer window at all — it is auserEventclick sequence through a MantineSelect— and it failed once, on the run where the box was at load ~150 and the file took 90 787 ms. There is no analogous "state changes underneath the assertion after N ms" shape to remove there, so nothing in this PR touches it. If it recurs on a quiet machine it should get its own issue with that evidence.Verification
Full
npm run local:gatein a clean worktree, several consecutive runs — see the comment thread on the PR for the run log.🤖 Generated with Claude Code
https://claude.ai/code/session_01VQgwZ1kGzhkdkMJ81JVg42