/** * E2E — starting layouts feature (picker + editor + shareable URLs). * * Covers Phase A-E user-visible flows: * 1. Lobby shows a LayoutPicker with every registered premade. * 2. Picking a premade changes the selection (description text * updates to match). * 3. Opening the "Custom…" entry opens the editor modal. * 4. Placing pieces in the editor via the palette + board clicks. * 5. Live validation flips the CTA's disabled state. * 6. Saving to library round-trips through localStorage. * 7. "Use This Layout" commits a custom layout back to the lobby. * 8. Query-string ?fen= pre-selects a custom layout on page load. * * Does NOT exercise the multiplayer flow (server round-trip) because * the broadcast-level contract is covered in server unit tests and * packages/chess/e2e/multiplayer.spec.ts. This spec runs against a * local dev server only — no WS server needed. */ import { test, expect, type Page } from '@playwright/test'; import { spawn, type ChildProcess } from 'node:child_process'; import { setTimeout as sleep } from 'node:timers/promises'; // ───────────────────────────────────────────────────────────────────── // WebSocket server lifecycle — reused across multiplayer test cases. // Mirrors the pattern from e2e/multiplayer.spec.ts so tests can run // against a fresh server or a local dev server. // ───────────────────────────────────────────────────────────────────── 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 < 20; i++) { await sleep(250); if (await isWsServerRunning()) break; } }); test.afterAll(async () => { if (wsServerProcess !== null) { wsServerProcess.kill('SIGINT'); await sleep(200); wsServerProcess = null; } }); test.describe('LayoutPicker (lobby)', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await expect(page.getByTestId('page-home')).toBeVisible(); }); test('renders the picker with Classic selected by default', async ({ page }) => { const picker = page.getByTestId('layout-picker'); await expect(picker).toBeVisible(); await expect(picker).toHaveValue('classic'); }); test('picker includes every expected premade', async ({ page }) => { const picker = page.getByTestId('layout-picker'); // All premades + Custom entry. "Empty" is intentionally absent — // it's a test fixture only, not a selectable starting position // (fails validation, serves no player need). for (const id of [ 'classic', 'dunsany', 'monster', 'pawns-only', 'horde', 'knightmate', 'chess960', ]) { await expect(picker.locator(`option[value="${id}"]`)).toHaveCount(1); } await expect(picker.locator('option[value="empty"]')).toHaveCount(0); await expect(picker.locator('option[value="__custom__"]')).toHaveCount(1); }); test('selecting Dunsany updates the description', async ({ page }) => { const picker = page.getByTestId('layout-picker'); await picker.selectOption('dunsany'); await expect( page.getByTestId('layout-picker-description'), ).toContainText(/pawn tide/i); }); test('?layoutId=dunsany query param pre-selects the layout', async ({ page }) => { await page.goto('/?layoutId=dunsany'); const picker = page.getByTestId('layout-picker'); await expect(picker).toHaveValue('dunsany'); }); test('malformed ?fen query silently falls back to Classic', async ({ page }) => { await page.goto('/?fen=not-a-valid-fen'); const picker = page.getByTestId('layout-picker'); // Custom layout from malformed FEN isn't applied; Classic stays // selected. await expect(picker).toHaveValue('classic'); }); }); test.describe('LayoutEditor modal', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.getByTestId('layout-picker').selectOption('__custom__'); await expect(page.getByTestId('layout-editor')).toBeVisible(); }); test('opens with an empty board — validation errors show', async ({ page }) => { // Empty board = no kings → validation errors. await expect(page.getByTestId('validation-errors')).toBeVisible(); // Primary CTA disabled. await expect(page.locator('[data-action="apply-layout"]')).toBeDisabled(); }); test('placing one king per side makes the layout valid', async ({ page }) => { // Select white king from palette, click e1. await page.getByTestId('palette-white-king').click(); await page.getByTestId('editor-square-4').click(); // e1 = square 4 await page.getByTestId('palette-black-king').click(); await page.getByTestId('editor-square-60').click(); // e8 = square 60 // Validation flips to "ok". await expect(page.getByTestId('validation-ok')).toBeVisible(); await expect(page.locator('[data-action="apply-layout"]')).toBeEnabled(); }); test('erase brush removes a placed piece', async ({ page }) => { await page.getByTestId('palette-white-king').click(); await page.getByTestId('editor-square-4').click(); // Piece rendered in the square. await expect( page.getByTestId('editor-square-4').locator('img'), ).toBeVisible(); await page.locator('[data-action="brush-erase"]').click(); await page.getByTestId('editor-square-4').click(); await expect( page.getByTestId('editor-square-4').locator('img'), ).toHaveCount(0); }); test('clicking a square with the same brush-piece toggles it off (delete)', async ({ page, }) => { // Place a white king on e1 using the palette brush. await page.getByTestId('palette-white-king').click(); await page.getByTestId('editor-square-4').click(); await expect( page.getByTestId('editor-square-4').locator('img'), ).toBeVisible(); // Same brush still selected — clicking the same square should // TOGGLE the piece off instead of no-op / re-place. await page.getByTestId('editor-square-4').click(); await expect( page.getByTestId('editor-square-4').locator('img'), ).toHaveCount(0); // Clicking a 3rd time with the same brush re-adds the piece. await page.getByTestId('editor-square-4').click(); await expect( page.getByTestId('editor-square-4').locator('img'), ).toBeVisible(); }); test('clicking a square with a DIFFERENT brush-piece replaces (does NOT delete)', async ({ page, }) => { // Place a white king on e1. await page.getByTestId('palette-white-king').click(); await page.getByTestId('editor-square-4').click(); // Switch brush to black queen and click the same square — piece // should be REPLACED, not deleted (only same-piece clicks toggle). await page.getByTestId('palette-black-queen').click(); await page.getByTestId('editor-square-4').click(); await expect( page.getByTestId('editor-square-4').locator('img'), ).toBeVisible(); // Alt text reflects the new piece (black queen), proving a // replacement happened rather than a same-piece toggle. await expect( page.getByTestId('editor-square-4').locator('img'), ).toHaveAttribute('alt', 'black queen'); }); test('FEN Load replaces the board', async ({ page }) => { const fen = '8/8/8/8/8/8/8/4K2k'; // white king e1, black king h1 (not a realistic position but valid) await page.getByTestId('editor-fen').fill(fen); await page.locator('[data-action="load-fen"]').click(); // White king at square 4 (e1). await expect( page.getByTestId('editor-square-4').locator('img'), ).toBeVisible(); }); test('FEN field updates live as pieces are placed on the board', async ({ page, }) => { const fenField = page.getByTestId('editor-fen'); // Empty board → all-eights FEN. await expect(fenField).toHaveValue('8/8/8/8/8/8/8/8'); // Place a white king on e1. await page.getByTestId('palette-white-king').click(); await page.getByTestId('editor-square-4').click(); // Textarea now reflects the board without any Load/Reset step. await expect(fenField).toHaveValue('8/8/8/8/8/8/8/4K3'); // Place a black king on e8. await page.getByTestId('palette-black-king').click(); await page.getByTestId('editor-square-60').click(); await expect(fenField).toHaveValue('4k3/8/8/8/8/8/8/4K3'); }); test('typing into FEN field stops live sync until Load or Discard', async ({ page, }) => { // Place a king so the live FEN has something meaningful. await page.getByTestId('palette-white-king').click(); await page.getByTestId('editor-square-4').click(); const fenField = page.getByTestId('editor-fen'); await expect(fenField).toHaveValue('8/8/8/8/8/8/8/4K3'); // Start editing the textarea — live sync should now pause. await fenField.fill('garbage in progress'); // "Unsaved edit" indicator appears; the Load button is enabled. await expect(page.getByTestId('editor-fen-dirty')).toBeVisible(); // Adding more pieces to the board does NOT clobber the draft. await page.getByTestId('palette-black-king').click(); await page.getByTestId('editor-square-60').click(); await expect(fenField).toHaveValue('garbage in progress'); // Discard restores the live FEN and clears the dirty indicator. await page.locator('[data-action="sync-fen"]').click(); await expect(fenField).toHaveValue('4k3/8/8/8/8/8/8/4K3'); await expect(page.getByTestId('editor-fen-dirty')).toHaveCount(0); }); test('Use This Layout commits a custom layout to the lobby', async ({ page }) => { // Build minimal valid layout. await page.getByTestId('palette-white-king').click(); await page.getByTestId('editor-square-4').click(); await page.getByTestId('palette-black-king').click(); await page.getByTestId('editor-square-60').click(); // Rename to something identifiable. await page.getByTestId('layout-name').fill('My Mate-in-0'); await page.locator('[data-action="apply-layout"]').click(); // Editor closes, picker now reflects the custom layout. await expect(page.getByTestId('layout-editor')).not.toBeVisible(); const picker = page.getByTestId('layout-picker'); await expect(picker).toHaveValue('__custom__'); }); test('Save to Library persists across reloads', async ({ page, context }) => { await page.getByTestId('palette-white-king').click(); await page.getByTestId('editor-square-4').click(); await page.getByTestId('palette-black-king').click(); await page.getByTestId('editor-square-60').click(); await page.getByTestId('layout-name').fill('Persisted'); await page.locator('[data-action="save-to-library"]').click(); // Open the library drawer. await page.locator('[data-action="library-toggle"]').click(); await expect(page.getByTestId('library-list')).toBeVisible(); await expect(page.locator('text=Persisted').first()).toBeVisible(); // Reload and re-open the editor; library should still have the entry. await page.reload(); await page.getByTestId('layout-picker').selectOption('__custom__'); await page.locator('[data-action="library-toggle"]').click(); await expect(page.locator('text=Persisted').first()).toBeVisible(); // Cleanup — remove the entry so parallel test runs stay clean. // (Library is localStorage-scoped per browser context; this block // is belt-and-suspenders.) await context.clearCookies(); await page.evaluate(() => { localStorage.removeItem('houserules:layouts:v1'); }); }); test('Esc closes the editor', async ({ page }) => { await page.keyboard.press('Escape'); await expect(page.getByTestId('layout-editor')).not.toBeVisible(); }); }); // ───────────────────────────────────────────────────────────────────── // Solo play — proves the engine actually opens from the selected layout // (not just that the UI claims it). // ───────────────────────────────────────────────────────────────────── test.describe('Solo play — layout selection reaches the engine', () => { test.beforeEach(async ({ page }) => { // Clear any autosaved game so we start from a clean slate. await page.goto('/'); await page.evaluate(() => { for (let i = localStorage.length - 1; i >= 0; i--) { const key = localStorage.key(i); if (key !== null && key.startsWith('paratype-chess:v2:autosave:')) { localStorage.removeItem(key); } } localStorage.removeItem('paratype-chess:v1:autosave'); }); }); test('Classic (default) Play Solo opens with 32 pieces on standard squares', async ({ page, }) => { await page.goto('/'); await page.locator('[data-action="play-solo"]').click(); await page.waitForURL('**/game'); // a1 should have a white rook — standard FIDE setup. const a1 = page.locator('[data-square="a1"] [data-piece]'); await expect(a1).toBeVisible(); // e1 = white king; use the data-piece attribute to verify type. const e1 = page.locator('[data-square="e1"] [data-piece]'); await expect(e1).toBeVisible(); }); test('Pawns-Only Play Solo opens with no pieces on back ranks', async ({ page, }) => { await page.goto('/'); await page.getByTestId('layout-picker').selectOption('pawns-only'); await page.locator('[data-action="play-solo"]').click(); await page.waitForURL('**/game'); // e1 has the white king (pawns-only always gives each side a king). await expect( page.locator('[data-square="e1"] [data-piece]'), ).toBeVisible(); // a1 / h1 should be EMPTY — no rooks in pawns-only. await expect( page.locator('[data-square="a1"] [data-piece]'), ).toHaveCount(0); await expect( page.locator('[data-square="h1"] [data-piece]'), ).toHaveCount(0); // Rank 2 is all pawns. for (const file of ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) { await expect( page.locator(`[data-square="${file}2"] [data-piece]`), ).toBeVisible(); } }); }); // ───────────────────────────────────────────────────────────────────── // Chess960 — re-randomizes on each selection // ───────────────────────────────────────────────────────────────────── test('Chess960 generates a fresh random position on each pick', async ({ page, }) => { // Snapshot the back-rank piece order for a Chess960 Solo game. Two // consecutive selections should (with very high probability) yield // different back ranks — if the picker isn't re-seeding, both runs // would produce the identical default ordering. // // There are 960 legal Chess960 positions; the probability of any // two random draws colliding is 1/960. Over 8 independent // selections the chance of all matching some reference is // (1/960)^7 ≈ 1e-21. Asserting 'not all identical' across 8 picks // is effectively deterministic. async function snapshotBackRank(): Promise { const pieces: string[] = []; for (const file of ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) { const el = page.locator(`[data-square="${file}1"] [data-piece]`); const attr = await el.getAttribute('data-piece'); pieces.push(attr ?? ''); } return pieces.join(','); } const snapshots = new Set(); for (let i = 0; i < 8; i++) { await page.goto('/'); // Clear any stale autosave so every Solo game starts from the // fresh selected layout rather than a hydrated in-progress board. await page.evaluate(() => { for (let j = localStorage.length - 1; j >= 0; j--) { const key = localStorage.key(j); if (key !== null && key.startsWith('paratype-chess:v2:autosave:')) { localStorage.removeItem(key); } } }); await page.getByTestId('layout-picker').selectOption('chess960'); await page.locator('[data-action="play-solo"]').click(); await page.waitForURL('**/game'); snapshots.add(await snapshotBackRank()); } expect(snapshots.size).toBeGreaterThan(1); }); // ───────────────────────────────────────────────────────────────────── // Valid ?fen URL pre-selects Custom // ───────────────────────────────────────────────────────────────────── test('?fen query pre-selects the layout (valid FEN path)', async ({ page }) => { // Minimal valid layout: just two kings on e1 / e8. const fen = '4k3/8/8/8/8/8/8/4K3'; await page.goto(`/?fen=${encodeURIComponent(fen)}&name=Lone+Kings`); // Picker shows Custom selected. await expect(page.getByTestId('layout-picker')).toHaveValue('__custom__'); // Description mentions "Custom layout loaded from your editor." await expect( page.getByTestId('layout-picker-description'), ).toContainText(/custom layout/i); }); // ───────────────────────────────────────────────────────────────────── // Library operations — delete + star toggle // ───────────────────────────────────────────────────────────────────── test.describe('Library operations', () => { test.beforeEach(async ({ page }) => { // Start from a known-clean library. await page.goto('/'); await page.evaluate(() => { localStorage.removeItem('houserules:layouts:v1'); }); }); test('save → star → unstar → delete round-trips', async ({ page }) => { await page.goto('/'); await page.getByTestId('layout-picker').selectOption('__custom__'); await expect(page.getByTestId('layout-editor')).toBeVisible(); // Place minimal kings and save. await page.getByTestId('palette-white-king').click(); await page.getByTestId('editor-square-4').click(); await page.getByTestId('palette-black-king').click(); await page.getByTestId('editor-square-60').click(); await page.getByTestId('layout-name').fill('Star Target'); await page.locator('[data-action="save-to-library"]').click(); await page.locator('[data-action="library-toggle"]').click(); const list = page.getByTestId('library-list'); await expect(list).toBeVisible(); // Find the Star button (the only button with aria-label="Star" or // "Unstar" inside the list). const starBtn = list.locator('button[aria-label="Star"]').first(); await expect(starBtn).toBeVisible(); await starBtn.click(); // After toggle, aria-label flips to "Unstar". const unstarBtn = list.locator('button[aria-label="Unstar"]').first(); await expect(unstarBtn).toBeVisible(); // Toggle back to unstarred. await unstarBtn.click(); await expect(list.locator('button[aria-label="Star"]').first()).toBeVisible(); // Now delete. await list .locator('button[aria-label="Delete"]') .first() .click(); // List re-renders with no entries. await expect(page.locator('text=/no saved layouts/i')).toBeVisible(); }); }); // ───────────────────────────────────────────────────────────────────── // Layout badge on GameView — shown for non-classic, hidden for classic // ───────────────────────────────────────────────────────────────────── test.describe('Layout badge on game view', () => { test('no badge for classic solo games', async ({ page }) => { await page.goto('/'); // Ensure no stale layout-name in sessionStorage from earlier tests. await page.evaluate(() => sessionStorage.removeItem('layout-name')); await page.locator('[data-action="play-solo"]').click(); await page.waitForURL('**/game'); await expect(page.getByTestId('layout-badge')).toHaveCount(0); }); // Solo games do NOT set sessionStorage['layout-name'] (that only // happens on multiplayer create/join paths). For solo-mode badge // support we'd need to wire selectedLayout through resetToFreshGame // into sessionStorage too. That's a small follow-up — for now, // solo games of any layout show no badge, which is why only the // classic case is asserted here. The multiplayer badge path is // exercised in the multiplayer-layout test below. }); // ───────────────────────────────────────────────────────────────────── // Multiplayer — server-authoritative layout propagation // Proves the end-to-end pipeline: picker → room.create with layout → // server resolves + validates + constructs engine → room.created // echoes layout → client stores name → creator AND joiner render the // same non-FIDE position. // ───────────────────────────────────────────────────────────────────── /** * Read the back-rank piece order from a page's board. Returns a * comma-joined snapshot like "white-rook,white-knight,..." suitable * for equality comparison across pages. */ async function snapshotBackRank( page: Page, rank: 1 | 8, ): Promise { const pieces: string[] = []; for (const file of ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) { const el = page.locator(`[data-square="${file}${rank}"] [data-piece]`); const attr = await el.getAttribute('data-piece').catch(() => null); pieces.push(attr ?? ''); } return pieces.join(','); } test('multiplayer: creating a room with Dunsany layout propagates to both players', async ({ browser, }) => { const ctxA = await browser.newContext(); const ctxB = await browser.newContext(); const pageA = await ctxA.newPage(); const pageB = await ctxB.newPage(); // Page A: pick Dunsany and Create Room via the normal Lobby UI // (not raw WS — we want to prove the client-side request shape // correctly carries the layout selection). await pageA.goto('/'); await pageA.getByTestId('layout-picker').selectOption('dunsany'); await pageA.locator('[data-action="create-room"]').click(); // Wait for navigation to the game view. await pageA.waitForURL(/\/game\//); await expect( pageA.locator('[data-testid="turn-indicator"]'), ).toBeVisible(); // Extract the room code from sessionStorage (mirrors what the Lobby // wrote). This avoids parsing the URL path. const roomCode = await pageA.evaluate( () => sessionStorage.getItem('room-code'), ); expect(roomCode).not.toBeNull(); // Layout badge visible on page A (Dunsany != classic). await expect(pageA.getByTestId('layout-badge')).toBeVisible(); await expect(pageA.getByTestId('layout-badge')).toContainText(/dunsany/i); // Page B: join the room via the code input. await pageB.goto('/'); await pageB.locator('[data-testid="room-code-input"]').fill(roomCode!); await pageB.locator('[data-action="join-room"]').click(); await pageB.waitForURL(/\/game\//); // Page B also shows the Dunsany badge — echoed by the server. await expect(pageB.getByTestId('layout-badge')).toBeVisible(); await expect(pageB.getByTestId('layout-badge')).toContainText(/dunsany/i); // Both boards render the SAME Dunsany setup. Dunsany has a white // king on e1 and pawns everywhere on ranks 1-4 (except e1 which // holds the king). Compare rank-1 snapshots between pages. const rankA = await snapshotBackRank(pageA, 1); const rankB = await snapshotBackRank(pageB, 1); expect(rankA).toBe(rankB); // Specifically assert rank 1 has a white king at e1 and white // pawns elsewhere. expect(rankA).toContain('white-king'); expect(rankA).toContain('white-pawn'); expect(rankA).not.toContain('white-rook'); // rook belongs to standard FIDE, not Dunsany white // Black's back rank is standard FIDE. const blackBack = await snapshotBackRank(pageA, 8); expect(blackBack).toContain('black-rook'); expect(blackBack).toContain('black-king'); await ctxA.close(); await ctxB.close(); }); test('multiplayer: invalid custom layout is rejected with LAYOUT_INVALID toast', async ({ page, }) => { // Use the editor to build a KINGLESS custom layout — the server // should reject it when Create Room is clicked (validator requires // at least one king per side). The client's normal Lobby flow // disables validation only for PREMADE selections; custom layouts // that pass the editor's validation won't trigger this, so we // forge the request via the Lobby state directly. // // Approach: set localStorage entry to pre-populate a "saved" bad // layout, load it, then wrap the onCustomRequested path. Simpler: // use the FEN field to inject a kingless board and then use the // "Copy Share Link" → paste flow. // // Easiest approach: bypass the editor entirely by crafting a URL // with a FEN that parses but validates at 0 kings. The Lobby's // client-side validateLayout call will filter this out (only // applying it if errors.length === 0), so the picker stays on // Classic. Which means a kingless URL-loaded layout never reaches // the server — already-safe behavior. // // Instead assert: attempting to Create Room while the picker // claims a layout the server would reject does NOT happen because // every premade passes server validation. This test guards the // property by firing a create request with custom pieces missing // a black king directly via raw WebSocket. await page.goto('/'); const errorMessage = await page.evaluate(async () => { return new Promise((resolve, reject) => { const ws = new WebSocket('ws://localhost:7357/ws'); const timer = setTimeout( () => reject(new Error('timeout')), 5000, ); ws.onopen = () => { ws.send( JSON.stringify({ v: 1, seq: 1, ts: Date.now(), type: 'room.create', payload: { layout: { kind: 'custom', pieces: [ { type: 'king', color: 'white', square: 4 }, // No black king — should fail validation. ], }, }, }), ); }; ws.onmessage = (e: MessageEvent) => { const msg = JSON.parse(e.data as string) as { type: string; payload: { code?: string; message?: string }; }; if (msg.type === 'error') { clearTimeout(timer); ws.close(); resolve(msg.payload.message ?? '(no message)'); } else if (msg.type === 'room.created') { clearTimeout(timer); ws.close(); reject(new Error('server accepted kingless layout')); } }; ws.onerror = () => { clearTimeout(timer); reject(new Error('ws error')); }; }); }); // Server rejects with a human-readable message containing "king". expect(errorMessage).toMatch(/king/i); });