Skip to content

Bulletproof Pre-game UI

Date: 2026-05-09 (closed) Status: Implemented Companion to: Bulletproof State Machine (UI) — paired UI plan that authored the master fixes this plan's test discipline locks against regression.

The Decision

Apply the backend's bulletproof discipline (Tier 4 e2e against the running stack, hook-level OTel telemetry, fail-loud writes, atomic state derivation) to the pre-game UI layer of apps/poker/. Stand up vitest + RTL + jsdom + Playwright for the first time in apps/poker/. Author Tier 4 scenarios against current master to baseline what's broken; fix forward; capture frontend testing practices for distillation.

The bug class this plan structurally prevents: "a pre-game UI surface (Sit Down, Stand Up, Top Up, Sign In, etc.) ships with a regression that no existing test catches." Every dispatcher behavior, wire-schema contract, error route, and affordance gate now has at least one structural test (typecheck OR vitest unit OR Tier 4 e2e).

Seven committed decisions

  1. Test infrastructure under apps/poker/ (vitest 2.1.9 + @testing-library/react 16 + jsdom 25), Tier 4 Playwright at project-level test/e2e/poker/pregame/. UI hook + Tier 1 test land in the same PR — every new framework or shared hook in apps/poker/src/{framework,shared}/ requires its Tier 1 vitest unit test in a sibling __tests__/ directory in the same PR. Reviewer rejects PRs that ship a hook without coverage.

  2. useGameDispatcher is the single point of ui.action.<type> instrumentation — no per-component opt-in. The dispatcher hook authors three span families: ui.action.<type> on every dispatch, ui.dispatch.rejected when a pre-flight gate rejects, ui.error.routed from ErrorRouter. Components consume the dispatcher; the dispatcher authors the spans. Sensitive payload fields (permit components, JWT, signatures) are stripped via filterSensitiveAttributes before attaching to span attributes.

  3. ErrorRouter is exhaustively typed against the wire schema. POKER_ERROR_CODES is a closed as const array in packages/types/src/wire/poker.ts; the Tier 1 test parameterizes over the union and asserts each has a route + non-fallback view + recovery hint. Plus an orphan-check asserts ROUTES contains no codes that aren't in the schema. Adding a new code anywhere fails the test in CI.

  4. Affordance discipline: invalid actions are absent from the DOM, not disabled. Sit Down doesn't render when balance is insufficient — a "Get Faucet Tokens" affordance renders in its place. Stand Up doesn't render when not seated. Sit In doesn't render when sitting_out + chipBalance == 0 — "Top up to rejoin" banner shows instead. Tier 2 RTL tests assert via queryByRole (absence) + getByRole (presence). Disabled-state assertions are a false positive — a button can be disabled=true and still be in the DOM with click handlers attached.

  5. Standalone Playwright for Tier 4 (NOT vitest-browser). Playwright runs tests in Node and remote-controls the browser, so pg-based DB cleanup hooks "just work" without needing the commands API indirection vitest-browser would require. Self-cleaning fixtures via the existing test/lib/psql helper. Wallet bridge via context.exposeFunction — sign typed-data and personal-sign run in Node where viem already lives; browser-side window.ethereum shim proxies via await window.__signTypedData__(...). HTTPS baseURL is required for Better Auth's CSRF guard + __Secure-* cookies — tests use a local HTTPS origin with ignoreHTTPSErrors.

  6. Sequential second-session consistency, NOT concurrent multi-tab fan-out. Game-server's pm.updateConnectionId rebinds the active connId to whoever sent enter last (last-writer-wins). True concurrent multi-tab broadcast fan-out would require server-side multi-conn-per-participant support — out of scope. The user-meaningful scenario IS testable: opening a second session AFTER the first sees consistent state via the initial game_state broadcast on connect.

  7. HTTP fallback is defense-in-depth, not optimistic UI. With withdraw-progress-notifier shipped (under BSM), the happy path has both the WS-driven intent slice AND the /api/withdraw HTTP response setting withdrawStatus="complete". The notifier's confirmed arrives before the HTTP response resolves; the HTTP set is a no-op overwrite on the success path. If the notifier (best-effort) drops a message, the HTTP fallback is the recovery path. Both signals reach the same end state.

Three master bugs surfaced + fixed during the lift

The Tier 4 e2e scenarios surfaced three real master bugs that no existing test caught:

  • WS reconnect leaves new connections unauthenticated. usePokerSocket.onOpen only fired enter when enteredRef === false; useGameSocket's connect() swap pattern nulled the old WS's onclose handler before closing it (to suppress reconnect chains), which also suppressed the user's onClose callback that resets enteredRef. After a reconnect, onOpen skipped enter, the new connection never bound to a player identity, sit_down landed as playerId:unknown, the seat was never created, admin-server's deposit succeeded on-chain anyway, and credit-chips looped with error:no_seat. Fix: onOpen always re-sends enter (game-server's handleEnter is idempotent — existing participant rebinds connId).
  • TableMember.stack missing on the wire. pm.toTableMember() returned available + inPlay but no stack field; the dashboard's affordance gate read myMember.stack. Late-joining clients (multi-tab, page reload, second device) received table_state on connect but no subsequent participant_update, so myMember.stack === undefined → ?? 0 → "no chips" banner even when chips were credited. Fix: add stack: p.chipBalance to toTableMember() + thread through buildMemberList + onConnect inline mapping + the TableMember interface.
  • DepositFlow surface unmounts before showing signing/confirming states. apps/poker/app/table/[tableId]/page.tsx's onSitDownWS clears chosenSeat synchronously when WS sit_down fires (before permit signing). Dashboard re-renders without pendingSeat → DepositFlow unmounts immediately. The 30-60s deposit confirm window has zero visible feedback. Real production UX bug, candidate for a follow-up plan.

What Shipped — Test Surface Map

Trajectory closure

41-row test trajectory:

  • 5 Tier 4 scenarios green (A1 + A2 + A3 + A4 + A8 + A10 = 6 actually, including A10): full happy path round-trip + sign-in routing + wallet-link routing + insufficient-balance gate + sequential second-session consistency + cross-service trace correlation.
  • 41 Tier 1/2 unit tests covering dispatcher hook + spans + ErrorRouter exhaustion + affordance gating + WS message handlers + state-machine drivers.
  • Subsumed (passing via cross-row coverage): T7 by T1, T13 by T1+T8.
  • Skipped with rationale (3): T5 + T6 (need server-side fixture work / Top Up affordance — follow-up plan candidates), T12 + T15 (structurally caught elsewhere — useGameState extraction + tsc respectively).

Final test suite: 93/93 vitest + 5 Tier 4 Playwright green.

Tradeoffs accepted

  • The plan ate part of bulletproof-state-machine. BSM and pregame-ui converged into the same effort viewed from different angles — BSM's master fixes shipped under BSM-banner commits, but pregame-ui's test infrastructure is what locks them against regression. The two plans are now archived as paired companions; the convention going forward (per BSM's retrospective) is to identify "we have two plans that are the same effort" upfront and merge.
  • Manual smoke compressed into Tier 4 + unit branches. T18-T21 + T28 manual-smoke trajectory rows were satisfied by Tier 4 e2e (A1 + A4 + A8 + A10) + DepositFlow.test.tsx state-branch coverage. Higher fidelity than manual smoke since Tier 4 ASSERTS rather than observes.
  • Browser-side OTLP-to-local-Jaeger deferred. The ui.action.<type> span emission is verified via T18 unit test (mock tracer). Wiring browser-side OTLP export so the UI span lands in Jaeger as the trace root is a config-shaped follow-up — instrumentation-client.ts currently uses @dash0/sdk-web → Dash0 cloud, not local OTel collector. T10 narrowed to server-side correlation (admin-server + tx-queue + game-server share a trace ID via Jaeger query); UI→server linkage in a single trace is the remaining gap.

Companion reads

  • Frontend Testing Guide — distilled patterns from the plan (Tier 1 dispatcher harness, mock OTel tracer, ErrorRouter exhaustion, affordance-presence Tier 2, Tier 4 wallet bridge + auth cookie injection + Jaeger trace assertion).
  • Bulletproof State Machine (UI) — the paired plan that authored the master fixes this plan's test discipline locks against regression.