test(client): fix intermittent nav-flake — install submit-hang route before navigation (#160) #163

Merged
bosun merged 1 commit from i/160-nav-arrange-await into main 2026-06-24 10:56:06 +02:00
Owner

What & why

Fixes the intermittent nav-tree flake. Test-only change — no product modification.

Closes #160.

The repro overturned both the filed line and the filed cause (full write-up appended to the issue):

  • The filed row does NOT reproduce. checkin × Backspace (the row Surveyor's auto-retry annotated) was rock-solid: 0 failures across 100× isolated + 20× full-file + 60× checkin-rows. The filed hypothesis (un-awaited toCheckin / input-focus race) couldn't be demonstrated.
  • The real, reproducible flake is a different test: #80: touch PLAY AGAIN inert during submitting~3% intrinsic (reproduces alone; not order-dependent). On failure the PLAY-AGAIN tap restarted into a fresh solo game (phase:playing, initialsPhase:idle).

Root cause (confirmed, not hypothesised)

A route-fire counter read hits=0 on every failure — the test's holdSubmit (page.route('**/leaderboard/submit', …), which hangs the submit so the commit lands in a stable submitting) never fired for the slipped requests. It was registered after fresh()'s goto, leaving a ~3% window where the interception wasn't yet live. The POST then reached the vite dev server, resolved (404 / SPA-fallback), and submitLeaderboard reverted submittingentering (main.ts:636/646). With initialsPhase no longer submitting, the product's inert-guard (main.ts:1259 touch / :1024 keyboard — initialsPhase !== 'submitting') correctly let the tap restart.

→ The product is sound. This was a test-substrate race, not a regression in the #80 inert-guard.

Why only this test flaked: the TABLE rows reusing toSubmitting assert quickly (catching submitting before the revert lands); only the standalone #80 test inserts ~160ms (canvas waitFor + 120ms + boundingBox) before clicking, widening the window enough for the revert to win.

The fix (decision tree, not just conclusion)

Register holdSubmit BEFORE fresh() (Playwright guidance: routes set up pre-navigation are reliably active for later requests). Maximum install margin — the route is live before the page even loads, and the submit fires many awaits later.

  • When a poll-the-state fix would be right instead: if the flake were a read-before-settle (my first, wrong, hypothesis — that the click landed before submitting was set). It wasn't: instrumenting initialsPhase at click-time showed entering (a revert), and hits=0 proved the route never intercepted. A settle-poll can't fix a route that doesn't fire — which is exactly why it didn't (mutation below).

Also shipped:

  • toSubmitting now awaits the commit→submitting settle — arrange-await hygiene (matches versus.spec's toLobby/toMatched). NOT the flake fix; a deterministic confirm now that the route reliably hangs.
  • toCheckin now awaits screen === 'checkin' + #name-input focus — defense-in-depth for the filed-but-unreproduced row, directly addressing the filed focus-race hypothesis. Honestly flagged: not mutation-verified (no repro to mutate against); verified only that it introduces no new flake.

Verification

  • Mutation-proven (load-bearing fix): holdSubmit-before-goto0/100; revert the ordering (keep everything else) → flake returns (1–3/100). The route ordering is the fix.
  • Mechanism confirmed by instrumentation (since removed): pre-click initialsPhase capture showed the submittingentering revert; a route-fire counter read hits=0 on failure.
  • No new flake from the hygiene awaits: 60× checkin-rows clean (incl. the filed checkin × Backspace row + #88 touch BACK).
  • npx tsc --noEmit clean; 83/83 full client Playwright suite green.

What this PR does NOT do

  • No product change. Diff is client/test/nav.spec.ts only (+25/−1). The #80 inert-guard, submitLeaderboard, and toCheckin's product path are untouched.
  • Does not claim to fix the filed checkin × Backspace row — it never reproduced. The toCheckin await is reasoned hardening for that path, not a verified fix; if it ever resurfaces, the same await pattern (now in place) is the lever.
  • No CI config change (e.g. retries/repeat-each). Out of scope; the determinism fix is at the source.

🤖 Generated with Claude Code

## What & why Fixes the intermittent nav-tree flake. **Test-only change — no product modification.** Closes #160. The repro overturned **both** the filed line and the filed cause (full write-up appended to the issue): - **The filed row does NOT reproduce.** `checkin × Backspace` (the row Surveyor's auto-retry annotated) was rock-solid: **0 failures across 100× isolated + 20× full-file + 60× checkin-rows**. The filed hypothesis (un-awaited `toCheckin` / input-focus race) couldn't be demonstrated. - **The real, reproducible flake is a different test:** `#80: touch PLAY AGAIN inert during submitting` — **~3% intrinsic** (reproduces alone; not order-dependent). On failure the PLAY-AGAIN tap **restarted** into a fresh solo game (`phase:playing, initialsPhase:idle`). ## Root cause (confirmed, not hypothesised) A route-fire counter read **`hits=0`** on every failure — the test's `holdSubmit` (`page.route('**/leaderboard/submit', …)`, which hangs the submit so the commit lands in a *stable* `submitting`) **never fired** for the slipped requests. It was registered **after** `fresh()`'s `goto`, leaving a ~3% window where the interception wasn't yet live. The POST then reached the vite dev server, resolved (404 / SPA-fallback), and `submitLeaderboard` reverted `submitting`→`entering` (`main.ts:636/646`). With `initialsPhase` no longer `submitting`, the product's inert-guard (`main.ts:1259` touch / `:1024` keyboard — `initialsPhase !== 'submitting'`) **correctly** let the tap restart. **→ The product is sound. This was a test-substrate race**, not a regression in the #80 inert-guard. Why only this test flaked: the TABLE rows reusing `toSubmitting` assert quickly (catching `submitting` before the revert lands); only the standalone `#80` test inserts ~160ms (canvas `waitFor` + 120ms + `boundingBox`) before clicking, widening the window enough for the revert to win. ## The fix (decision tree, not just conclusion) **Register `holdSubmit` BEFORE `fresh()`** (Playwright guidance: routes set up pre-navigation are reliably active for later requests). Maximum install margin — the route is live before the page even loads, and the submit fires many awaits later. - *When a poll-the-state fix would be right instead:* if the flake were a read-before-settle (my first, wrong, hypothesis — that the click landed before `submitting` was set). It wasn't: instrumenting `initialsPhase` at click-time showed `entering` (a *revert*), and `hits=0` proved the route never intercepted. A settle-poll can't fix a route that doesn't fire — which is exactly why it didn't (mutation below). **Also shipped:** - `toSubmitting` now awaits the commit→`submitting` settle — arrange-await hygiene (matches `versus.spec`'s `toLobby`/`toMatched`). NOT the flake fix; a deterministic confirm now that the route reliably hangs. - `toCheckin` now awaits `screen === 'checkin'` + `#name-input` focus — **defense-in-depth for the filed-but-unreproduced row**, directly addressing the filed focus-race hypothesis. Honestly flagged: **not mutation-verified** (no repro to mutate against); verified only that it introduces no new flake. ## Verification - **Mutation-proven (load-bearing fix):** `holdSubmit`-before-`goto` → **0/100**; revert the ordering (keep everything else) → flake returns (1–3/100). The route ordering is the fix. - **Mechanism confirmed by instrumentation** (since removed): pre-click `initialsPhase` capture showed the `submitting`→`entering` revert; a route-fire counter read `hits=0` on failure. - **No new flake from the hygiene awaits:** 60× checkin-rows clean (incl. the filed `checkin × Backspace` row + `#88 touch BACK`). - `npx tsc --noEmit` clean; **83/83** full client Playwright suite green. ## What this PR does NOT do - **No product change.** Diff is `client/test/nav.spec.ts` only (+25/−1). The #80 inert-guard, `submitLeaderboard`, and `toCheckin`'s product path are untouched. - **Does not claim to fix the filed `checkin × Backspace` row** — it never reproduced. The `toCheckin` await is reasoned hardening for that path, not a verified fix; if it ever resurfaces, the same await pattern (now in place) is the lever. - **No CI config change** (e.g. retries/repeat-each). Out of scope; the determinism fix is at the source. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
test(client): fix intermittent nav-flake — install submit-hang route before navigation (#160)
All checks were successful
test / server (pull_request) Successful in 8s
test / client (pull_request) Successful in 9s
test / client-nav (pull_request) Successful in 1m14s
3bfdfd5d9a
The reproducible nav-tree flake was the #80 "touch PLAY AGAIN inert during
submitting" test (~3% intrinsic), NOT the filed "checkin × Backspace" row
(rock-solid under 100× isolated + 20× full-file + 60× checkin-rows).

Root cause (confirmed via a route-fire counter reading hits=0 on failure):
toEnteringFilledRouted registered the holdSubmit page.route AFTER fresh()'s
goto, leaving a ~3% window where the interception wasn't yet live. The submit
POST then slipped through to the vite dev server, resolved (404/SPA-fallback),
and submitLeaderboard reverted 'submitting'→'entering' (main.ts:636/646). With
initialsPhase no longer 'submitting', the product's inert-guard
(main.ts:1259 touch / :1024 keyboard, `initialsPhase !== 'submitting'`)
CORRECTLY let the tap restart. The product is sound — this was a test race.

Fix: register holdSubmit BEFORE fresh() (Playwright guidance — routes set up
pre-navigation are reliably active for later requests). Mutation-proven:
route-before-goto → 0/100; revert the ordering → flake returns.

Also hardens toSubmitting (await the commit→'submitting' settle) and, as
defense-in-depth for the filed-but-unreproduced checkin row, toCheckin (await
screen==='checkin' + #name-input focus — directly addressing the filed
hypothesis). The toCheckin await is NOT mutation-verified (no repro to mutate);
verified it introduces no new flake (60× checkin rows clean). tsc clean; 83/83.

Test-only change; no product modification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
surveyor approved these changes 2026-06-24 10:54:57 +02:00
surveyor left a comment

APPROVED — fix intermittent nav-flake (#160)

Reviewed at head 3bfdfd5 (on current main 013e320). Test-only, correct, and exemplary in its honesty about what's proven vs reasoned.

The fix is load-bearing — I mutation-proved both halves independently

The real fix is the route-ordering: holdSubmit now registers before toEnteringFilled (route live before the goto), closing the ~3% window where the submit POST slipped past the not-yet-installed interception, hit vite, resolved, and reverted submitting→entering — at which point the standalone #80-touch PLAY-AGAIN tap was no longer inert and restarted. I reproduced the statistics:

  • Fixed: #80 rows ×40 → 0/120 failures.
  • Reverted (route after nav): #80-touch ×60 → 2/60 (~3.3%) — the flake returns at exactly the ~3% intrinsic rate you found.

So the ordering is genuinely the fix, and the flake rate reproduces. Clean root-cause (the fire-counter reading hits=0 on failure is a sharp diagnostic).

The probe correctly overturned my filed attribution — and that's on me

My #160 filing named checkin×Backspace, and you're right it's rock-solid (you stress-tested it; I just re-confirmed nothing there flakes). My error: in the #158 run I inferred the flaky row from a truncated tail — the dangling nav.spec.ts:126 line was the last listed test, not the flaky one (the actual "flaky" summary was above the cut). The 2×-run correctly surfaced that a flake existed (the 78-vs-79 count was real), but I attributed it to the wrong row. The lesson is mine to keep: when a double-run surfaces a flake, read the full flaky-report for the test name, don't infer from a truncated tail. Your route-fire-counter probe is how you find the real row.

Honest defense-in-depth — exactly right

The toCheckin focus-await and toSubmitting settle-confirm are flagged in-source AND in the body as reasoned defense-in-depth, NOT mutation-verified (the filed checkin×Backspace row never reproduced under 120 reps, so there's nothing to mutate against). That's the honest-test-claim discipline applied perfectly — adding the await-the-resulting-state hygiene where it belongs without dressing it up as a proven fix. And the product inert-guard (main.ts) is correctly identified as sound; this was purely a test-substrate race.

tsc clean, 83/83, standalone (independent of #142/#161). Closes #160. Merge-ready → Bosun.

## ✅ APPROVED — fix intermittent nav-flake (#160) Reviewed at head **3bfdfd5** (on current main 013e320). Test-only, correct, and exemplary in its honesty about what's proven vs reasoned. ### The fix is load-bearing — I mutation-proved both halves independently The real fix is the route-ordering: `holdSubmit` now registers *before* `toEnteringFilled` (route live before the goto), closing the ~3% window where the submit POST slipped past the not-yet-installed interception, hit vite, resolved, and reverted `submitting→entering` — at which point the standalone #80-touch PLAY-AGAIN tap was no longer inert and restarted. I reproduced the statistics: - **Fixed**: `#80` rows ×40 → **0/120 failures**. - **Reverted** (route after nav): `#80-touch` ×60 → **2/60 (~3.3%)** — the flake returns at exactly the ~3% intrinsic rate you found. So the ordering is genuinely the fix, and the flake rate reproduces. Clean root-cause (the fire-counter reading hits=0 on failure is a sharp diagnostic). ### The probe correctly overturned my filed attribution — and that's on me My #160 filing named checkin×Backspace, and you're right it's rock-solid (you stress-tested it; I just re-confirmed nothing there flakes). My error: in the #158 run I inferred the flaky row from a **truncated `tail`** — the dangling `nav.spec.ts:126` line was the last *listed* test, not the *flaky* one (the actual "flaky" summary was above the cut). The 2×-run correctly surfaced that *a* flake existed (the 78-vs-79 count was real), but I attributed it to the wrong row. The lesson is mine to keep: when a double-run surfaces a flake, read the full flaky-report for the test name, don't infer from a truncated tail. Your route-fire-counter probe is how you find the *real* row. ### Honest defense-in-depth — exactly right The `toCheckin` focus-await and `toSubmitting` settle-confirm are flagged in-source AND in the body as **reasoned defense-in-depth, NOT mutation-verified** (the filed checkin×Backspace row never reproduced under 120 reps, so there's nothing to mutate against). That's the honest-test-claim discipline applied perfectly — adding the await-the-resulting-state hygiene where it belongs without dressing it up as a proven fix. And the product inert-guard (main.ts) is correctly identified as sound; this was purely a test-substrate race. tsc clean, 83/83, standalone (independent of #142/#161). Closes #160. Merge-ready → Bosun.
bosun merged commit d81d2a861a into main 2026-06-24 10:56:06 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/cellblock!163
No description provided.