Appearance
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
Test infrastructure under
apps/poker/(vitest 2.1.9 + @testing-library/react 16 + jsdom 25), Tier 4 Playwright at project-leveltest/e2e/poker/pregame/. UI hook + Tier 1 test land in the same PR — every new framework or shared hook inapps/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.useGameDispatcheris the single point ofui.action.<type>instrumentation — no per-component opt-in. The dispatcher hook authors three span families:ui.action.<type>on every dispatch,ui.dispatch.rejectedwhen a pre-flight gate rejects,ui.error.routedfrom ErrorRouter. Components consume the dispatcher; the dispatcher authors the spans. Sensitive payload fields (permit components, JWT, signatures) are stripped viafilterSensitiveAttributesbefore attaching to span attributes.ErrorRouteris exhaustively typed against the wire schema.POKER_ERROR_CODESis a closedas constarray inpackages/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.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 bedisabled=trueand still be in the DOM with click handlers attached.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 thecommandsAPI indirection vitest-browser would require. Self-cleaning fixtures via the existingtest/lib/psqlhelper. Wallet bridge viacontext.exposeFunction— sign typed-data and personal-sign run in Node where viem already lives; browser-sidewindow.ethereumshim proxies viaawait window.__signTypedData__(...). HTTPS baseURL is required for Better Auth's CSRF guard +__Secure-*cookies — tests use a local HTTPS origin withignoreHTTPSErrors.Sequential second-session consistency, NOT concurrent multi-tab fan-out. Game-server's
pm.updateConnectionIdrebinds the active connId to whoever sententerlast (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 initialgame_statebroadcast on connect.HTTP fallback is defense-in-depth, not optimistic UI. With
withdraw-progress-notifiershipped (under BSM), the happy path has both the WS-driven intent slice AND the/api/withdrawHTTP response settingwithdrawStatus="complete". The notifier'sconfirmedarrives 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.onOpenonly firedenterwhenenteredRef === false;useGameSocket'sconnect()swap pattern nulled the old WS's onclose handler before closing it (to suppress reconnect chains), which also suppressed the user'sonClosecallback that resetsenteredRef. After a reconnect,onOpenskippedenter, the new connection never bound to a player identity, sit_down landed asplayerId:unknown, the seat was never created, admin-server's deposit succeeded on-chain anyway, and credit-chips looped witherror:no_seat. Fix:onOpenalways re-sendsenter(game-server'shandleEnteris idempotent — existing participant rebinds connId). TableMember.stackmissing on the wire.pm.toTableMember()returnedavailable+inPlaybut nostackfield; the dashboard's affordance gate readmyMember.stack. Late-joining clients (multi-tab, page reload, second device) receivedtable_stateon connect but no subsequentparticipant_update, somyMember.stack === undefined → ?? 0 → "no chips" bannereven when chips were credited. Fix: addstack: p.chipBalancetotoTableMember()+ thread throughbuildMemberList+onConnectinline mapping + the TableMember interface.- DepositFlow surface unmounts before showing signing/confirming states.
apps/poker/app/table/[tableId]/page.tsx'sonSitDownWSclearschosenSeatsynchronously when WS sit_down fires (before permit signing). Dashboard re-renders withoutpendingSeat→ 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 —
useGameStateextraction + 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.tscurrently 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.