v0.14.0 web replay UI, My games, CLI replay

This commit is contained in:
lixen committed 2026-10-19 18:07:41 -04:00
1 parent 87dcb3e550
commit a999177f0c
37 files changed
+4058 -1803

No files matched your search

+39 -1
View File
@@ -38,9 +38,11 @@ The server runs with `-dev`, debug logging, HTTP request logging, and relaxed ra
It drops the test tables on exit, so it's preferred for clean testing.
Can be used for all the tests.
Logging can be adjusted without editing the script:
Logging can be adjusted without editing the script, and `WEB_PORT` also
serves the embedded web client (needed by the end-to-end suite):
```bash
LOG_LEVEL=info LOG_HTTP=false test/run-test-server.sh bin/chess-server
WEB_PORT=9090 test/run-test-server.sh bin/chess-server
```
### Pre-configured Users
@@ -83,6 +85,7 @@ curl -X POST http://localhost:8080/api/games \
| End States | `test-endstate.sh` | Checkmate, stalemate, terminal custom positions |
| Replay | `test-replay.sh` | SAN history, PGN export, ETag/304, promotion, FEN validation, cursor-paged listing |
| Draws & Resignation | `test-draw.sh` | Automatic draws, offers (human and computer), resignation and authorization, long-poll wake-up, concessions and claim control, server version, hand-deleted game row |
| Web client (end-to-end) | `e2e/` | Playwright in Chromium against the embedded web client: play, My games, replay controls and keyboard, autoplay, FEN/PGN export and download compared with the API, deep links, device list |
| Test Server | `run-test-server.sh` | Pre-populated test environment |
## 1. API Functionality Tests (`test-api.sh`)
@@ -216,3 +219,38 @@ unknown route answered with `NOT_FOUND`, `/health` reporting the build, and a
game whose row is deleted with
psql (needs `CHESS_TEST_DSN` and `psql`; the server unloads the game and
stays healthy).
## Web client end-to-end tests (`e2e/`)
Playwright drives the embedded web client in Chromium against a running
server with PostgreSQL and Stockfish: it plays a short game, lists it under
My games, replays it with the buttons and the keyboard, autoplays to the end,
compares the FEN and PGN it exports (copy and download) with the API, follows
a deep link to a checkmate, steps back through the browser history, and
replays an anonymous game from the device list.
Requirements: Node.js 20+ and a Chromium that Playwright can run.
```bash
# Terminal 1: the test server with the web client
WEB_PORT=9090 test/run-test-server.sh bin/chess-server
# Terminal 2, once
cd test/e2e && npm install && npx playwright install chromium
# Terminal 2: run
npm test # or, from the repository root: make test-e2e
```
Environment:
- `CHESS_E2E_WEB` (default `http://localhost:9090`): the web client's origin
- `CHESS_E2E_API` (default `http://localhost:8080`): the API's origin, used
to register users, create games and fetch what the client should export
- `CHESS_E2E_CHROMIUM`: path to a Chromium binary when
`npx playwright install` is not possible (an already installed browser)
Each test registers its own user or plays anonymously, so the suite can be
repeated against the same server. The engine plays at level 0 with a 100 ms
search, so a run takes about ten seconds. On failure Playwright keeps a trace
under `test/e2e/test-results/`; open it with `npx playwright show-trace`.
+3
View File
@@ -0,0 +1,3 @@
node_modules
test-results
playwright-report
+11
View File
@@ -0,0 +1,11 @@
{
"name": "chess-web-e2e",
"private": true,
"description": "Playwright end-to-end tests for the chess web client (see test/README.md)",
"scripts": {
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "1.57.0"
}
}
+29
View File
@@ -0,0 +1,29 @@
// Playwright configuration for the web client's end-to-end tests. The server
// must already run with -serve and PostgreSQL (test/run-test-server.sh with
// WEB_PORT, or any -dev -serve server): CHESS_E2E_WEB names the client's
// origin and CHESS_E2E_API the API's. CHESS_E2E_CHROMIUM points at an
// installed Chromium binary when `npx playwright install chromium` is not an
// option.
'use strict';
const { defineConfig } = require('@playwright/test');
const chromium = process.env.CHESS_E2E_CHROMIUM;
module.exports = defineConfig({
testDir: '.',
testMatch: '*.spec.js',
timeout: 120_000,
expect: { timeout: 15_000 },
fullyParallel: false,
workers: 1,
retries: 0,
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL: process.env.CHESS_E2E_WEB || 'http://localhost:9090',
viewport: { width: 980, height: 480 },
permissions: ['clipboard-read', 'clipboard-write'],
acceptDownloads: true,
trace: 'retain-on-failure',
launchOptions: chromium ? { executablePath: chromium } : {},
},
});
+198
View File
@@ -0,0 +1,198 @@
// End-to-end: play a short game on the board, open My games, replay it with
// the controls and the keyboard, autoplay to the end, and compare the
// exported FEN and PGN with the API. Also the deep link, a checkmate's
// replay, and the device list for an anonymous visitor.
'use strict';
const { test, expect } = require('@playwright/test');
const API = process.env.CHESS_E2E_API || 'http://localhost:8080';
const START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
// The engine level and search time are set through the sliders' inputs.
async function startGame(page, { color = 'white' } = {}) {
await page.click('#new-game-btn');
await expect(page.locator('#modal-overlay')).toHaveClass(/show/);
await page.locator('#computer-level').fill('0');
await page.locator('#search-time').fill('100');
await page.check(`input[name="player-color"][value="${color}"]`);
await page.click('#start-game-btn');
await expect(page.locator('#modal-overlay')).not.toHaveClass(/show/);
}
async function move(page, from, to) {
await page.click(`[data-square="${from}"]`);
await page.click(`[data-square="${to}"]`);
}
// Waits for the computer's reply: n plies in the list and the board free.
async function expectPlies(page, n) {
await expect(page.locator('#live-moves .move')).toHaveCount(n, { timeout: 60_000 });
await expect(page.locator('#turn-text')).not.toContainText('thinking', { timeout: 60_000 });
}
async function register(page, request) {
const username = `e2e${Date.now().toString(36)}${Math.floor(Math.random() * 1000)}`;
const response = await request.post(`${API}/api/auth/register`, {
data: { username, password: 'Passw0rd123' },
});
expect(response.status(), await response.text()).toBe(201);
const auth = await response.json();
// The client restores a session from localStorage on load.
await page.addInitScript(token => localStorage.setItem('authToken', token), auth.token);
return { ...auth, password: 'Passw0rd123' };
}
test.describe('web replay', () => {
test('play, list, replay, export', async ({ page, request }) => {
const user = await register(page, request);
await page.goto('/');
await expect(page.locator('#auth-label')).toHaveText(user.username);
await startGame(page);
await move(page, 'e2', 'e4');
await expectPlies(page, 2);
await move(page, 'g1', 'f3');
await expectPlies(page, 4);
// The live list shows SAN from the server.
await expect(page.locator('#live-moves .move').first()).toHaveText('e4');
await expect(page.locator('#live-moves .move').nth(2)).toHaveText('Nf3');
const gameId = await page.evaluate(() => live.gameId);
const liveFen = await page.evaluate(() => live.fen);
const history = await (await request.get(`${API}/api/games/${gameId}/history`)).json();
expect(history.moves.map(m => m.san)).toEqual(await page.$$eval('#live-moves .move', els => els.map(e => e.textContent)));
// Copy PGN from the live view: the stored game's export.
await page.click('#copy-pgn-btn');
await expect(page.locator('#copy-pgn-btn')).toHaveClass(/done/);
const copied = await page.evaluate(() => navigator.clipboard.readText());
const apiPgn = await (await request.get(`${API}/api/games/${gameId}/pgn`)).text();
expect(copied).toBe(apiPgn);
// My games lists the game with the user's side named.
await page.click('#games-btn');
await expect(page.locator('#games-overlay')).toHaveClass(/show/);
const row = page.locator('#games-list .game-row').first();
await expect(row).toContainText('You vs Stockfish L0');
await expect(row).toContainText('Ongoing');
await expect(row).toHaveClass(/current/);
await expect(page).toHaveURL(/\?games$/);
await page.click('.seg[data-filter="status"][data-value="finished"]');
await expect(page.locator('#games-status')).toContainText('No games match');
await page.click('.seg[data-filter="status"][data-value=""]');
await expect(page.locator('#games-list .game-row')).toHaveCount(1);
// Replay from the list opens at the last ply; the live controls hide.
await row.click();
await expect(page.locator('body')).toHaveAttribute('data-mode', 'replay');
await expect(page.locator('#replay-ply')).toHaveText('4 / 4');
await expect(page).toHaveURL(new RegExp(`\\?replay=${gameId}&ply=4$`));
await expect(page.locator('#pane-live')).toBeHidden();
await expect(page.locator('#replay-moves .move[aria-current="true"]')).toHaveText(history.moves[3].san);
// Backward and forward: buttons, then the keyboard.
await page.click('#replay-first');
await expect(page.locator('#replay-ply')).toHaveText('0 / 4');
await expect(page.locator('#replay-first')).toBeDisabled();
await page.keyboard.press('ArrowRight');
await page.keyboard.press('ArrowRight');
await expect(page.locator('#replay-ply')).toHaveText('2 / 4');
await expect(page.locator('#ply-announcer')).toContainText('Ply 2 of 4');
await expect(page).toHaveURL(new RegExp(`ply=2$`));
await page.keyboard.press('ArrowLeft');
await expect(page.locator('#replay-ply')).toHaveText('1 / 4');
await page.keyboard.press('End');
await expect(page.locator('#replay-ply')).toHaveText('4 / 4');
await page.keyboard.press('Home');
await expect(page.locator('#replay-ply')).toHaveText('0 / 4');
await page.click('#replay-moves .move[data-ply="3"]');
await expect(page.locator('#replay-ply')).toHaveText('3 / 4');
// The exported FEN and PGN at ply 3 match the API.
await page.click('#replay-copy-fen');
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(history.moves[2].fenAfterMove);
await page.click('#replay-copy-pgn');
await expect(page.locator('#replay-copy-pgn')).toHaveClass(/done/);
const pgn3 = await (await request.get(`${API}/api/games/${gameId}/pgn?ply=3`)).text();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(pgn3);
const downloadPromise = page.waitForEvent('download');
await page.click('#replay-download');
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^chess-\d{8}-[0-9a-f]{8}-ply3\.pgn$/);
await page.click('#replay-link');
expect(await page.evaluate(() => navigator.clipboard.readText()))
.toBe(`${new URL(page.url()).origin}/?replay=${gameId}&ply=3`);
// Autoplay to the end at 4x, then pause state clears.
await page.selectOption('#replay-speed', '250');
await page.click('#replay-play');
await expect(page.locator('#replay-play')).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('#replay-ply')).toHaveText('4 / 4', { timeout: 10_000 });
await expect(page.locator('#replay-play')).toHaveAttribute('aria-pressed', 'false');
// Flip, then back to the live game, which continues where it was.
await page.click('#replay-flip');
await expect(page.locator('.coordinates.ranks span').first()).toHaveText('1');
await page.click('#replay-exit');
await expect(page.locator('body')).toHaveAttribute('data-mode', 'live');
await expect(page.locator('.coordinates.ranks span').first()).toHaveText('8');
expect(await page.evaluate(() => live.fen)).toBe(liveFen);
await expect(page.locator('#live-moves .move')).toHaveCount(4);
await move(page, 'b1', 'c3');
await expectPlies(page, 6);
// Browser history: back to the replay, back again to the list.
await page.goBack();
await expect(page.locator('body')).toHaveAttribute('data-mode', 'replay');
await page.goBack();
await expect(page.locator('#games-overlay')).toHaveClass(/show/);
await expect(page.locator('body')).toHaveAttribute('data-mode', 'live');
});
test('deep link to a checkmate replays with the mated king marked', async ({ page, request }) => {
// A human-vs-human game over the API: fool's mate.
const created = await request.post(`${API}/api/games`, { data: { white: { type: 1 }, black: { type: 1 } } });
expect(created.status()).toBe(201);
const { gameId } = await created.json();
for (const m of ['f2f3', 'e7e5', 'g2g4', 'd8h4']) {
const r = await request.post(`${API}/api/games/${gameId}/moves`, { data: { move: m } });
expect(r.status(), await r.text()).toBe(200);
}
await page.goto(`/?replay=${gameId}&ply=2`);
await expect(page.locator('body')).toHaveAttribute('data-mode', 'replay');
await expect(page.locator('#replay-ply')).toHaveText('2 / 4');
await expect(page.locator('#replay-result')).toContainText('0-1');
await expect(page.locator('#replay-result')).toContainText('Checkmate');
await expect(page.locator('#replay-exit')).toHaveText('Close replay');
await expect(page.locator('#replay-moves .move').nth(3)).toHaveText('Qh4#');
await page.keyboard.press('End');
await expect(page.locator('[data-square="e1"]')).toHaveClass(/mated-king/);
await expect(page.locator('[data-square="h4"]')).toHaveClass(/last-move-to/);
// Escape leaves the replay; with no live game the start position shows.
await page.keyboard.press('Escape');
await expect(page.locator('body')).toHaveAttribute('data-mode', 'live');
await expect(page.locator('#turn-text')).toHaveText('No game');
await expect(page).toHaveURL(/\/$/);
});
test('an anonymous visitor replays from the device list', async ({ page }) => {
await page.goto('/');
await expect(page.locator('#auth-label')).toHaveText('Sign in');
await startGame(page, { color: 'black' });
await expectPlies(page, 1);
// Black at the bottom while playing Black.
await expect(page.locator('.coordinates.ranks span').first()).toHaveText('1');
await page.click('#games-btn');
await expect(page.locator('#games-signin-note')).toBeVisible();
const row = page.locator('#local-games-list .game-row');
await expect(row).toHaveCount(1);
await expect(row).toContainText('Stockfish L0 vs You');
await row.click();
await expect(page.locator('body')).toHaveAttribute('data-mode', 'replay');
await expect(page.locator('#replay-ply')).toHaveText('1 / 1');
// The viewer's side stays at the bottom in the replay.
await expect(page.locator('.coordinates.ranks span').first()).toHaveText('1');
expect(await page.evaluate(() => replay.history.initialFen)).toBe(START_FEN);
await page.click('#replay-exit');
await expect(page.locator('#turn-text')).toContainText('Your move (Black)');
});
});
+6 -1
View File
@@ -10,6 +10,8 @@ CHESS_SERVER_EXEC=${1:-"bin/chess-server"}
export CHESS_DSN="$CHESS_TEST_DSN"
PID_FILE="/tmp/chess-server_test.pid"
API_PORT=${API_PORT:-8080}
# Set WEB_PORT to also serve the embedded web client (for test/e2e).
WEB_PORT=${WEB_PORT:-}
LOG_LEVEL=${LOG_LEVEL:-debug}
LOG_HTTP=${LOG_HTTP:-true}
@@ -95,7 +97,7 @@ echo ""
echo "Configuration:"
echo " Executable: $CHESS_SERVER_EXEC"
echo " Database: \$CHESS_TEST_DSN (tables dropped on exit)"
echo " Port: $API_PORT"
echo " Port: $API_PORT${WEB_PORT:+ (web client on $WEB_PORT)}"
echo " Mode: Development (relaxed rate limits; PostgreSQL storage)"
echo " Log level: $LOG_LEVEL (HTTP requests: $LOG_HTTP)"
echo " Purpose: Backend for chess-server tests"
@@ -111,10 +113,13 @@ echo "Starting server..."
echo ""
# Start chess-server in foreground with dev mode and storage
WEB_FLAGS=()
[ -n "$WEB_PORT" ] && WEB_FLAGS=(-serve -web-port "$WEB_PORT")
"$CHESS_SERVER_EXEC" \
-dev \
-log-level "$LOG_LEVEL" \
-log-http="$LOG_HTTP" \
-api-port "$API_PORT" \
"${WEB_FLAGS[@]}" \
-pid "$PID_FILE" \
-pid-lock