/** * T68 / T79 — Playwright E2E: request-choice round-trip flows * * Three scenarios that exercise the full request-choice → submit-choice * round-trip across the WebSocket boundary: * * 1. Single-player choice (mr_freeze descriptor) — PASS * Activate → request-choice modal appears → click column → * game proceeds with frozen-square markers visible. * * 2. Both-player choice (mind_control descriptor) — PASS * Activate (push two frames, one per chooser) → 2 browser * contexts (one per player) → both modals appear → each clicks → * game proceeds with conversions on both sides. * * 3. Nested choice (parry rule, RPS over capture) — TODO * Capture triggers RPS → both RPS modals → choices resolve → * conditional cancels capture if defender wins. * * The parry descriptor is `on-captured`, NOT `on-rule-activated`. * Wiring this up requires the move pipeline to fire `on-captured` * with a real registered (non-`__trigger__`) descriptor id AND * the broadcast layer to reverse the capture's `game.delta` when * `cancel-capture` runs. Both gaps are too invasive to patch * with a test-only shim. See "Outstanding gaps" below. * * ───────────────────────────────────────────────────────────────────── * Activation path (T79 test-only debug handler) * ───────────────────────────────────────────────────────────────────── * * The server has no production `activate-descriptor` action (gap E in * the original docstring); to drive Tests 1 & 2 we use a test-only * WebSocket message `__test__.activate-descriptor`. The handler lives * in `packages/server/src/broadcast.ts` (gated to NODE_ENV !== * "production") and: * * 1. Parses the descriptor (mr_freeze.json / mind_control.json). * 2. LIFTS the inner `on-rule-activated` arm so the * `request-choice` is the registered descriptor's top primitive. * That way `submitChoiceAndResume` can resolve the descriptor * by id and walk to `arm[0].params.then` — bypassing the * synthetic `__trigger__` descriptorId path used by the trigger * dispatcher. * 3. Sets `LastModifierChooser` to the requested chooser color. * 4. Pushes a PendingChoice frame and runs * `broadcastTopChoiceIfNew`. Both ends mirror the choice-timeout * unit tests (`pushPendingChoice` + `broadcastTopChoiceIfNew`). * * Click → submit-choice → submitChoiceAndResume → spawn markers / * convert pieces → broadcastGameStateSnapshot is the existing * production round-trip. * * ───────────────────────────────────────────────────────────────────── * Outstanding gaps (deferred work) * ───────────────────────────────────────────────────────────────────── * * F. No real server-side `activate-descriptor` action. * The T79 debug handler is test-only. A future production * affordance would add a PlayerActionWire kind for * activate-descriptor + server handler that calls the engine's * `applyCustomDescriptor` against GAME_ENTITY (or a chooser- * owned piece). The descriptor's apply walker also needs to * skip eager request-choice apply (deferred until the real * trigger fires) — see mr_freeze.test.ts § "Why we don't use * `applyCustomDescriptor`". * * G. Trigger-fired choices carry `descriptorId = "__trigger__"`. * The trigger dispatcher (`runPrimitives` in triggers.ts) * injects a synthetic placeholder; `submitChoiceAndResume` * can't resolve it on the engine's customModifiers registry. * Test 3 (parry / on-captured) needs the dispatcher to thread * the real owning descriptor id into the ctx. The plan * (mr_freeze.test.ts § "Future cleanup") flags this. * * H. Cancel-capture broadcast reversal. * When `cancel-capture` fires inside an `on-captured` arm, the * attacker's move was already broadcast as `game.delta`. The * broadcast layer needs to either suppress that delta until * the cascade completes OR emit a compensating revert delta. * Today the engine restores facts via LastCaptureSnapshot but * the wire-level rollback isn't wired. * * I. for-row / for-each-piece dispatcher double-recurse. * After the iteration primitive's apply() runs the inner * cascade with extended bindings, the dispatcher's child-walk * runs the children AGAIN with outer bindings → BindingError * on `$row` / `$piece` references. Test 1 happens to spawn * all 8 markers BEFORE the throw (correct outcome), and the * server's submit-choice handler swallows the post-spawn * BindingError with a logger.warn — so the test passes. A * real fix would skip childPrimitives() when the primitive * already iterated internally. * * ───────────────────────────────────────────────────────────────────── * Assertion ladders preserved * ───────────────────────────────────────────────────────────────────── * * The contract that the original spec pinned (column 4 → 8 frozen * markers; both contexts converted; defender wins → restore on both * boards) is preserved verbatim. The Test 3 `.todo()` keeps the * scenario authored so the Playwright report flags it as * outstanding work. */ import { test, expect, type Page } from '@playwright/test'; import { spawn, type ChildProcess } from 'node:child_process'; import { setTimeout as sleep } from 'node:timers/promises'; import { existsSync, mkdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; // --------------------------------------------------------------------------- // Server lifecycle (mirrors `multiplayer.spec.ts`) // --------------------------------------------------------------------------- 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; wsServerProcess = spawn('bun', ['run', 'packages/server/src/index.ts'], { stdio: 'pipe', env: { ...process.env, PORT: '7357' }, }); for (let i = 0; i < 40; i++) { await sleep(250); if (await isWsServerRunning()) break; } }); test.afterAll(async () => { if (wsServerProcess) { wsServerProcess.kill('SIGINT'); await sleep(200); wsServerProcess = null; } }); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const EVIDENCE_DIR = join(process.cwd(), '.sisyphus/evidence/task-68-screenshots'); if (!existsSync(EVIDENCE_DIR)) mkdirSync(EVIDENCE_DIR, { recursive: true }); const MR_FREEZE_DESCRIPTOR = JSON.parse( readFileSync( join(process.cwd(), 'packages/chess/src/__fixtures__/parity/mr_freeze.json'), 'utf8', ), ) as Record; const MIND_CONTROL_DESCRIPTOR = JSON.parse( readFileSync( join(process.cwd(), 'packages/chess/src/__fixtures__/parity/mind_control.json'), 'utf8', ), ) as Record; const PARRY_DESCRIPTOR = JSON.parse( readFileSync( join(process.cwd(), 'packages/chess/src/__fixtures__/parity/parry.json'), 'utf8', ), ) as Record; async function snapshot(page: Page, label: string): Promise { await page.screenshot({ path: join(EVIDENCE_DIR, `${label}.png`), fullPage: true, }); } async function wsCreateRoom( page: Page, ): Promise<{ code: string; token: string; color: string }> { return page.evaluate(async () => { 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 = () => { 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')); }; }, ); }); } 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); } async function joinAsHost( page: Page, ): Promise<{ code: string; token: string; color: string }> { await page.goto('http://localhost:5173/'); await page.waitForSelector('[data-testid="page-home"]'); const room = await wsCreateRoom(page); await page.evaluate((r) => { sessionStorage.setItem('room-code', r.code); sessionStorage.setItem('room-token', r.token); sessionStorage.setItem('player-color', r.color); }, room); await page.goto('http://localhost:5173/game'); await expect(page.locator('[data-testid="turn-indicator"]')).toBeVisible(); return room; } async function joinAsGuest( page: Page, code: string, ): Promise<{ code: string; token: string; color: string }> { await page.goto('http://localhost:5173/'); await page.waitForSelector('[data-testid="page-home"]'); const room = await wsJoinRoom(page, code); await page.evaluate((r) => { sessionStorage.setItem('room-code', r.code); sessionStorage.setItem('room-token', r.token); sessionStorage.setItem('player-color', r.color); }, room); await page.goto('http://localhost:5173/game'); await expect(page.locator('[data-testid="turn-indicator"]')).toBeVisible(); return room; } /** * T79 — drive the test-only `__test__.activate-descriptor` debug * frame from the browser. Opens a fresh raw WebSocket (with the * room's token in `ws.data` via `room.join`) so the server's room * lookup resolves; sends the debug frame; closes. The MultiplayerGame * client running in the same page is OBSERVING the same room and * receives the resulting `request-choice` broadcast on its own * socket. */ async function activateDescriptor( page: Page, args: { code: string; token: string; descriptor: unknown; chooserColor: 'white' | 'black'; liftedId?: string; }, ): Promise { // T79: route the test-debug frame through the GameClient that the // page's MultiplayerGameView already opened. The `__paratypeChessClient` // window hook is set in dev mode by `useMultiplayerGame` (gated on // `import.meta.env.DEV`), so this only works against the dev server. // The server's `__test__.*` fast-path strips the v1 envelope and // routes by `type`, so the wrapping in `client.send` is invisible // to the dispatcher. // // Going through the EXISTING client socket (instead of opening a // fresh one) means the broadcast can reach the same socket that's // observing for `request-choice` events — no cross-socket // bookkeeping needed. await page.waitForFunction( () => Boolean((globalThis as { __paratypeChessClient?: unknown }).__paratypeChessClient), null, { timeout: 5000 }, ); await page.evaluate((a) => { const client = (globalThis as { __paratypeChessClient?: { send: (msg: { type: string; payload: unknown }) => void }; }).__paratypeChessClient; if (!client) throw new Error('activateDescriptor: __paratypeChessClient not present'); client.send({ type: '__test__.activate-descriptor', payload: { roomCode: a.code, descriptor: a.descriptor, chooserColor: a.chooserColor, liftedId: a.liftedId, }, }); }, args); } /** * T83 — drive the test-only `__test__.seed-on-captured-hook` debug * frame. Seeds the parry descriptor's inner arm directly onto the * piece at `square` (resolved by 0..63 LERF index server-side). * * The handler: * 1. Parses the descriptor; rejects if its primitives[0] is not * `on-captured`. * 2. Registers a LIFTED descriptor whose primitives ARE the inner * arm so `submitChoiceAndResume` can walk to the request-choice * via `triggerPath: []` + `primitiveIndex: 0`. * 3. Inserts an `OnCapturedHooks` entry on the target piece with * the lifted descriptor's id (so the dispatcher threads it into * the PendingChoice frame at fire time — Wave 14 / Gap G * threading). * 4. Sets `ChoiceTimeoutPolicy: { mode: "no-timeout" }` so transient * WS disconnects mid-test don't auto-forfeit the room. */ async function seedOnCapturedHook( page: Page, args: { code: string; descriptor: unknown; /** 0..63 LERF index. d5 = 35, f7 = 53, e4 = 28. */ square: number; }, ): Promise { await page.waitForFunction( () => Boolean( (globalThis as { __paratypeChessClient?: unknown }) .__paratypeChessClient, ), null, { timeout: 5000 }, ); await page.evaluate((a) => { const client = ( globalThis as { __paratypeChessClient?: { send: (msg: { type: string; payload: unknown }) => void; }; } ).__paratypeChessClient; if (!client) throw new Error('seedOnCapturedHook: __paratypeChessClient not present'); client.send({ type: '__test__.seed-on-captured-hook', payload: { roomCode: a.code, descriptor: a.descriptor, square: a.square, }, }); }, args); } /** Drag a piece via the same UI path the multiplayer e2e uses. */ const drag = async (page: Page, from: string, to: string): Promise => { await page .locator(`[data-square="${from}"] [data-piece]`) .dragTo(page.locator(`[data-square="${to}"]`)); }; // --------------------------------------------------------------------------- // Test 1 — Single-player choice (mr_freeze) // --------------------------------------------------------------------------- test('T68/1 single-player choice: mr_freeze descriptor → column modal → frozen markers', async ({ browser, }) => { const ctx = await browser.newContext(); const page = await ctx.newPage(); const room = await joinAsHost(page); expect(room.color).toBe('white'); await snapshot(page, 'test1-pre-activation'); // Activate mr_freeze via the T79 debug WS frame. Chooser = white // (matching the unit-test seeding in mr_freeze.test.ts). await activateDescriptor(page, { code: room.code, token: room.token, descriptor: MR_FREEZE_DESCRIPTOR, chooserColor: 'white', liftedId: 'parity:mr_freeze__test1', }); // Modal appears with kind=column. const modal = page.locator('[data-testid="request-choice-modal"]'); await expect(modal).toBeVisible({ timeout: 5000 }); await expect(modal).toHaveAttribute('data-choice-kind', 'column'); await snapshot(page, 'test1-modal-open'); // Click column 4 (e-file). await modal.locator('[data-column="4"]').click(); await expect(modal).not.toBeVisible({ timeout: 5000 }); // 8 frozen markers on e-file. for (const square of ['e1', 'e2', 'e3', 'e4', 'e5', 'e6', 'e7', 'e8']) { await expect( page.locator(`[data-square="${square}"] [data-marker-kind="frozen-square"]`), ).toBeVisible({ timeout: 5000 }); } await snapshot(page, 'test1-post-resolve'); await ctx.close(); }); // --------------------------------------------------------------------------- // Test 2 — Both-player choice (mind_control) // --------------------------------------------------------------------------- test('T68/2 both-player choice: mind_control → 2 contexts → both modals → conversions', async ({ browser, }) => { const ctxA = await browser.newContext(); const ctxB = await browser.newContext(); const pageA = await ctxA.newPage(); const pageB = await ctxB.newPage(); const roomA = await joinAsHost(pageA); expect(roomA.color).toBe('white'); const roomB = await joinAsGuest(pageB, roomA.code); expect(roomB.color).toBe('black'); // Wait for both clients to settle (game.state arrived on both). await expect(pageA.locator('[data-testid="my-color"]')).toContainText('white'); await expect(pageB.locator('[data-testid="my-color"]')).toContainText('black'); // Push TWO request-choice frames so each player sees one. The // mind_control unit test (mind_control.test.ts) documents that V1 // "forPlayer: both" pushes a single frame; for the e2e contract // (each browser sees its own modal) we activate twice — once per // chooser — using distinct lifted ids so engine.customModifiers // holds two separate descriptor records and submitChoiceAndResume // can resolve each independently. // // Push order matters: white first, then black. After both pushes // black is the LIFO top, so its broadcast goes only to black. The // earlier white broadcast already routed to white. Each client // ends up with ONE entry in its pendingChoiceStack — its own. // Build a per-chooser variant of the mind_control descriptor: // - `forPlayer` on the request-choice routes the broadcast to // just the chooser's color. // - The set-piece-attr's `value` is BAKED to the chooser's // literal color (instead of the descriptor's runtime // `ctx-attr: { entity: "chooser" }` lookup). Two activate // calls in sequence overwrite `LastModifierChooser` to the // LATER chooser's color, so a runtime ctx-attr lookup at // resume time would resolve to the wrong color for the first // submit. Baking the color into the descriptor sidesteps that // ordering hazard for the e2e contract. const buildScopedMindControl = (forPlayer: 'white' | 'black') => { const root = (MIND_CONTROL_DESCRIPTOR['primitives'] as Array<{ kind: string; params: { primitives: Array<{ kind: string; params: Record }> }; }>)[0]!; const inner = root.params.primitives[0]!; const innerParams = inner.params as { kind: string; prompt: string; forPlayer: string; bind: string; then: Array<{ kind: string; params: Record }>; }; const setPieceAttr = innerParams.then[0]!; return { ...MIND_CONTROL_DESCRIPTOR, primitives: [ { kind: 'on-rule-activated', params: { primitives: [ { kind: 'request-choice', params: { ...innerParams, forPlayer, then: [ { ...setPieceAttr, params: { ...setPieceAttr.params, value: forPlayer, }, }, ], }, }, ], }, }, ], } as unknown as Record; }; await activateDescriptor(pageA, { code: roomA.code, token: roomA.token, descriptor: buildScopedMindControl('white'), chooserColor: 'white', liftedId: 'parity:mind_control__test2-white', }); // The second push goes through pageB's GameClient so the // server-side dispatcher sees both frames as discrete operations // — symmetrical with how a real `forPlayer="both"` arm would // surface to two clients. await activateDescriptor(pageB, { code: roomA.code, token: roomB.token, descriptor: buildScopedMindControl('black'), chooserColor: 'black', liftedId: 'parity:mind_control__test2-black', }); // Both modals visible (kind=piece). const modalA = pageA.locator('[data-testid="request-choice-modal"]'); const modalB = pageB.locator('[data-testid="request-choice-modal"]'); await expect(modalA).toBeVisible({ timeout: 5000 }); await expect(modalB).toBeVisible({ timeout: 5000 }); await expect(modalA).toHaveAttribute('data-choice-kind', 'piece'); await expect(modalB).toHaveAttribute('data-choice-kind', 'piece'); await snapshot(pageA, 'test2-modal-A'); await snapshot(pageB, 'test2-modal-B'); // Each player picks an enemy non-king piece by id. We pull the // piece id off the rendered board: white targets a black pawn on // e7 → its piece id is the EntityId stamped onto the // `[data-piece-id]` attribute on the Piece component. Because the // initial layout is deterministic (chess starting position is // seeded by ChessEngine), the ids are stable across runs. const e7PieceId = await pageA .locator('[data-square="e7"] [data-piece-id]') .first() .getAttribute('data-piece-id'); const e2PieceId = await pageB .locator('[data-square="e2"] [data-piece-id]') .first() .getAttribute('data-piece-id'); expect(e7PieceId).not.toBeNull(); expect(e2PieceId).not.toBeNull(); // Fill the piece-id input + submit. modalB is on top of the stack // server-side, so it must resolve first. After B submits, the // engine resumes set-piece-attr on the e2 white pawn → Color flips // to black. Then A's frame becomes the top; A submits, e7 pawn // flips to white. await modalB.locator('input[type="number"]').fill(String(e2PieceId)); await modalB.locator('button:has-text("Submit Piece ID")').click(); await expect(modalB).not.toBeVisible({ timeout: 5000 }); await modalA.locator('input[type="number"]').fill(String(e7PieceId)); await modalA.locator('button:has-text("Submit Piece ID")').click(); await expect(modalA).not.toBeVisible({ timeout: 5000 }); // Conversions visible on both sides. e7 pawn was black → now white; // e2 pawn was white → now black. The Piece component's data-piece // attribute follows the Color fact so it flips to the new color // identifier. await expect( pageA.locator('[data-square="e7"] [data-piece="white-pawn"]'), ).toBeVisible({ timeout: 5000 }); await expect( pageA.locator('[data-square="e2"] [data-piece="black-pawn"]'), ).toBeVisible({ timeout: 5000 }); await expect( pageB.locator('[data-square="e7"] [data-piece="white-pawn"]'), ).toBeVisible({ timeout: 5000 }); await expect( pageB.locator('[data-square="e2"] [data-piece="black-pawn"]'), ).toBeVisible({ timeout: 5000 }); await snapshot(pageA, 'test2-after-resolve-A'); await snapshot(pageB, 'test2-after-resolve-B'); await ctxA.close(); await ctxB.close(); }); // --------------------------------------------------------------------------- // Test 3 — Nested choice (parry rule, RPS over capture) — TODO // --------------------------------------------------------------------------- test('T68/3 nested choice: parry → capture triggers RPS → defender wins → cancel-capture', async ({ browser, }) => { // Wave 14 closed Gap G (descriptor-id threading) + Gap H // (broadcast revert / suppression while suspended). T83 (Wave 15) // closed the residual production gap that blocked this e2e: // // - `submitChoiceAndResume` now synthesizes a `capture` event // from `LastCaptureSnapshot` so `cancel-capture` (which // gates on `ctx.event.kind === "capture"`) doesn't throw at // resume time. // - `handleSubmitChoice` mirrors apply.ts stage 4b's cleanup // post-resume — when `cancel-capture` set // `CaptureCancelled = true`, the WS layer rolls back the // attacker, retracts the flag + snapshot, then broadcasts // a fresh `game.state` snapshot. Both clients see the // restored board (defender at original square, attacker // back at origin) without any intermediate post-capture // delta sneaking through. // // Drive path: // 1. Two contexts (white=A, black=B). Open the multiplayer // view on each; wait for game.state to settle. // 2. Move white queen to h5 (Qh5) and black knight to c6 to // reach a position where Qxf7 is legal AND the f7 piece is // a black pawn. The standard FIDE Scholar's-Mate prelude // delivers exactly that. // 3. Seed the parry on-captured hook on the f7 black pawn via // `__test__.seed-on-captured-hook` (T83). This bypasses // `applyCustomDescriptor` (which would eagerly fire the // inner request-choice at apply time, before the capture // event arrives) and matches the parry-real test's seeding // strategy. // 4. White plays Qxf7. The capture pipeline fires // `fireOnCapturedHooks` on the f7 pawn → request-choice // suspends → both clients see the rps modal. // 5. Each player submits a value (we use "rock" for both; // cancel-capture fires unconditionally inside // `conditional({type:"always"})`, mirroring the descriptor's // real semantics under the locked rps-eval simplification — // see `parity/parry.test.ts` § "Plan-spec deviation"). // 6. Post-resume `game.state` lands. Asserts: // - Black pawn back at f7 (defender restored). // - White queen NOT on f7 (attacker rolled back to h5). // - Both clients agree (state snapshot is authoritative). const ctxA = await browser.newContext(); const ctxB = await browser.newContext(); const pageA = await ctxA.newPage(); const pageB = await ctxB.newPage(); const roomA = await joinAsHost(pageA); expect(roomA.color).toBe('white'); const roomB = await joinAsGuest(pageB, roomA.code); expect(roomB.color).toBe('black'); await expect(pageA.locator('[data-testid="my-color"]')).toContainText('white'); await expect(pageB.locator('[data-testid="my-color"]')).toContainText('black'); // Drive a short prelude to set up a simple pawn capture e4xd5. // Using a quiet capture (NOT mate) so the parry cascade has // somewhere to land: the on-captured hook fires on the dying // d5 pawn → request-choice suspends → both clients see the // modal. A capture that ENDS the game (Scholar's Mate Qxf7#) // would race fireOnCapturedHooks against game.end and the // suspended choice's broadcast would be drowned by the // game-over signal. await drag(pageA, 'e2', 'e4'); await expect( pageB.locator('[data-square="e4"] [data-piece="white-pawn"]'), ).toBeVisible(); await drag(pageB, 'd7', 'd5'); await expect( pageA.locator('[data-square="d5"] [data-piece="black-pawn"]'), ).toBeVisible(); // Seed the parry hook on d5 (LERF index: rank 4 * 8 + file 3 = // 35). The descriptor's `on-captured` wrapper is unwrapped // server-side; the inner arm (request-choice → conditional → // cancel-capture) is what actually seeds onto d5. await seedOnCapturedHook(pageA, { code: roomA.code, descriptor: PARRY_DESCRIPTOR, square: 35, // d5 }); // Allow the seed's broadcast game.state to round-trip so the // hook is committed before the next inbound `game.move`. The // server-side handler emits a snapshot post-seed so the wait // is bounded by the natural WS RTT. await pageA.waitForTimeout(300); await snapshot(pageA, 'test3-pre-capture-A'); await snapshot(pageB, 'test3-pre-capture-B'); // Diagnostic: verify the hook landed on the d5 pawn. Reads the // engine's session via the dev-only PredictionManager export. // Pre-capture, OnCapturedHooks should be a non-empty array on // the d5 piece's entity id. const d5PieceId = await pageA .locator('[data-square="d5"] [data-piece-id]') .first() .getAttribute('data-piece-id'); expect(d5PieceId).not.toBeNull(); const hooks = await pageA.evaluate( (id) => { const mgr = ( globalThis as { __paratypeChessPrediction?: { getCurrentEngine: () => { session: { get: (id: unknown, attr: string) => unknown }; }; }; } ).__paratypeChessPrediction; if (!mgr) return null; return mgr.getCurrentEngine().session.get(id, 'OnCapturedHooks') ?? null; }, Number(d5PieceId), ); // The hook list MUST be present and non-empty — confirms the // seed-on-captured-hook handler attached to the right entity. expect(Array.isArray(hooks)).toBe(true); expect((hooks as unknown[]).length).toBeGreaterThan(0); // White plays e4xd5. Both clients should see the rps modal // (forPlayer="both" routes to both), NOT a post-capture board // delta (T81 broadcast suppression). await drag(pageA, 'e4', 'd5'); // Brief settle for the server's request-choice broadcast. await pageA.waitForTimeout(500); const modalA = pageA.locator('[data-testid="request-choice-modal"]'); const modalB = pageB.locator('[data-testid="request-choice-modal"]'); await expect(modalA).toBeVisible({ timeout: 5000 }); await expect(modalB).toBeVisible({ timeout: 5000 }); await expect(modalA).toHaveAttribute('data-choice-kind', 'rps'); await snapshot(pageA, 'test3-modal-A'); await snapshot(pageB, 'test3-modal-B'); // Both clients see the post-capture board SUPPRESSED — f7 still // shows the black pawn (it was transiently re-inserted by stage // 4 for hook reading; T81 doesn't broadcast the post-capture // delta while a choice is suspended). The white queen still // appears at h5 from the client's perspective (no game.delta // moving it to f7 was broadcast). Pre-T83/T81 these would have // already flipped to the post-capture state. // // Note: the queen at h5 + black pawn at f7 invariant relies on // the broadcast suppression — verifying it at THIS point of the // test is what keeps the contract honest. After the player // submits, the post-resume snapshot is the load-bearing pin // (see lines below). // Submit the rps value via the modal's UI. The descriptor's // `forPlayer: "both"` lets either player resolve the top frame; // V1 pushes a SINGLE PendingChoice for "both", so only one // submission is needed. Per LIFO discipline the first submit // drains the stack and the resume runs cancel-capture // unconditionally (the descriptor wraps cancel-capture in // `conditional({type:"always"})` — see parity/parry.test.ts). // Click "rock" on whichever modal we see first. The rps button // is data-rps="rock"; Modal renders three buttons (rock / // paper / scissors). await modalA.locator('[data-rps="rock"]').click(); await expect(modalA).not.toBeVisible({ timeout: 5000 }); // V1 doesn't yet broadcast a "choice resolved" frame — the // server pops the choice + broadcasts post-resume game.state, // but useMultiplayerGame's local pendingChoiceStack stays // populated on the non-submitter's client. The board state is // authoritative and reflects the resolution; the stale modal // is a documented V1 UX gap (deferred to a future "choice // dismiss" protocol message). The board-state assertions // below are the load-bearing pins for T68/3. // Post-resume assertions — the load-bearing pins for T68/3. // Both clients agree on the restored board: black pawn back at // d5, white pawn NOT on d5. The white pawn rolled back to e4 // (its origin square per the LastCaptureSnapshot's // attackerFromSquare). await expect( pageA.locator('[data-square="d5"] [data-piece="black-pawn"]'), ).toBeVisible({ timeout: 5000 }); await expect( pageB.locator('[data-square="d5"] [data-piece="black-pawn"]'), ).toBeVisible({ timeout: 5000 }); await expect( pageA.locator('[data-square="d5"] [data-piece="white-pawn"]'), ).toHaveCount(0); await expect( pageB.locator('[data-square="d5"] [data-piece="white-pawn"]'), ).toHaveCount(0); // White pawn rolled back to e4. await expect( pageA.locator('[data-square="e4"] [data-piece="white-pawn"]'), ).toBeVisible({ timeout: 5000 }); await expect( pageB.locator('[data-square="e4"] [data-piece="white-pawn"]'), ).toBeVisible({ timeout: 5000 }); await snapshot(pageA, 'test3-post-resolve-A'); await snapshot(pageB, 'test3-post-resolve-B'); await ctxA.close(); await ctxB.close(); }); // --------------------------------------------------------------------------- // Sentinel: server lifecycle + raw connectivity. Not gap-related. // --------------------------------------------------------------------------- test('T68 sentinel: server is reachable and home page renders', async ({ browser, }) => { const ctx = await browser.newContext(); const page = await ctx.newPage(); await page.goto('http://localhost:5173/'); await expect(page.locator('[data-testid="page-home"]')).toBeVisible(); const room = await wsCreateRoom(page); expect(room.code).toHaveLength(6); await snapshot(page, 'sentinel-page-home'); await ctx.close(); });