/** * T14 — Playwright e2e for the 9 NEW ThressGame-template recipes * added by T11/T12/T13 (see `recipes.ts` lines 539+). * * Coverage shape (matches the `T14 — Templates modal` brief): * * 1. Nine LOAD-AND-VALIDATE tests — one per recipe id. Mirrors the * pattern from `custom-modifiers.spec.ts § "Templates picker * loads a recipe into the editor"` (line 1459): open the Custom * Modifier Editor → click Templates → click the recipe's * `[data-testid="custom-template-{id}"]` button → assert the * modal closes, the descriptor name field reflects the recipe's * `descriptor.name`, and the validation footer reports "Valid * Custom Descriptor". * * 2. Three RUNTIME-BEHAVIOR tests — for the recipes whose * observable surface is most testable end-to-end: * * a. `tpl-religious-conversion` (on-move → for-each-adjacent * → set-piece-attr Color resolver): * Drives the cascade via `__test__.setup-board` (mirrors * `parity-religious.spec.ts` — the canonical religious_conversion * e2e). Pre-places a white bishop at d4 with adjacent black * pawns at a7/b7/c7; moves bishop d4→b6; asserts the * previously-black pawns now render as white. * * b. `tpl-kamikaze` (on-capture → with-probability(0.25) → * for-each-adjacent → destroy-piece): * Same `__test__.setup-board` pattern. To make the * probabilistic branch deterministic we clone the recipe's * descriptor and patch `with-probability.p` from 0.25 to * 1.0. This is the same approach `parity-religious.spec.ts` * already uses for the parity kamikaze fixture (the prior * T84 brief flags brittle seed-fishing as the wrong path). * Pre-places white queen at e2, target black pawn at e4, * AOE victims at d4 and f4, plus an immune black king at * e5. Captures via queen e2→e4 and asserts d4/f4 are * destroyed while e5 (king) survives. * * c. `tpl-mind-control` (on-rule-activated → request-choice * kind=piece, forPlayer=both): * Uses the simpler `__test__.activate-descriptor` lift — * the recipe's primitives[0].kind is on-rule-activated and * the inner arm's primitives[0].kind is request-choice, so * the lift handler accepts it. Asserts the * `[data-testid="request-choice-modal"]` becomes visible * with `data-choice-kind="piece"`. No further interaction * required for the runtime smoke. * * ───────────────────────────────────────────────────────────────────── * Driving infrastructure — verbatim duplication from sibling specs * ───────────────────────────────────────────────────────────────────── * * Per the precedent set in `orphan-primitives.spec.ts` and * `parity-religious.spec.ts`, e2e helpers are duplicated rather than * extracted into a shared module — Playwright's worker model loads * each spec file in isolation, and `e2e/` is in `testMatch` so a * shared module under `e2e/` would be treated as a test file. * * Helper script: * `.sisyphus/scripts/run-pw.sh /tmp/ ` (NEVER * set CI=true — it flips reuseExistingServer:false and collides * with the docker compose dev stack on :5173 / :7357). */ import { test, expect, type Page } from '@playwright/test'; import { spawn, type ChildProcess } from 'node:child_process'; import { setTimeout as sleep } from 'node:timers/promises'; // --------------------------------------------------------------------------- // LocalStorage / SessionStorage hygiene keys (mirror custom-modifiers.spec.ts) // --------------------------------------------------------------------------- const PROFILE_LIBRARY_KEY = 'houserules:modifier-profiles:v1'; const CUSTOM_LIBRARY_KEY = 'houserules:custom-modifiers:v1'; // --------------------------------------------------------------------------- // The 9 new recipe ids + canonical descriptor.name strings (sourced from // recipes.ts lines 547+). These are pinned constants — if the recipe // names change in recipes.ts, this map must be updated. // --------------------------------------------------------------------------- const NEW_RECIPE_IDS = [ 'tpl-religious-conversion', 'tpl-mr-freeze', 'tpl-mind-control', 'tpl-kamikaze', 'tpl-ice-physics', 'tpl-minefield-full', 'tpl-mass-destroyer-they-deserved-it', 'tpl-lifetime-restriction', 'tpl-adjacent-debuff', ] as const; type NewRecipeId = (typeof NEW_RECIPE_IDS)[number]; const RECIPE_NAMES: Record = { 'tpl-religious-conversion': 'Religious Conversion', 'tpl-mr-freeze': 'Mr Freeze', 'tpl-mind-control': 'Mind Control', 'tpl-kamikaze': 'Kamikaze', 'tpl-ice-physics': 'Ice Physics', 'tpl-minefield-full': 'Minefield (Spawn Arm)', 'tpl-mass-destroyer-they-deserved-it': 'They Deserved It', 'tpl-lifetime-restriction': '5-Turn Freeze', 'tpl-adjacent-debuff': 'Adjacent Debuff', }; // --------------------------------------------------------------------------- // Inline copies of the runtime-tested descriptors. Hard-coded here so // the spec doesn't need to import from chess source (which the e2e // runner doesn't bundle) and so any drift between the recipe and this // spec surfaces as a name-mismatch in test (a) above. // // These MUST stay in sync with `packages/chess/src/modifiers/custom/recipes.ts` // for the recipes: // - tpl-religious-conversion // - tpl-kamikaze // - tpl-mind-control // --------------------------------------------------------------------------- const RELIGIOUS_CONVERSION_DESCRIPTOR = { type: 'data', id: 'tpl-religious-conversion', name: 'Religious Conversion', description: 'Bishop converts adjacent enemy non-king pieces to its own color when it moves.', version: 1, primitives: [ { kind: 'on-move', params: { primitives: [ { kind: 'for-each-adjacent', params: { target: 'self', bind: 'adj', filter: { occupied: true, excludeKing: true }, then: [ { kind: 'set-piece-attr', params: { target: { $var: 'adj' }, attr: 'Color', value: { 'ctx-attr': { entity: 'self', attr: 'Color' }, }, }, }, ], }, }, ], }, }, ], targetAttrs: ['Color', 'OnMoveHooks'], uiForm: 'primitive-composer', source: 'custom', } as const; const KAMIKAZE_DESCRIPTOR_PATCHED = { type: 'data', // Distinct id so registry lookups don't collide with the canonical // recipe id. id: 'tpl-kamikaze__test-p1', name: 'Kamikaze (test, p=1.0)', description: 'Test variant: with-probability patched from 0.25 to 1.0 for deterministic AOE.', version: 1, primitives: [ { kind: 'on-capture', params: { primitives: [ { kind: 'with-probability', params: { p: 1.0, then: [ { kind: 'for-each-adjacent', params: { target: 'self', bind: 'adj', filter: { occupied: true, excludeKing: true }, then: [ { kind: 'destroy-piece', params: { target: { $var: 'adj' } }, }, ], }, }, ], }, }, ], }, }, ], targetAttrs: ['OnCaptureHooks'], uiForm: 'primitive-composer', source: 'custom', } as const; const MIND_CONTROL_DESCRIPTOR = { type: 'data', id: 'tpl-mind-control', name: 'Mind Control', description: 'Each player picks an enemy non-king piece and converts it to their color.', version: 1, primitives: [ { kind: 'on-rule-activated', params: { primitives: [ { kind: 'request-choice', params: { kind: 'piece', prompt: 'Mind Control — pick an enemy non-king piece to convert', forPlayer: 'both', bind: 'target', then: [ { kind: 'set-piece-attr', params: { target: { $var: 'target' }, attr: 'Color', value: { 'ctx-attr': { entity: 'chooser', attr: 'Color' }, }, }, }, ], }, }, ], }, }, ], targetAttrs: ['Color', 'OnRuleActivatedHooks'], uiForm: 'primitive-composer', source: 'custom', } as const; // --------------------------------------------------------------------------- // Server lifecycle (mirrors parity-religious.spec.ts — the dev stack // running in docker is detected via the healthz probe, so this beforeAll // is a no-op against a running dev container) // --------------------------------------------------------------------------- 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; } }); // --------------------------------------------------------------------------- // LOAD-PATH helpers (lobby → profile editor → custom-modifier editor). // Mirrors the helpers in `custom-modifiers.spec.ts` (lines 20-59). // --------------------------------------------------------------------------- async function freshLobby(page: Page): Promise { await page.goto('/'); await page.evaluate( ({ profileKey, customKey }) => { localStorage.removeItem(profileKey); localStorage.removeItem(customKey); sessionStorage.removeItem('room-code'); sessionStorage.removeItem('room-token'); sessionStorage.removeItem('player-color'); sessionStorage.removeItem('layout-name'); sessionStorage.removeItem('modifier-profile-name'); }, { profileKey: PROFILE_LIBRARY_KEY, customKey: CUSTOM_LIBRARY_KEY }, ); await page.reload(); } async function openProfileEditor(page: Page): Promise { const picker = page.getByTestId('profile-picker'); await expect(picker).toBeVisible(); await picker.selectOption('custom'); await expect( page .getByTestId('per-type-panel-paste') .or(page.locator('[role="dialog"], .fixed.inset-0').first()), ).toBeVisible({ timeout: 3000 }); } async function openCustomModifierEditor(page: Page): Promise { await page.getByTestId('open-custom-modifier-editor').click(); await expect(page.getByTestId('custom-modifier-editor')).toBeVisible({ timeout: 3000, }); } // --------------------------------------------------------------------------- // RUNTIME-PATH helpers (raw WS room creation + setup-board + activate / // apply-descriptor frames). Mirrors `parity-religious.spec.ts` and // `request-choice.spec.ts`. // --------------------------------------------------------------------------- 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 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(); await page.waitForFunction( () => Boolean( (globalThis as { __paratypeChessClient?: unknown }) .__paratypeChessClient, ), null, { timeout: 5000 }, ); return room; } interface BoardPlacement { square: string | number; type: 'pawn' | 'knight' | 'bishop' | 'rook' | 'queen' | 'king'; color: 'white' | 'black'; hasMoved?: boolean; handle?: string; } interface BoardHookSpec { pieceHandle?: string; pieceSquare?: string | number; hookAttr: | 'OnMoveHooks' | 'OnCaptureHooks' | 'OnCapturedHooks' | 'OnDamagedHooks' | 'OnPromotionHooks' | 'OnTurnStartHooks' | 'OnTurnEndHooks'; descriptor: unknown; descriptorIdOverride?: string; } interface BoardSetupArgs { code: string; clear?: boolean; clearIncludingKings?: boolean; placements?: BoardPlacement[]; rngSeed?: number; hooks?: BoardHookSpec[]; turn?: 'white' | 'black'; } async function setupBoard(page: Page, args: BoardSetupArgs): Promise { await page.evaluate(async (a) => { type Client = { send: (msg: { type: string; payload: unknown }) => void; readonly isConnected?: boolean; }; const getClient = (): Client | undefined => (globalThis as { __paratypeChessClient?: Client }).__paratypeChessClient; const deadline = Date.now() + 3000; let client = getClient(); while (Date.now() < deadline) { client = getClient(); if (client && client.isConnected === true) break; await new Promise((r) => setTimeout(r, 50)); } if (!client || client.isConnected !== true) { throw new Error('setupBoard: GameClient never became connected'); } client.send({ type: '__test__.setup-board', payload: { roomCode: a.code, clear: a.clear, clearIncludingKings: a.clearIncludingKings, placements: a.placements, rngSeed: a.rngSeed, hooks: a.hooks, turn: a.turn, }, }); }, args); } async function sendMove(page: Page, from: string, to: string): Promise { await page.evaluate( async (a) => { type Client = { send: (msg: { type: string; payload: unknown }) => void; sendMove?: (from: string, to: string) => void; readonly isConnected?: boolean; }; const getClient = (): Client | undefined => (globalThis as { __paratypeChessClient?: Client }) .__paratypeChessClient; const deadline = Date.now() + 3000; let client = getClient(); while (Date.now() < deadline) { client = getClient(); if (client && client.isConnected === true) break; await new Promise((r) => setTimeout(r, 50)); } if (!client || client.isConnected !== true) { throw new Error('sendMove: GameClient never became connected'); } if (typeof client.sendMove === 'function') { client.sendMove(a.from, a.to); return; } client.send({ type: 'game.move', payload: { from: a.from, to: a.to }, }); }, { from, to }, ); } async function activateDescriptor( page: Page, args: { code: string; descriptor: unknown; chooserColor: 'white' | 'black'; liftedId?: string; }, ): 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('activateDescriptor: __paratypeChessClient not present'); client.send({ type: '__test__.activate-descriptor', payload: { roomCode: a.code, descriptor: a.descriptor, chooserColor: a.chooserColor, liftedId: a.liftedId, }, }); }, args); } // --------------------------------------------------------------------------- // Test suite — load-and-validate × 9 + runtime × 3 // --------------------------------------------------------------------------- test.describe('T14 — Templates modal: ThressGame parity-faithful + new pattern recipes', () => { // ── 9 LOAD-AND-VALIDATE tests ──────────────────────────────────────── for (const id of NEW_RECIPE_IDS) { test(`loads ${id} into the editor without error`, async ({ page }) => { // Surface any page-side runtime errors in the test report rather // than letting them silently corrupt the editor state. const pageErrors: Error[] = []; page.on('pageerror', (err) => pageErrors.push(err)); await freshLobby(page); await openProfileEditor(page); await openCustomModifierEditor(page); // Open the templates modal. await page.getByTestId('custom-templates').click(); await expect(page.getByTestId('custom-templates-modal')).toBeVisible(); // Click the recipe's row. The CustomModifierEditor renders each // recipe with `data-testid="custom-template-${recipe.id}"` // (line 528 of CustomModifierEditor.tsx) — note the absence of // a `recipe-` infix; the prompt's mockup naming was incorrect. await page.getByTestId(`custom-template-${id}`).click(); // Modal closes after pick. await expect(page.getByTestId('custom-templates-modal')).toHaveCount(0); // The descriptor name field reflects the recipe's // `descriptor.name` — the same selector the existing // `Templates picker loads a recipe into the editor` test uses // (line 1477 of custom-modifiers.spec.ts). await expect( page.locator('input[placeholder="Modifier Name"]'), ).toHaveValue(RECIPE_NAMES[id]); // Footer reports the descriptor as VALID — the editor's // validation footer renders "Valid Custom Descriptor" when // `validationResult.ok` is true (CustomModifierEditor.tsx:485). // This subsumes the "no error toast" check the brief asks for — // a structural validation failure is the only path through which // a recipe load could surface an error toast at this stage. await expect( page.getByText('Valid Custom Descriptor'), ).toBeVisible(); // Page-side runtime check. expect(pageErrors).toEqual([]); }); } // ── RUNTIME TEST 1 — religious_conversion ───────────────────────────── test('tpl-religious-conversion: bishop move converts adjacent enemy non-king pieces', async ({ browser, }) => { const ctx = await browser.newContext(); const page = await ctx.newPage(); const room = await joinAsHost(page); expect(room.color).toBe('white'); // Synthetic board: clear the FIDE position (kings retained at e1/e8), // place a white bishop at d4 plus four pawns around the destination // square b6 (a6 ally, a7/b7/c7 enemies). On move d4→b6 the // for-each-adjacent walks the 8 squares around b6, narrows by // occupied + excludeKing → matches a6/a7/b7/c7. The set-piece-attr // Color resolver pulls self.Color (white) so: // - a7/b7/c7 (black pawns) flip to white // - a6 (white pawn) stays white (no-op) await setupBoard(page, { code: room.code, clear: true, clearIncludingKings: false, turn: 'white', placements: [ { square: 'd4', type: 'bishop', color: 'white', handle: 'bishop' }, { square: 'a6', type: 'pawn', color: 'white' }, { square: 'a7', type: 'pawn', color: 'black' }, { square: 'b7', type: 'pawn', color: 'black' }, { square: 'c7', type: 'pawn', color: 'black' }, ], hooks: [ { pieceHandle: 'bishop', hookAttr: 'OnMoveHooks', descriptor: RELIGIOUS_CONVERSION_DESCRIPTOR, }, ], }); // Pre-move pin: bishop at d4, black pawns at a7/b7/c7. await expect( page.locator('[data-square="d4"] [data-piece="white-bishop"]'), ).toBeVisible({ timeout: 5000 }); await expect( page.locator('[data-square="a7"] [data-piece="black-pawn"]'), ).toBeVisible(); await expect( page.locator('[data-square="b7"] [data-piece="black-pawn"]'), ).toBeVisible(); await expect( page.locator('[data-square="c7"] [data-piece="black-pawn"]'), ).toBeVisible(); await sendMove(page, 'd4', 'b6'); // Bishop landed at b6. await expect( page.locator('[data-square="b6"] [data-piece="white-bishop"]'), ).toBeVisible({ timeout: 5000 }); // The previously-black pawns are now white. await expect( page.locator('[data-square="a7"] [data-piece="white-pawn"]'), ).toBeVisible({ timeout: 5000 }); await expect( page.locator('[data-square="b7"] [data-piece="white-pawn"]'), ).toBeVisible(); await expect( page.locator('[data-square="c7"] [data-piece="white-pawn"]'), ).toBeVisible(); // Negative pin: NO black pawns remain on a7/b7/c7. await expect( page.locator('[data-square="a7"] [data-piece="black-pawn"]'), ).toHaveCount(0); await expect( page.locator('[data-square="b7"] [data-piece="black-pawn"]'), ).toHaveCount(0); await expect( page.locator('[data-square="c7"] [data-piece="black-pawn"]'), ).toHaveCount(0); await ctx.close(); }); // ── RUNTIME TEST 2 — kamikaze (deterministic p=1.0 variant) ─────────── test('tpl-kamikaze: capture triggers AOE destroying adjacent non-king pieces (king immune)', async ({ browser, }) => { const ctx = await browser.newContext(); const page = await ctx.newPage(); const room = await joinAsHost(page); expect(room.color).toBe('white'); // Patch the canonical recipe's with-probability(p=0.25) to p=1.0 // for deterministic AOE firing — same approach // `parity-religious.spec.ts § kamikazeAlwaysFires()` uses for the // parity fixture. A fixed seed alone is brittle: integration // preset hooks running on onAfterMove may consume RNG draws // before with-probability gets its turn, shifting the stream // offset. The probabilistic contract is pinned by // `kamikaze.test.ts` (locked-numerics); this e2e exercises the // dispatcher / WS / DOM path with deterministic outcome. await setupBoard(page, { code: room.code, clear: true, clearIncludingKings: true, turn: 'white', rngSeed: 42, placements: [ // Both kings re-placed (engine legality requirement). { square: 'e1', type: 'king', color: 'white' }, { square: 'h8', type: 'king', color: 'black' }, // Capturing piece + target. { square: 'e2', type: 'queen', color: 'white', handle: 'queen' }, { square: 'e4', type: 'pawn', color: 'black' }, // AOE victims adjacent to the queen's destination (e4). { square: 'd4', type: 'pawn', color: 'black' }, { square: 'f4', type: 'pawn', color: 'black' }, // King immunity: a black king adjacent to e4 must SURVIVE. { square: 'e5', type: 'king', color: 'black' }, ], hooks: [ { pieceHandle: 'queen', hookAttr: 'OnCaptureHooks', descriptor: KAMIKAZE_DESCRIPTOR_PATCHED, descriptorIdOverride: 'tpl-kamikaze__test-p1', }, ], }); // Pre-capture pin. await expect( page.locator('[data-square="e2"] [data-piece="white-queen"]'), ).toBeVisible({ timeout: 5000 }); await expect( page.locator('[data-square="e4"] [data-piece="black-pawn"]'), ).toBeVisible(); await expect( page.locator('[data-square="d4"] [data-piece="black-pawn"]'), ).toBeVisible(); await expect( page.locator('[data-square="f4"] [data-piece="black-pawn"]'), ).toBeVisible(); await expect( page.locator('[data-square="e5"] [data-piece="black-king"]'), ).toBeVisible(); // Capture: queen e2 → e4. e3 is empty post-clear so the slide is // legal. fireOnCaptureHooks runs the patched arm with p=1.0 → // walks adjacent → destroys d4/f4 → king on e5 survives the // excludeKing filter. await sendMove(page, 'e2', 'e4'); // Capture succeeded. await expect( page.locator('[data-square="e4"] [data-piece="white-queen"]'), ).toBeVisible({ timeout: 5000 }); // AOE destroyed d4 + f4. await expect(page.locator('[data-square="d4"] [data-piece]')).toHaveCount( 0, { timeout: 5000 }, ); await expect(page.locator('[data-square="f4"] [data-piece]')).toHaveCount( 0, ); // King immune. await expect( page.locator('[data-square="e5"] [data-piece="black-king"]'), ).toBeVisible(); await ctx.close(); }); // ── RUNTIME TEST 3 — mind_control (request-choice modal smoke) ──────── test('tpl-mind-control: activation surfaces a piece-kind request-choice modal', async ({ browser, }) => { const ctx = await browser.newContext(); const page = await ctx.newPage(); const room = await joinAsHost(page); expect(room.color).toBe('white'); // Lift the descriptor — its primitives[0] is on-rule-activated and // the inner arm's primitives[0] is request-choice (kind=piece, // forPlayer=both). The activate-descriptor handler synthesizes // the PendingChoice frame the trigger dispatcher would normally // push on activation, so the front-end's RequestChoiceModal renders // immediately. await activateDescriptor(page, { code: room.code, descriptor: MIND_CONTROL_DESCRIPTOR, chooserColor: 'white', liftedId: 'tpl-mind-control__lifted__test', }); const modal = page.locator('[data-testid="request-choice-modal"]'); await expect(modal).toBeVisible({ timeout: 5000 }); // Choice kind is `piece` per the descriptor. await expect(modal).toHaveAttribute('data-choice-kind', 'piece'); await ctx.close(); }); });