/** * W3.6 — Playwright e2e for the 8 NEW Wave-3 thressgame-100 recipes * shipped by W3.0-W3.5 (see `recipes.ts` lines 2256-2645). * * Coverage shape (mirrors `wave2-countdowns.spec.ts`, scaled to the * Wave-3 batch): * * 1. Eight LOAD-AND-VALIDATE tests — one per recipe id. Same * pattern as `wave2-countdowns.spec.ts § "loads {id} into the * editor without error"`: 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. Three RUNTIME-BEHAVIOR tests — request-choice modal flow: * * Batch G — `tpl-bottomless-pit` (kind:square modal): * Activate via `__test__.activate-descriptor`. Assert the * RequestChoiceModal renders with `data-choice-kind="square"` * (the first square recipe shipped — exercises the * ParamSquarePicker UI path that was previously * schema-supported but unused by any recipe). * * Batch G — `tpl-portal-storm` (2-step nested square chooser): * Activate. Assert modal #1 (square) visible. Click a * square (e4 / index 28) → submitChoiceAndResume drives * the continuation, which immediately fires the inner * request-choice → modal #2 (square) opens. This is the * first multi-step request-choice runtime test in the * codebase — proves the LIFO stack push/pop on resume. * * Batch J — `tpl-sophies-choice` (kind:piece, forPlayer:"both"): * Activate with chooserColor:"white". Per the dispatcher's * forPlayer:"both" semantics, the descriptor fires the * chooser's-side request-choice first; both players are * eventually prompted but a single-page test only sees * the white-side modal. Assert the modal is visible with * data-choice-kind="piece" and data-for-player="both". * * ───────────────────────────────────────────────────────────────────── * Driving infrastructure * ───────────────────────────────────────────────────────────────────── * * Per the precedent set in `wave1-recipes.spec.ts`, * `wave2-countdowns.spec.ts`, and `templates-thressgame.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 custom-modifiers.spec.ts) // --------------------------------------------------------------------------- const PROFILE_LIBRARY_KEY = 'houserules:modifier-profiles:v1'; const CUSTOM_LIBRARY_KEY = 'houserules:custom-modifiers:v1'; // --------------------------------------------------------------------------- // The 8 new recipe ids + canonical descriptor.name strings (sourced from // recipes.ts lines 2256-2645). These are pinned constants — if the recipe // names change in recipes.ts, this map must be updated. // --------------------------------------------------------------------------- const NEW_RECIPE_IDS = [ // Batch G — choice-driven spawn (3, kind:"square") 'tpl-bottomless-pit', 'tpl-call-down-lightning', 'tpl-portal-storm', // Batch H — choice-driven swap/move (2, kind:"piece") 'tpl-anti-camping-choice', 'tpl-two-kids-trenchcoat', // Batch I — choice-driven self-modification (2, kind:"piece") 'tpl-blood-sacrifice', 'tpl-summoning-ritual-light', // Batch J — sophie's-choice (1, forPlayer:"both") 'tpl-sophies-choice', ] as const; type NewRecipeId = (typeof NEW_RECIPE_IDS)[number]; // `descriptor.name` (the second arg to `descriptorForRecipe`) — the // editor's Modifier Name field reflects this on template load. const RECIPE_NAMES: Record = { 'tpl-bottomless-pit': 'Bottomless Pit', 'tpl-call-down-lightning': 'Call Down Lightning', 'tpl-portal-storm': 'Portal Storm', 'tpl-anti-camping-choice': 'Anti-Camping Choice', 'tpl-two-kids-trenchcoat': 'Two Kids Trenchcoat', 'tpl-blood-sacrifice': 'Blood Sacrifice', 'tpl-summoning-ritual-light': 'Summoning Ritual (light)', 'tpl-sophies-choice': "Sophie's Choice", }; // --------------------------------------------------------------------------- // Inline copies of the runtime-tested descriptors. Hard-coded here so the // spec doesn't need to import from chess source (the e2e runner doesn't // bundle TS; specs run via Playwright's loader). Drift between these and // `recipes.ts` surfaces as a name-mismatch in the load-and-validate tests // above (which read the recipe through the actual app bundle). // // MUST stay in sync with `packages/chess/src/modifiers/custom/recipes.ts` // for the recipes that have inline copies here: // - tpl-bottomless-pit (on-rule-activated → request-choice square) // - tpl-portal-storm (on-rule-activated → 2-step request-choice square) // - tpl-sophies-choice (on-rule-activated → request-choice piece, forPlayer:"both") // --------------------------------------------------------------------------- const BOTTOMLESS_PIT_DESCRIPTOR = { type: 'data', id: 'tpl-bottomless-pit', name: 'Bottomless Pit', description: 'Chooser picks a square; a permanent pit appears there. Pure request-choice(square) → spawn-marker chain.', version: 1, primitives: [ { kind: 'on-rule-activated', params: { primitives: [ { kind: 'request-choice', params: { kind: 'square', prompt: 'Bottomless Pit — pick a square to drop a permanent pit', forPlayer: 'white', bind: 'sq', then: [ { kind: 'spawn-marker', params: { markerKind: 'pit', square: { $var: 'sq' }, lifetime: { kind: 'permanent' }, }, }, ], }, }, ], }, }, ], targetAttrs: [], uiForm: 'primitive-composer', source: 'custom', } as const; const PORTAL_STORM_DESCRIPTOR = { type: 'data', id: 'tpl-portal-storm', name: 'Portal Storm', description: 'Chooser picks 2 squares; a linked portal pair spawns. Demonstrates 2-step request-choice(square) → spawn-marker-pair.', version: 1, primitives: [ { kind: 'on-rule-activated', params: { primitives: [ { kind: 'request-choice', params: { kind: 'square', prompt: 'Portal Storm — pick the FIRST portal endpoint', forPlayer: 'white', bind: 'sq1', then: [ { kind: 'request-choice', params: { kind: 'square', prompt: 'Portal Storm — pick the SECOND portal endpoint', forPlayer: 'white', bind: 'sq2', then: [ { kind: 'spawn-marker-pair', params: { markerKind: 'portal-end', squareA: { $var: 'sq1' }, squareB: { $var: 'sq2' }, lifetime: { kind: 'permanent' }, }, }, ], }, }, ], }, }, ], }, }, ], targetAttrs: [], uiForm: 'primitive-composer', source: 'custom', } as const; const SOPHIES_CHOICE_DESCRIPTOR = { type: 'data', id: 'tpl-sophies-choice', name: "Sophie's Choice", description: 'Each player picks one of their own pieces; that piece is destroyed. Both-player chooser with destroy-piece consequence.', version: 1, primitives: [ { kind: 'on-rule-activated', params: { primitives: [ { kind: 'request-choice', params: { kind: 'piece', prompt: "Sophie's Choice — pick one of your own pieces; it will be destroyed", forPlayer: 'both', bind: 'doomed', then: [ { kind: 'destroy-piece', params: { target: { $var: 'doomed' } }, }, ], }, }, ], }, }, ], targetAttrs: [], uiForm: 'primitive-composer', source: 'custom', } as const; // --------------------------------------------------------------------------- // Server lifecycle (mirrors wave2-countdowns.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 wave2-countdowns.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 + activate-descriptor). // Mirrors wave2-countdowns.spec.ts and choice-kinds.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; } 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 — 8 load-and-validate + 3 runtime // --------------------------------------------------------------------------- test.describe('W3.6 — Wave-3 thressgame-100 recipes (8 load + 3 runtime)', () => { // ── 8 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. Subsumes the "no error // toast" check — a structural validation failure is the only path // that surfaces an error toast at this load stage. await expect(page.getByText('Valid Custom Descriptor')).toBeVisible(); // Page-side runtime check. expect(pageErrors).toEqual([]); }); } // ── RUNTIME 1 (Batch G) — tpl-bottomless-pit (kind:square modal) ───── // // First runtime test of the kind:"square" request-choice path in the // wave-N suites. The recipe's descriptor is `on-rule-activated → // request-choice(square)`; the activate-descriptor handler lifts the // inner arm so primitives[0] becomes the request-choice and pushes // a PendingChoice frame on activation. RequestChoiceModal renders // synchronously with data-choice-kind="square" — proves the // ParamSquarePicker UI path the Wave-3 batch unblocks. test('tpl-bottomless-pit: activation surfaces a square-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'); await activateDescriptor(page, { code: room.code, descriptor: BOTTOMLESS_PIT_DESCRIPTOR, chooserColor: 'white', liftedId: 'tpl-bottomless-pit__lifted__test', }); const modal = page.locator('[data-testid="request-choice-modal"]'); await expect(modal).toBeVisible({ timeout: 5000 }); // Choice kind is `square` per the descriptor. await expect(modal).toHaveAttribute('data-choice-kind', 'square'); // ParamSquarePicker renders a 64-square grid. await expect(modal.locator('button[aria-label^="Square "]')).toHaveCount( 64, ); await ctx.close(); }); // ── RUNTIME 2 (Batch G) — tpl-portal-storm (2-step nested square) ──── // // The descriptor's structure is: // on-rule-activated → request-choice(square, "sq1") // → request-choice(square, "sq2") // → spawn-marker-pair // // First multi-step request-choice runtime test in the codebase. The // activate-descriptor handler pushes the OUTER request-choice frame // on activation. Resolving it (clicking a square in modal #1) drives // submitChoiceAndResume; the continuation immediately fires the INNER // request-choice → modal #2 opens with the same data-choice-kind but // a NEW choice-id. Proves the LIFO push/pop semantics on the engine's // PendingChoices stack. test('tpl-portal-storm: 2-step nested square chooser — modal#1 → click → modal#2 opens', async ({ browser, }) => { const ctx = await browser.newContext(); const page = await ctx.newPage(); const room = await joinAsHost(page); expect(room.color).toBe('white'); await activateDescriptor(page, { code: room.code, descriptor: PORTAL_STORM_DESCRIPTOR, chooserColor: 'white', liftedId: 'tpl-portal-storm__lifted__test', }); const modal = page.locator('[data-testid="request-choice-modal"]'); // Modal #1 — picks the first portal endpoint. The choice-id is // assigned by the engine; capture it so we can assert modal #2 // is a DIFFERENT choice (not the same modal still open). await expect(modal).toBeVisible({ timeout: 5000 }); await expect(modal).toHaveAttribute('data-choice-kind', 'square'); const firstChoiceId = await modal.getAttribute('data-choice-id'); expect(firstChoiceId).toBeTruthy(); // Click e4 (LERF index 28). ParamSquarePicker tags each button // with `aria-label="Square "`. await modal.locator('button[aria-label="Square 28"]').click(); // Modal #2 opens. Same data-testid, but the choice-id MUST be // different — that's the proof a fresh PendingChoice frame was // pushed on resume. We poll on the data-choice-id attribute // changing rather than asserting !visible-then-visible (the // close+reopen race could be sub-frame; the choice-id flip is // the durable signal). await expect .poll( async () => modal.getAttribute('data-choice-id'), { timeout: 5000 }, ) .not.toBe(firstChoiceId); // Confirm the second modal is also a square-kind chooser (the // inner request-choice in the descriptor). await expect(modal).toHaveAttribute('data-choice-kind', 'square'); // And it should still render the 64-button grid. await expect(modal.locator('button[aria-label^="Square "]')).toHaveCount( 64, ); await ctx.close(); }); // ── RUNTIME 3 (Batch J) — tpl-sophies-choice (forPlayer:"both") ────── // // forPlayer:"both" semantics: the descriptor fires sequentially for // each player. The chooser-color player goes first; the modal that // renders on the chooser's page (white in this test) is the white // player's prompt. The modal's data-for-player attribute carries // the descriptor's forPlayer literal ("both"), NOT the resolved // current-pick player — that's how the front-end labels who the // chooser is in a many-player descriptor. // // We don't drive the resolve step (would need a second page logged // in as black to take the second prompt). The smoke depth here is // sufficient: the modal opens correctly, indicating the dispatcher // recognized forPlayer:"both" and routed the first push to the // chooser color (white). test("tpl-sophies-choice: activation surfaces a piece-kind modal for chooser (forPlayer='both')", async ({ browser, }) => { const ctx = await browser.newContext(); const page = await ctx.newPage(); const room = await joinAsHost(page); expect(room.color).toBe('white'); await activateDescriptor(page, { code: room.code, descriptor: SOPHIES_CHOICE_DESCRIPTOR, chooserColor: 'white', liftedId: 'tpl-sophies-choice__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'); // The descriptor's forPlayer is "both"; the modal carries the // literal through to data-for-player (RequestChoiceModal.tsx:59). await expect(modal).toHaveAttribute('data-for-player', 'both'); await ctx.close(); }); });