The Determinism Inventory
A test is deterministic when nothing outside it can change its result. Your suite currently leaves four inputs to the environment. Three of them have a one-line fix; the fourth is the interesting one.
~18 min · after C1 · closes the Q4 blank
1. The term
Determinism, in Kent Beck's Test Desiderata framing: if nothing changes, the test result shouldn't change. It sits alongside isolation — same result regardless of run order — as one of twelve properties you trade against each other.
That framing matters because it names what retries: 3 is. Retries do not add determinism. They buy an appearance of it by paying in wall-clock time and in lost signal: a test that passes on attempt three is recorded as passing, and the nondeterminism it proved is discarded. Beck's rule is that you give up a property only to gain a more valuable one. Here you gave up determinism to gain… nothing. It was never a decision.
A test's result is a function of its inputs. Enumerate the inputs and you have the audit:
graph LR T[Test result] --- A[Test code] T --- B[App code] T --- C["⏱ Time"] T --- D["🌐 Network"] T --- E["🌍 Locale & timezone"] T --- F["🗄 Shared backend state"] T --- G["⚙️ Machine resources"] style C fill:#fee2e2,stroke:#dc2626 style D fill:#fee2e2,stroke:#dc2626 style E fill:#fee2e2,stroke:#dc2626 style F fill:#fef3c7,stroke:#d97706 style G fill:#fef3c7,stroke:#d97706 style A fill:#dcfce7,stroke:#16a34a style B fill:#dcfce7,stroke:#16a34a
Red is uncontrolled in your suite today. Amber is partially controlled — shared state by convention and guard fixtures (that's A2/A3), resources not at all. Green is the only part your existing conventions actually govern.
2. Locale and timezone: the cheapest fix in the repo
Your config sets reducedMotion and nothing else from Playwright's emulation surface. timezoneId and locale are unset, which means they come from the machine — your laptop in one place, the CI runner in another.
Now consider what the suite asserts. money-field.spec.ts, date-fields.spec.ts, multi-date-time-field.spec.ts, i18n/live-reformat.spec.ts. Every one of those asserts a string produced by Intl, and Intl reads its defaults from the environment.
use: {
baseURL: PREVIEW_URL,
locale: 'en-GB', // pin it — whatever the app should be tested under
timezoneId: 'Europe/Copenhagen',
contextOptions: { reducedMotion: 'reduce' },
}
This is a two-line change that converts a class of latent CI-vs-local disagreement into a config fact. It is worth doing even if it has never yet bitten you, because the failure it prevents is one that looks like a product bug.
Second-order point. Pinning the timezone also changes what your date tests mean. A test that only passes in Europe/Copenhagen is not testing date handling, it is testing date handling in one timezone — which may be correct and deliberate. Pinning makes that a stated choice rather than an accident, and it makes "add a second project with timezoneId: 'Pacific/Kiritimati'" a five-minute experiment.
3. TanStack Query: the defaults are a nondeterminism list
You said "don't know" on this one, so here is the mechanism rather than the answer. TanStack's documented defaults, read as an audit:
| staleTime: 0 | Query results are considered stale immediately. Nothing is ever served from cache without also being refetched. |
| refetch triggers | Stale queries refetch in the background when a new instance mounts, the window is refocused, or the network reconnects. All three are things a browser test causes incidentally. |
| retry: 3 | A failed query retries three times with exponential backoff. So a single transient error becomes seconds of silent delay — inside a test whose expect.timeout is 5 s. |
| gcTime: 5 min | Unused cache entries are collected after five minutes — longer than any test, so within a test the cache only grows. |
Compose the first two and you get the concrete answer to the question you couldn't answer:
The background-refetch race, step by step
- Test navigates to the products table. The query mounts and fetches. Rows render.
- Test asserts row count is 20. Passes.
- Something remounts the query component — a filter toggle, a route change, a panel opening. Because
staleTimeis 0, that mount triggers a background refetch. - The refetch is in flight while the test's next assertion runs. TanStack keeps serving the old data until it resolves, then swaps.
- If a parallel worker inserted a row in between, the swap changes the row count underneath a passing assertion. The next line fails, pointing at innocent code.
Note what this is in the taxonomy from A1: it presents as async-wait, but the cause is concurrency over shared backend state, with the cache as the delay line that makes the two events overlap. Misfiling it costs you the fix.
4. Why app-idle absorbs this rather than fixing it
Your fixtures/app-idle.ts holds the test open at teardown until the app stops fetching, mutating and navigating. That is genuinely good engineering, and it solves a real problem: a request outliving its test and landing during the next one.
But look at when it acts. It is a teardown guard. It protects the next test from this test's in-flight work. It does nothing about a refetch that starts and lands in the middle of the current test, between two assertions — which is exactly the race above.
Converts cross-test contamination from an invisible flake into a visible wait. A correctness guarantee at the boundary.
Stop an intra-test refetch from changing state between two assertions. There is no boundary there to guard.
The word for the distinction is worth having: app-idle gives you quiescence at the boundary, not quiescence during. The fix for intra-test races is not a better guard, it is removing the reason a refetch happens at all — and that is a product config question (a non-zero staleTime for read-heavy lists), not a test one.
5. Time: the lever with a trap in it
page.clock installs a fake clock that intercepts Date, Date.now, setTimeout, setInterval, performance.now — and requestAnimationFrame. Zero uses in your suite.
Read that list against your suite and the trap is immediate. Your canvas gestures depend on real animation frames. dragBetween steps pointer moves across rAFs precisely so Gesto and Moveable register them; the actionability stable check from C1 needs two consecutive frames. Install a fake clock and both stop advancing unless you drive them.
| Spec shape | Clock? |
|---|---|
| Debounced search input; unsaved-changes timer; session expiry; "3 minutes ago" labels | Yes — setFixedTime or fastForward. This is what it is for. |
Keyframe/animation playback assertions (active-frame-plays-alone) | Carefully — pauseAt + explicit runFor turns a load-sensitive test into a deterministic one, but you now own frame advancement. |
Any spec routing through lib/editor gestures | No. The gesture helpers and the stable check both depend on real rAF. |
One more trap, from the fake-timers engine underneath: advancing a clock does not flush promises. Fast-forwarding past a debounce fires the timer, but the await chain it kicks off still needs microtask turns. In a Playwright test your web-first assertion polls, so this mostly resolves itself — which is exactly why the bug, when it appears, is baffling.
6. Network: what you cannot currently test
Four route calls across the whole suite. The consequence isn't flakiness — it is a coverage hole shaped exactly like the real world:
- A 500 from an endpoint the user is mid-form on. A healthy backend will not produce one on request.
- A request that never resolves — the loading state that stays.
tables/loading-state.spec.tsexists; how does it produce a slow response without a hard wait? route.abort()for offline. Note your own convention: an aborted request firesrequestfailed, notresponse, sobackendErrorGuardneeds no declaration — which means the guard cannot see it.- An optimistic update whose mutation then fails. This is the bug a manually-handled cache actually ships, and it is unreachable without failing a request on demand.
The vocabulary, from Fowler's Mocks Aren't Stubs: a route.fulfill handler is a stub — canned answers, verified by state. Asserting that a request was made would be behaviour verification with a mock. Your suite's black-box rule is untouched by either: intercepting at the network boundary is still outside the app.
7. Bug hunt
Click the line that makes each test nondeterministic — or "no bug". One of the four is clean.
8. Free recall
Without scrolling: name the environmental inputs a browser test's result depends on, beyond the test code and the app code.
Time · Network · Locale & timezone · Shared backend state · Machine resources
Bonus if you added randomness (unseeded test data) or collection order (a query with no ORDER BY — which your findSeededProduct already trips over, per the A2 finding).
9. What this buys you this week
- Pin
localeandtimezoneIdinplaywright.config.ts. Two lines, removes a whole input. - Find out what
staleTimethe app sets for the products and asset-library lists. If it is 0, you have located the intra-test refetch race — and the fix lives in the app, not the test. - Write one spec that could not exist before:
route.fulfilla 500 on a product save and assert the optimistic update rolls back. Declare it throughbackendErrorGuard. - Add to
e2e/CLAUDE.md:page.clockis forbidden in any spec that useslib/editorgestures, because it fakesrequestAnimationFrame. Write the reason down before someone discovers it the hard way.
press skips.STATUS · sources: TanStack — Important Defaults, Playwright — Clock, Emulation, Beck — Test Desiderata