Clicking a square with the palette's currently-selected piece now deletes that piece instead of re-placing it. Makes tap-to-delete a natural single-gesture operation without switching to the Erase brush. A different brush-piece still replaces (unchanged behaviour).
698 lines
28 KiB
TypeScript
698 lines
28 KiB
TypeScript
/**
|
|
* 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<boolean> {
|
|
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<string> {
|
|
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<string>();
|
|
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<string> {
|
|
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<string>((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);
|
|
});
|