/** * P4.12 — E2E multiplayer scenario (Phase 4 gate) * * Two browser contexts create/join a room and play Scholar's Mate with * LIVE sync: every drag on one page appears on the other via server * `game.delta` broadcast. Ctx B disconnects mid-game, ctx A plays a move * during B's grace window, ctx B reconnects and catches up via the * server's buffered deltas, then the game completes to checkmate on * both boards. * * Architecture notes * ────────────────── * 1. Room create/join is driven directly via raw WebSocket inside * page.evaluate (Origin = http://localhost:5173 so it's inside the * server's allow-list). This matches what the Lobby UI does. * * 2. Once room-code/room-token/player-color are in sessionStorage, * navigating to /game mounts MultiplayerGameView, which opens its * own GameClient + PredictionManager. Moves are sent as `game.move` * and the server echoes `game.delta` to BOTH sockets, so each player * sees the opponent's moves live. * * 3. Drag is turn-gated AND color-gated: white moves are dragged on page * A, black moves on page B. The Piece component sets `draggable=false` * for pieces that don't match `myColor`. * * Server-level reconnect (seq tracking, game.state replay, game.delta * buffering) is unit-tested in packages/server/src/broadcast.test.ts. */ import { test, expect, type Page } from '@playwright/test'; import { spawn, type ChildProcess } from 'node:child_process'; import { setTimeout as sleep } from 'node:timers/promises'; // --------------------------------------------------------------------------- // Server lifecycle // --------------------------------------------------------------------------- let wsServerProcess: ChildProcess | null = null; async function isWsServerRunning(): Promise { try { const res = await fetch('http://localhost:7357/healthz'); return res.ok; } catch { return false; } } test.beforeAll(async () => { if (await isWsServerRunning()) return; // reuse existing (local dev) wsServerProcess = spawn('bun', ['run', 'packages/server/src/index.ts'], { stdio: 'pipe', env: { ...process.env, PORT: '7357' }, }); // Poll until ready (up to 5 s) for (let i = 0; i < 20; i++) { await sleep(250); if (await isWsServerRunning()) break; } }); test.afterAll(async () => { if (wsServerProcess) { wsServerProcess.kill('SIGINT'); await sleep(200); wsServerProcess = null; } }); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Drag a piece from `from` square to `to` square (algebraic notation). */ const drag = async (page: Page, from: string, to: string): Promise => { await page .locator(`[data-square="${from}"] [data-piece]`) .dragTo(page.locator(`[data-square="${to}"]`)); }; /** * Create a room via raw WebSocket from within the browser context. * Returns the room payload: { code, token, color }. */ async function wsCreateRoom( page: Page, options?: { preferredColor?: 'white' | 'black' | 'random' }, ): Promise<{ code: string; token: string; color: string }> { return page.evaluate(async (opts: { preferredColor?: 'white' | 'black' | 'random' } | undefined) => { return new Promise<{ code: string; token: string; color: string }>( (resolve, reject) => { const ws = new WebSocket('ws://localhost:7357/ws'); const timer = setTimeout( () => reject(new Error('wsCreateRoom: timeout')), 5000, ); ws.onopen = () => { const payload: Record = {}; if (opts?.preferredColor !== undefined) { payload.preferredColor = opts.preferredColor; } ws.send( JSON.stringify({ v: 1, seq: 1, ts: Date.now(), type: 'room.create', payload, }), ); }; ws.onmessage = (e: MessageEvent) => { const msg = JSON.parse(e.data as string) as { type: string; payload: { code: string; token: string; color: string; message?: string }; }; if (msg.type === 'room.created') { clearTimeout(timer); ws.close(); resolve(msg.payload); } else if (msg.type === 'error') { clearTimeout(timer); ws.close(); reject(new Error(msg.payload.message ?? 'room.create error')); } }; ws.onerror = () => { clearTimeout(timer); reject(new Error('wsCreateRoom: WebSocket error')); }; }, ); }, options); } /** * Join an existing room via raw WebSocket from within the browser context. * Returns the join payload: { code, token, color }. */ async function wsJoinRoom( page: Page, code: string, ): Promise<{ code: string; token: string; color: string }> { return page.evaluate(async (roomCode: string) => { return new Promise<{ code: string; token: string; color: string }>( (resolve, reject) => { const ws = new WebSocket('ws://localhost:7357/ws'); const timer = setTimeout( () => reject(new Error('wsJoinRoom: timeout')), 5000, ); ws.onopen = () => { ws.send( JSON.stringify({ v: 1, seq: 1, ts: Date.now(), type: 'room.join', payload: { code: roomCode }, }), ); }; ws.onmessage = (e: MessageEvent) => { const msg = JSON.parse(e.data as string) as { type: string; payload: { code: string; token: string; color: string; message?: string }; }; if (msg.type === 'room.joined') { clearTimeout(timer); ws.close(); resolve(msg.payload); } else if (msg.type === 'error') { clearTimeout(timer); ws.close(); reject(new Error(msg.payload.message ?? 'room.join error')); } }; ws.onerror = () => { clearTimeout(timer); reject(new Error('wsJoinRoom: WebSocket error')); }; }, ); }, code); } // --------------------------------------------------------------------------- // Test // --------------------------------------------------------------------------- test("multiplayer: two contexts, reconnect at move 7, Scholar's Mate checkmate", async ({ browser, }) => { const ctxA = await browser.newContext(); const ctxB = await browser.newContext(); const pageA = await ctxA.newPage(); const pageB = await ctxB.newPage(); // ── Step 1: ctx A navigates to home, creates a room via WebSocket ───────── await pageA.goto('http://localhost:5173/'); await pageA.waitForSelector('[data-testid="page-home"]'); const roomA = await wsCreateRoom(pageA); expect(roomA.code).toHaveLength(6); // Store session data (mirrors what Lobby.tsx does on room.created) await pageA.evaluate((r) => { sessionStorage.setItem('room-code', r.code); sessionStorage.setItem('room-token', r.token); sessionStorage.setItem('player-color', r.color); }, roomA); await pageA.goto('http://localhost:5173/game'); await expect(pageA.locator('[data-testid="turn-indicator"]')).toBeVisible(); // ── Step 2: ctx B navigates to home, joins the room via WebSocket ───────── await pageB.goto('http://localhost:5173/'); await pageB.waitForSelector('[data-testid="page-home"]'); const roomB = await wsJoinRoom(pageB, roomA.code); expect(roomB.code).toBe(roomA.code); await pageB.evaluate((r) => { sessionStorage.setItem('room-code', r.code); sessionStorage.setItem('room-token', r.token); sessionStorage.setItem('player-color', r.color); }, roomB); await pageB.goto('http://localhost:5173/game'); await expect(pageB.locator('[data-testid="turn-indicator"]')).toBeVisible(); // Both pages should now be connected to the server via their own // MultiplayerGameViews. Wait for each to show its color badge so we // know the initial game.state snapshot has arrived and the board is // interactive. await expect(pageA.locator('[data-testid="my-color"]')).toContainText('white'); await expect(pageB.locator('[data-testid="my-color"]')).toContainText('black'); // ── Step 3: Play moves 1–6 with live sync ───────────────────────────────── // // 9-move Scholar's Mate sequence, alternating sides: // 1. a2-a3 (white on A) // 2. h7-h6 (black on B) // 3. e2-e4 (white on A) // 4. e7-e5 (black on B) // 5. f1-c4 (white on A) Bc4 // 6. b8-c6 (black on B) Nc6 ← disconnect B after this move // 7. d1-h5 (white on A) Qh5 played while B is disconnected // ← reconnect B // 8. g8-f6 (black on B) Nf6?? — fatal Scholar's Mate blunder // 9. h5-f7 (white on A) Qxf7# — CHECKMATE // // After each drag we assert that the OPPOSITE page's board reflects // the move — that's the live-sync check. await drag(pageA, 'a2', 'a3'); // 1. white await expect(pageB.locator('[data-square="a3"] [data-piece="white-pawn"]')).toBeVisible(); await drag(pageB, 'h7', 'h6'); // 2. black await expect(pageA.locator('[data-square="h6"] [data-piece="black-pawn"]')).toBeVisible(); await drag(pageA, 'e2', 'e4'); // 3. white await expect(pageB.locator('[data-square="e4"] [data-piece="white-pawn"]')).toBeVisible(); await drag(pageB, 'e7', 'e5'); // 4. black await expect(pageA.locator('[data-square="e5"] [data-piece="black-pawn"]')).toBeVisible(); await drag(pageA, 'f1', 'c4'); // 5. white Bc4 await expect(pageB.locator('[data-square="c4"] [data-piece="white-bishop"]')).toBeVisible(); await drag(pageB, 'b8', 'c6'); // 6. black Nc6 await expect(pageA.locator('[data-square="c6"] [data-piece="black-knight"]')).toBeVisible(); // After 6 half-moves the turn returns to white. In multiplayer the // indicator is personalized: the white player (pageA) sees "Your // turn"; the black player (pageB) sees "Opponent's turn". await expect(pageA.locator('[data-testid="turn-indicator"]')).toContainText("Your turn"); await expect(pageB.locator('[data-testid="turn-indicator"]')).toContainText("Opponent's turn"); // ── Step 4: ctx B disconnects (simulates network drop) ─────────────────── await pageB.close(); // ── Step 5: ctx A plays move 7 (Qh5) during B's grace window ───────────── await drag(pageA, 'd1', 'h5'); // 7. white Qh5 await expect(pageA.locator('[data-square="h5"] [data-piece="white-queen"]')).toBeVisible(); // After Qh5 it's black to move. PageA is white → sees "Opponent's turn". await expect(pageA.locator('[data-testid="turn-indicator"]')).toContainText("Opponent's turn"); // ── Step 6: ctx B reconnects ───────────────────────────────────────────── // A new page in the SAME browser context inherits sessionStorage // (room-code, room-token, player-color). MultiplayerGameView opens a // new GameClient, sends `room.join` with the stored token, and the // server's reconnect path sends a fresh game.state snapshot reflecting // ALL moves (including Qh5 played while we were disconnected). const pageB2 = await ctxB.newPage(); // Re-plant sessionStorage on the new tab. sessionStorage is per-tab // per the browser spec, so ctxB.newPage() does NOT inherit pageB's // session storage — only localStorage. This mirrors a real client // that reloaded its tab (same-tab reload DOES preserve sessionStorage); // the test uses a new tab because pageB.close() is the easiest way to // simulate a disconnect, so we manually restore the token the way the // Lobby would. await pageB2.goto('http://localhost:5173/'); await pageB2.evaluate((r) => { sessionStorage.setItem('room-code', r.code); sessionStorage.setItem('room-token', r.token); sessionStorage.setItem('player-color', r.color); }, roomB); await pageB2.goto('http://localhost:5173/game'); await expect(pageB2.locator('[data-testid="turn-indicator"]')).toBeVisible(); // The snapshot should include Qh5 on h5. await expect(pageB2.locator('[data-square="h5"] [data-piece="white-queen"]')).toBeVisible(); // ── Step 7: Complete Scholar's Mate with live sync ─────────────────────── await drag(pageB2, 'g8', 'f6'); // 8. black Nf6?? await expect(pageA.locator('[data-square="f6"] [data-piece="black-knight"]')).toBeVisible(); await drag(pageA, 'h5', 'f7'); // 9. white Qxf7# — CHECKMATE await expect(pageA.locator('[data-testid="game-over"]')).toBeVisible(); await expect(pageB2.locator('[data-testid="game-over"]')).toBeVisible(); await ctxA.close(); await ctxB.close(); }); /** * Scope bug regression test. * * Before per-color preset scoping shipped, a client that enabled * `knights-leap-twice` locally would predict a double-leap move * (legal under the client's rule set), send it to the server, and * receive ILLEGAL_MOVE because the server's ChessEngine didn't know * the preset was active. The snap-back rolled the board back and the * user saw an error toast. * * With per-room authoritative rule state + `room.setPresets`, the * client dispatches the activation to the server, which broadcasts * `game.presets` to both players. The next move calc on both sides * uses the same rule set, so predictions and validations agree. * * The test specifically exercises `scope=white`: only white's knights * can double-leap, proving both that the server respects the preset * AND that the scope narrowing reaches the server. */ test('multiplayer presets: white-only knights-leap-twice applies to white, not black', async ({ browser, }) => { const ctxA = await browser.newContext(); const ctxB = await browser.newContext(); const pageA = await ctxA.newPage(); const pageB = await ctxB.newPage(); // 1. Room create/join. await pageA.goto('http://localhost:5173/'); const roomA = await wsCreateRoom(pageA); await pageA.evaluate((r) => { sessionStorage.setItem('room-code', r.code); sessionStorage.setItem('room-token', r.token); sessionStorage.setItem('player-color', r.color); }, roomA); await pageA.goto('http://localhost:5173/game'); await expect(pageA.locator('[data-testid="my-color"]')).toContainText('white'); await pageB.goto('http://localhost:5173/'); const roomB = await wsJoinRoom(pageB, roomA.code); await pageB.evaluate((r) => { sessionStorage.setItem('room-code', r.code); sessionStorage.setItem('room-token', r.token); sessionStorage.setItem('player-color', r.color); }, roomB); await pageB.goto('http://localhost:5173/game'); await expect(pageB.locator('[data-testid="my-color"]')).toContainText('black'); // 2. White enables knights-leap-twice with scope=white via the drawer. await pageA.locator('[data-action="open-rules-drawer"]').click(); const knightRow = pageA.locator('[data-preset="knights-leap-twice"]'); await knightRow.locator('[data-role="toggle"]').click(); // Default scope is 'both' — change to 'white'. await knightRow.locator('[data-role="scope-white"]').click(); await pageA.locator('[data-action="close-rules-drawer"]').click(); // 3. Wait for pageB to receive the `game.presets` broadcast and render // the active-preset count in its drawer pill. await expect(pageB.locator('[data-action="open-rules-drawer"]')).toContainText('1'); // 4. White drags g1 knight to d4 — a double-leap that's ONLY legal // with knights-leap-twice active. Before the fix this produced an // ILLEGAL_MOVE; after the fix the move is accepted. await drag(pageA, 'g1', 'd4'); await expect(pageA.locator('[data-square="d4"] [data-piece="white-knight"]')).toBeVisible(); await expect(pageB.locator('[data-square="d4"] [data-piece="white-knight"]')).toBeVisible(); // 5. Black attempts the same double-leap with its g8 knight. Preset // scope is white-only so this must NOT be legal for black. // `dragTo` still performs the mouse movement but the client's // legal-move set won't include it, so the board stays unchanged. await drag(pageB, 'g8', 'd5'); await expect(pageB.locator('[data-square="d5"]')).not.toContainText('black-knight'); // The knight should still be on g8. await expect(pageB.locator('[data-square="g8"] [data-piece="black-knight"]')).toBeVisible(); await ctxA.close(); await ctxB.close(); }); // --------------------------------------------------------------------------- // F1 (post-epic-deferrals): host color preference // --------------------------------------------------------------------------- test('F1 color preference: host picks black → host=black, joiner=white', async ({ browser, }) => { const ctxA = await browser.newContext(); const ctxB = await browser.newContext(); const pageA = await ctxA.newPage(); const pageB = await ctxB.newPage(); await pageA.goto('/'); await pageB.goto('/'); const createdA = await wsCreateRoom(pageA, { preferredColor: 'black' }); expect(createdA.color).toBe('black'); const joinedB = await wsJoinRoom(pageB, createdA.code); expect(joinedB.color).toBe('white'); expect(joinedB.code).toBe(createdA.code); await ctxA.close(); await ctxB.close(); }); test('F1 color preference: host picks random → both colors assigned disjointly', async ({ browser, }) => { const ctxA = await browser.newContext(); const ctxB = await browser.newContext(); const pageA = await ctxA.newPage(); const pageB = await ctxB.newPage(); await pageA.goto('/'); await pageB.goto('/'); const createdA = await wsCreateRoom(pageA, { preferredColor: 'random' }); expect(['white', 'black']).toContain(createdA.color); const joinedB = await wsJoinRoom(pageB, createdA.code); expect(['white', 'black']).toContain(joinedB.color); // Disjoint: exactly one is white, the other black — never the same. expect(joinedB.color).not.toBe(createdA.color); await ctxA.close(); await ctxB.close(); }); test('F1 color preference: default (no field) keeps legacy creator=white', async ({ browser, }) => { // Back-compat sanity: omitting `preferredColor` from the payload // must produce the pre-F1 hardcoded assignment. const ctxA = await browser.newContext(); const pageA = await ctxA.newPage(); await pageA.goto('/'); const createdA = await wsCreateRoom(pageA); // no options → no field on wire expect(createdA.color).toBe('white'); await ctxA.close(); }); // --------------------------------------------------------------------------- // F4b (post-epic-deferrals): game.action WS message // --------------------------------------------------------------------------- /** * Create a room with a preset list preloaded via raw `room.create`. * Mirrors `wsCreateRoom` but threads the ruleset ids so the * transferable-royalty action has an owning preset on both sides * from move 1 — no drawer interaction required. */ async function wsCreateRoomWithPresets( page: Page, rulesetIds: string[], ): Promise<{ code: string; token: string; color: string }> { return page.evaluate(async (ids: string[]) => { return new Promise<{ code: string; token: string; color: string }>( (resolve, reject) => { const ws = new WebSocket('ws://localhost:7357/ws'); const timer = setTimeout( () => reject(new Error('wsCreateRoomWithPresets: timeout')), 5000, ); ws.onopen = () => { ws.send( JSON.stringify({ v: 1, seq: 1, ts: Date.now(), type: 'room.create', payload: { rulesetIds: ids }, }), ); }; ws.onmessage = (e: MessageEvent) => { const msg = JSON.parse(e.data as string) as { type: string; payload: { code: string; token: string; color: string; message?: string; }; }; if (msg.type === 'room.created') { clearTimeout(timer); ws.close(); resolve(msg.payload); } else if (msg.type === 'error') { clearTimeout(timer); ws.close(); reject(new Error(msg.payload.message ?? 'room.create error')); } }; ws.onerror = () => { clearTimeout(timer); reject(new Error('wsCreateRoomWithPresets: WebSocket error')); }; }, ); }, rulesetIds); } /** * Send a `game.action` envelope over a fresh authenticated WebSocket * and return the first server message that either acks (via a * `game.state` broadcast) or rejects (via `error`). * * Opens its own socket per call because the e2e tests interact with * the multiplayer UI pages' sockets too — sharing one socket across * the test scope would race with the UI's PredictionManager. */ async function wsSendAction( page: Page, code: string, token: string, action: { kind: string; fromPieceId: number; toPieceId: number }, ): Promise<{ kind: 'state'; turn: string } | { kind: 'error'; code: string; message: string }> { return page.evaluate( async (args: { code: string; token: string; action: { kind: string; fromPieceId: number; toPieceId: number }; }) => { return new Promise< | { kind: 'state'; turn: string } | { kind: 'error'; code: string; message: string } >((resolve, reject) => { const ws = new WebSocket('ws://localhost:7357/ws'); const timer = setTimeout( () => reject(new Error('wsSendAction: timeout')), 5000, ); let joined = false; let actionSent = false; ws.onopen = () => { // Re-join with the stored token so the server recognises us. ws.send( JSON.stringify({ v: 1, seq: 1, ts: Date.now(), token: args.token, type: 'room.join', payload: { code: args.code }, }), ); }; ws.onmessage = (e: MessageEvent) => { const msg = JSON.parse(e.data as string) as { type: string; payload: Record; }; if (msg.type === 'room.joined' && !joined) { joined = true; // Don't fire action yet — wait for the initial game.state // snapshot that the server sends on join. return; } if (msg.type === 'game.state' && !actionSent) { // This is the initial state snapshot from join. Now fire // the action so the next game.state is the post-action one. actionSent = true; ws.send( JSON.stringify({ v: 1, seq: 2, ts: Date.now(), token: args.token, type: 'game.action', payload: { action: args.action }, }), ); return; } if (msg.type === 'game.state' && actionSent) { clearTimeout(timer); ws.close(); resolve({ kind: 'state', turn: msg.payload.turn as string }); return; } if (msg.type === 'error') { clearTimeout(timer); ws.close(); resolve({ kind: 'error', code: msg.payload.code as string, message: msg.payload.message as string, }); return; } }; ws.onerror = () => { clearTimeout(timer); reject(new Error('wsSendAction: WebSocket error')); }; }); }, { code, token, action }, ); } test('F4b game.action: transfer-royalty via wire succeeds and broadcasts to both clients', async ({ browser, }) => { const ctxA = await browser.newContext(); const ctxB = await browser.newContext(); const pageA = await ctxA.newPage(); const pageB = await ctxB.newPage(); // 1. Room with transferable-royalty preset active from the start. await pageA.goto('http://localhost:5173/'); const roomA = await wsCreateRoomWithPresets(pageA, ['transferable-royalty']); expect(roomA.color).toBe('white'); await pageB.goto('http://localhost:5173/'); const roomB = await wsJoinRoom(pageB, roomA.code); expect(roomB.color).toBe('black'); // 2. Probe the starting layout so we know which entity ids the engine // assigned to white's king (e1, square 4) and queen (d1, square 3). // A one-shot socket queries `game.state` via room.join. const startingFacts = await pageA.evaluate( async (args: { code: string; token: string }) => { return new Promise<{ id: number; attr: string; value: unknown }[]>( (resolve, reject) => { const ws = new WebSocket('ws://localhost:7357/ws'); const t = setTimeout(() => reject(new Error('timeout')), 5000); ws.onopen = () => ws.send( JSON.stringify({ v: 1, seq: 1, ts: Date.now(), token: args.token, type: 'room.join', payload: { code: args.code }, }), ); ws.onmessage = (e: MessageEvent) => { const msg = JSON.parse(e.data as string) as { type: string; payload: { facts?: { id: number; attr: string; value: unknown }[]; }; }; if (msg.type === 'game.state') { clearTimeout(t); ws.close(); resolve(msg.payload.facts ?? []); } }; ws.onerror = () => { clearTimeout(t); reject(new Error('ws error')); }; }, ); }, { code: roomA.code, token: roomA.token }, ); // Locate white king (e1=4) and queen (d1=3) ids via fact scan. const findPiece = ( facts: { id: number; attr: string; value: unknown }[], pieceType: string, square: number, ): number | null => { const typeIds = new Set( facts .filter((f) => f.attr === 'PieceType' && f.value === pieceType) .map((f) => f.id), ); const hit = facts.find( (f) => f.attr === 'Position' && f.value === square && typeIds.has(f.id), ); return hit ? hit.id : null; }; const whiteKing = findPiece(startingFacts, 'king', 4); const whiteQueen = findPiece(startingFacts, 'queen', 3); expect(whiteKing).not.toBeNull(); expect(whiteQueen).not.toBeNull(); // 3. White sends game.action: transfer royalty king→queen. Server // accepts and broadcasts a fresh game.state to both sockets. const whiteResult = await wsSendAction(pageA, roomA.code, roomA.token, { kind: 'transfer-royalty', fromPieceId: whiteKing!, toPieceId: whiteQueen!, }); expect(whiteResult.kind).toBe('state'); if (whiteResult.kind === 'state') { // Turn has flipped to black post-action (performAction consumes // the turn just like applyMove). expect(whiteResult.turn).toBe('black'); } // 4. Black tries the SAME action. The server rejects with // ILLEGAL_ACTION because white's transfer already consumed the // per-color slot in the preset's state (second transfer by same // color is REJECTED; using white's ids from black's socket also // can't work because black isn't that color). Either way the // wire surface must be ILLEGAL_ACTION — never a crash or silent // accept. const blackResult = await wsSendAction(pageB, roomA.code, roomB.token, { kind: 'transfer-royalty', fromPieceId: whiteKing!, toPieceId: whiteQueen!, }); expect(blackResult.kind).toBe('error'); if (blackResult.kind === 'error') { // Could be ILLEGAL_ACTION (INVALID_TARGET — can't transfer from // opponent's king) OR NOT_YOUR_TURN if ordering of the two socket // opens lands with the turn check first. Either is acceptable // F4b behaviour — both codes indicate "server refused" rather // than "silently accepted". expect(['ILLEGAL_ACTION', 'NOT_YOUR_TURN']).toContain(blackResult.code); } await ctxA.close(); await ctxB.close(); }); test('F4b game.action: malformed payload rejected pre-dispatch', async ({ browser, }) => { // Schema-level rejection: unknown action kind never reaches the // engine. Confirms the wire schema fails fast with INVALID_MESSAGE // (fatal) before the token check runs. const ctxA = await browser.newContext(); const pageA = await ctxA.newPage(); await pageA.goto('http://localhost:5173/'); const roomA = await wsCreateRoomWithPresets(pageA, ['transferable-royalty']); const result = await pageA.evaluate( async (args: { code: string; token: string }) => { return new Promise<{ code: string; fatal: boolean }>( (resolve, reject) => { const ws = new WebSocket('ws://localhost:7357/ws'); const t = setTimeout(() => reject(new Error('timeout')), 5000); let joined = false; ws.onopen = () => ws.send( JSON.stringify({ v: 1, seq: 1, ts: Date.now(), token: args.token, type: 'room.join', payload: { code: args.code }, }), ); ws.onmessage = (e: MessageEvent) => { const msg = JSON.parse(e.data as string) as { type: string; payload: { code?: string; fatal?: boolean }; }; if (msg.type === 'room.joined' && !joined) { joined = true; // Malformed action kind — schema union should reject. ws.send( JSON.stringify({ v: 1, seq: 2, ts: Date.now(), token: args.token, type: 'game.action', payload: { action: { kind: 'bogus-kind', fromPieceId: 1, toPieceId: 2, }, }, }), ); return; } if (msg.type === 'error') { clearTimeout(t); ws.close(); resolve({ code: msg.payload.code ?? '', fatal: msg.payload.fatal ?? false, }); } }; ws.onerror = () => { clearTimeout(t); reject(new Error('ws error')); }; }, ); }, { code: roomA.code, token: roomA.token }, ); expect(result.code).toBe('INVALID_MESSAGE'); expect(result.fatal).toBe(true); await ctxA.close(); }); test('F1 color preference: UI buttons render in Lobby and are toggleable', async ({ page, }) => { await page.goto('/'); // All three buttons exist. await expect( page.locator('[data-testid="color-preference-white"]'), ).toBeVisible(); await expect( page.locator('[data-testid="color-preference-black"]'), ).toBeVisible(); await expect( page.locator('[data-testid="color-preference-random"]'), ).toBeVisible(); // Default is white — aria-checked reflects radiogroup semantics. await expect( page.locator('[data-testid="color-preference-white"]'), ).toHaveAttribute('aria-checked', 'true'); await expect( page.locator('[data-testid="color-preference-black"]'), ).toHaveAttribute('aria-checked', 'false'); // Click black → now checked. await page.locator('[data-testid="color-preference-black"]').click(); await expect( page.locator('[data-testid="color-preference-black"]'), ).toHaveAttribute('aria-checked', 'true'); await expect( page.locator('[data-testid="color-preference-white"]'), ).toHaveAttribute('aria-checked', 'false'); // Click random → cycles again. await page.locator('[data-testid="color-preference-random"]').click(); await expect( page.locator('[data-testid="color-preference-random"]'), ).toHaveAttribute('aria-checked', 'true'); });