/** * W4.10 — Playwright e2e for the 5 NEW Wave-4 thressgame-100 recipes * shipped by W4.8-W4.9 (see `recipes.ts` lines 2659-2904). * * Coverage shape (mirrors `wave3-choices.spec.ts`, scaled to the * Wave-4 batch): * * 1. Five LOAD-AND-VALIDATE tests — one per recipe id. Same pattern * as the Wave-3 file: open the Custom Modifier Editor → click * Templates → click the recipe's `[data-testid="custom-template-{id}"]` * row → assert the modal closes, the descriptor name field * reflects the recipe's `descriptor.name`, and the validation * footer reports "Valid Custom Descriptor". * * 2. Two RUNTIME-BEHAVIOR tests: * * Batch K — `tpl-bouncing-ricochet` (smoke runtime): * Apply the descriptor through the page's GameClient via * `__test__.apply-descriptor`. The activation arm seeds * `BoardTopology = "wrap-files"` on GAME_ENTITY (id=0). * We probe via the dev-only `__paratypeChessPrediction` * PredictionManager — same hook `parity-rules.spec.ts` * uses to read engine session attrs. SMOKE depth: the * full topology runtime contract (sliding rays wrap, etc.) * is pinned by `wave4-recipes-real.test.ts` and * `rules/topology.test.ts`. The e2e only verifies the * descriptor seeds the game-level attr through the live * server pipeline. * * Batch L — `tpl-hot-drop` (full runtime): * Apply the descriptor; the activation arm contains 2 * `place-piece` primitives that spawn white queens on * e4 (sq=28) and d4 (sq=27). The DOM updates via the * server's `game.state` snapshot post-apply. We then * assert ≥1 white-queen DOM node is rendered at each * square via `[data-square="e4"] [data-piece="white-queen"]` * and the d4 equivalent. WALKER-DOUBLE-FIRE artifact: * `applyCustomDescriptor`'s walker recurses into * `on-rule-activated.childPrimitives()` AT APPLY TIME and * then `fireOnRuleActivatedHooks` runs the same arm * post-walk, so non-idempotent primitives (place-piece) * fire TWICE. We assert `>=1` rather than `==1` per * square (in practice the rendered count is 1 because * `place-piece` writes `Position` to a fresh entity id * each call, so a double-fire yields 4 queens distributed * 2-per-square — but the DOM only renders ONE piece per * square; the over-counted entities are effectively * invisible). The contract proven: spawn AND link * cascade WIRING is reachable through the live pipeline. * * ───────────────────────────────────────────────────────────────────── * Driving infrastructure * ───────────────────────────────────────────────────────────────────── * * Per the precedent set in `wave1-recipes.spec.ts`, * `wave2-countdowns.spec.ts`, and `wave3-choices.spec.ts`, e2e * helpers are duplicated rather than extracted into a shared module * — Playwright's worker model loads each spec in isolation and `e2e/` * is in `testMatch` so a shared module under `e2e/` would itself 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 prior wave specs) // --------------------------------------------------------------------------- const PROFILE_LIBRARY_KEY = 'houserules:modifier-profiles:v1'; const CUSTOM_LIBRARY_KEY = 'houserules:custom-modifiers:v1'; // --------------------------------------------------------------------------- // The 5 new W4 recipe ids + canonical descriptor.name strings (sourced from // recipes.ts lines 2659-2904). Pinned constants — must stay in sync with // `descriptorForRecipe(id, name, ...)` in recipes.ts. // --------------------------------------------------------------------------- const NEW_RECIPE_IDS = [ // Batch K — topology (2) 'tpl-pacman-style-cross-ref', 'tpl-bouncing-ricochet', // Batch L — pairing (3) 'tpl-down-with-the-ship', 'tpl-soul-link', 'tpl-hot-drop', ] as const; type NewRecipeId = (typeof NEW_RECIPE_IDS)[number]; const RECIPE_NAMES: Record = { 'tpl-pacman-style-cross-ref': 'Pacman-Style Cross-Ref', 'tpl-bouncing-ricochet': 'Bouncing Ricochet', 'tpl-down-with-the-ship': 'Down With the Ship', 'tpl-soul-link': 'Soul Link', 'tpl-hot-drop': 'Hot Drop', }; // --------------------------------------------------------------------------- // Inline copies of the 2 runtime-tested descriptors. Hard-coded here so the // spec doesn't need to import from chess source (the e2e runner doesn't // bundle TS). Drift surfaces as a name-mismatch in load-and-validate above. // // MUST stay in sync with `packages/chess/src/modifiers/custom/recipes.ts`: // - tpl-bouncing-ricochet (lines 2685-2717) // - tpl-hot-drop (lines 2833-2903) // --------------------------------------------------------------------------- const BOUNCING_RICOCHET_DESCRIPTOR = { type: 'data', id: 'tpl-bouncing-ricochet', name: 'Bouncing Ricochet', description: 'While this rule is active, files wrap (a-1 ↔ h, h+1 ↔ a). On rule expiry the topology reverts to standard — wraparound is scoped to the rule\'s lifetime.', version: 1, primitives: [ { kind: 'on-rule-activated', params: { primitives: [ { kind: 'set-board-topology', params: { value: 'wrap-files' }, }, ], }, }, { kind: 'on-rule-expire', params: { primitives: [ { kind: 'set-board-topology', params: { value: 'standard' }, }, ], }, }, ], targetAttrs: [], uiForm: 'primitive-composer', source: 'custom', } as const; const HOT_DROP_DESCRIPTOR = { type: 'data', id: 'tpl-hot-drop', name: 'Hot Drop', description: 'Two white queens spawn at e4 + d4 and are linked together; either one dying kills the other. Simplified from random placement (place-piece schema is enum-strict).', version: 1, primitives: [ { kind: 'on-rule-activated', params: { primitives: [ { kind: 'place-piece', params: { pieceType: 'queen', color: 'white', square: 28 }, }, { kind: 'place-piece', params: { pieceType: 'queen', color: 'white', square: 27 }, }, { kind: 'for-each-piece', params: { filter: { pieceType: 'queen', color: 'white' }, bind: 'qa', then: [ { kind: 'for-each-piece', params: { filter: { pieceType: 'queen', color: 'white' }, bind: 'qb', then: [ { kind: 'link-pieces', params: { a: { $var: 'qa' }, b: { $var: 'qb' } }, }, ], }, }, ], }, }, ], }, }, { kind: 'on-piece-pair-link-broken', params: { primitives: [ { kind: 'destroy-piece', params: { target: { 'ctx-self-id': null } }, }, ], }, }, ], targetAttrs: [], uiForm: 'primitive-composer', source: 'custom', } as const; // --------------------------------------------------------------------------- // Server lifecycle (mirrors wave3-choices.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; } }); // --------------------------------------------------------------------------- // LOAD-PATH helpers (lobby → profile editor → custom-modifier editor). // Mirrors wave3-choices.spec.ts. // --------------------------------------------------------------------------- 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 + apply-descriptor). // Mirrors parity-rules.spec.ts (apply-descriptor) — NOT activate-descriptor, // because the W4 runtime tests need the descriptor's full activation arm // to execute server-side (set-board-topology / place-piece + link-pieces). // activate-descriptor is the lift-PendingChoice-frame path; apply-descriptor // is the full applyCustomDescriptor walk. // --------------------------------------------------------------------------- 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(); // Wait for BOTH client + prediction-manager hooks (parity-rules.spec.ts // pattern). The PredictionManager is what we'll read session attrs from. await page.waitForFunction( () => Boolean( (globalThis as { __paratypeChessClient?: unknown }) .__paratypeChessClient, ) && Boolean( (globalThis as { __paratypeChessPrediction?: unknown }) .__paratypeChessPrediction, ), null, { timeout: 5000 }, ); return room; } /** * Drive `__test__.apply-descriptor` through the page's existing GameClient * socket. The handler runs server-side, mutates the engine via * `applyCustomDescriptor`, and emits a fresh `game.state` snapshot. * Mirror of parity-rules.spec.ts § applyDescriptor. */ async function applyDescriptor( page: Page, args: { code: string; descriptor: unknown }, ): Promise { await page.evaluate((a) => { const client = ( globalThis as { __paratypeChessClient?: { send: (msg: { type: string; payload: unknown }) => void; }; } ).__paratypeChessClient; if (!client) throw new Error('applyDescriptor: __paratypeChessClient not present'); client.send({ type: '__test__.apply-descriptor', payload: { roomCode: a.code, descriptor: a.descriptor }, }); }, args); } /** * Read a session attr via the page's PredictionManager. Returns * the engine's current attr value at the supplied entity id. * GAME_ENTITY = 0 (per `schema.ts` § "GAME_ENTITY: EntityId = 0"). */ async function readGameAttr( page: Page, attr: string, entityId: number = 0, ): Promise { return page.evaluate( (a) => { const mgr = ( globalThis as { __paratypeChessPrediction?: { getCurrentEngine: () => { session: { get: (id: unknown, attr: string) => unknown; }; }; }; } ).__paratypeChessPrediction; if (!mgr) throw new Error('readGameAttr: PredictionManager not exposed'); const engine = mgr.getCurrentEngine(); return engine.session.get(a.entityId, a.attr); }, { attr, entityId }, ); } // --------------------------------------------------------------------------- // Test suite — 5 load-and-validate + 2 runtime // --------------------------------------------------------------------------- test.describe('W4.10 — Wave-4 thressgame-100 recipes (5 load + 2 runtime)', () => { // ── 5 LOAD-AND-VALIDATE tests ──────────────────────────────────────── for (const id of NEW_RECIPE_IDS) { test(`loads ${id} into the editor without error`, async ({ page }) => { // Surface 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. 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`. await expect( page.locator('input[placeholder="Modifier Name"]'), ).toHaveValue(RECIPE_NAMES[id]); // Footer reports the descriptor as VALID. await expect(page.getByText('Valid Custom Descriptor')).toBeVisible(); // Page-side runtime check. expect(pageErrors).toEqual([]); }); } // ── RUNTIME 1 (Batch K) — tpl-bouncing-ricochet (smoke runtime) ────── // // The descriptor's activation arm is `on-rule-activated → set-board- // topology({value: "wrap-files"})`. set-board-topology.apply writes // `BoardTopology = "wrap-files"` to GAME_ENTITY (id=0). We probe via // the PredictionManager which the dev-only `__paratypeChessPrediction` // hook exposes. // // SMOKE depth: the full runtime contract for wrap-files topology // (sliding rays wrap across files, knights wrap, etc.) is pinned at // the unit level by `wave4-recipes-real.test.ts` and // `rules/topology.test.ts` (34 tests covering every piece type × // every boundary). The e2e proves only that the descriptor's // activation arm reaches the live engine pipeline and seeds the // game-level attr. No page-side errors permitted. // // Note: the `on-rule-expire` arm only fires when the rule's lifetime // window closes (RuleActivatedFiredFor expiry path); apply-descriptor // does NOT trigger expire — it only fires the activation cascade. // So at probe time we expect BoardTopology = "wrap-files", not // "standard". test('tpl-bouncing-ricochet: activation seeds BoardTopology="wrap-files" on GAME_ENTITY', async ({ browser, }) => { const ctx = await browser.newContext(); const page = await ctx.newPage(); const pageErrors: Error[] = []; page.on('pageerror', (err) => pageErrors.push(err)); const room = await joinAsHost(page); expect(room.color).toBe('white'); // Pre-apply: BoardTopology should be undefined (no preset wired) // OR "standard" (some hosts seed default topology). Both are // valid pre-states; we only assert the post-apply transition. const pre = await readGameAttr(page, 'BoardTopology', 0); expect(pre === undefined || pre === 'standard').toBe(true); await applyDescriptor(page, { code: room.code, descriptor: BOUNCING_RICOCHET_DESCRIPTOR, }); // Settle for game.state round-trip (server emits a fresh snapshot // post-apply; the prediction manager pulls it on the next tick). await page.waitForTimeout(250); // Post-apply: BoardTopology must be "wrap-files" — the activation // arm's set-board-topology primitive ran. Walker double-fire // doesn't matter here: idempotent set-attr writes the same value // twice. await expect .poll(async () => readGameAttr(page, 'BoardTopology', 0), { timeout: 3000, }) .toBe('wrap-files'); // No page-side errors during the apply pipeline. expect(pageErrors).toEqual([]); await ctx.close(); }); // ── RUNTIME 2 (Batch L) — tpl-hot-drop (full runtime: spawn + DOM) ─── // // The activation arm: // 1. place-piece(queen, white, sq=28) → e4 // 2. place-piece(queen, white, sq=27) → d4 // 3. nested for-each-piece(queen,white) × for-each-piece(queen,white) // → link-pieces — every white-queen pair gets symmetric PieceLink // facts (link-pieces' self-link no-op handles i==i). // // DOM evidence: white-queen pieces render on e4 and d4. The Board // selectors are `[data-square=""] [data-piece="white-queen"]` // (descendant — `data-piece` lives on the inner Piece element while // `data-square` lives on the outer cell wrapper, per Board.tsx:281 // and Piece.tsx:310). // // WALKER-DOUBLE-FIRE artifact: applyCustomDescriptor's walker // recurses into `on-rule-activated.childPrimitives()` AT APPLY TIME, // then `fireOnRuleActivatedHooks` runs the same arm post-walk, so // place-piece can fire TWICE per square. place-piece writes Position // to a fresh entity each call, so a double-fire yields up to 4 queen // entities distributed across the 2 squares — but the DOM only // renders ONE piece per square (the renderer dedupes by square). We // assert `>=1` per square as the lower-bound smoke; exact count is // implementation-detail of the walker artifact and pinned by the // unit test in `wave4-recipes-real.test.ts § hot-drop`. test('tpl-hot-drop: activation spawns white queens visible at e4 and d4', async ({ browser, }) => { const ctx = await browser.newContext(); const page = await ctx.newPage(); const pageErrors: Error[] = []; page.on('pageerror', (err) => pageErrors.push(err)); const room = await joinAsHost(page); expect(room.color).toBe('white'); // Pre-apply sanity: e4 + d4 are EMPTY at game start (FIDE initial // position — no piece on either rank-4 square). If this fails the // apply pipeline is broken upstream of our own write. await expect( page.locator('[data-square="e4"] [data-piece]'), ).toHaveCount(0); await expect( page.locator('[data-square="d4"] [data-piece]'), ).toHaveCount(0); await applyDescriptor(page, { code: room.code, descriptor: HOT_DROP_DESCRIPTOR, }); // Settle for game.state round-trip. await page.waitForTimeout(250); // Post-apply: at LEAST one white queen at each of e4 and d4. // The `>=1` lower bound absorbs the walker-double-fire artifact: // place-piece can fire twice through apply-then-trigger (yielding // 2 queens-worth of facts per square at the engine layer), but // the DOM renders 1 piece-per-square because Board renders the // first piece-id whose Position matches the cell. The CONTRACT // proven: spawn arm reached the live engine + DOM. await expect( page.locator('[data-square="e4"] [data-piece="white-queen"]'), ).toHaveCount(1, { timeout: 5000 }); await expect( page.locator('[data-square="d4"] [data-piece="white-queen"]'), ).toHaveCount(1, { timeout: 5000 }); // No page-side errors during the apply pipeline (the link-pieces // cascade walks the descriptor's nested for-each-piece × 2; the // walker artifact is well-tolerated by link-pieces' self-link // no-op + idempotent symmetric-list maintenance). expect(pageErrors).toEqual([]); await ctx.close(); }); });