v0.15.0 password recovery, phone layout, CLI pager, embed policy fixes
test / test (push) Canceled after 0s
test / e2e (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-19 18:15:31 -04:00
1 parent a999177f0c
commit e1fa5242e3
48 files changed
+2101 -135

No files matched your search

+14 -8
View File
@@ -38,11 +38,12 @@ 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, and `WEB_PORT` also
serves the embedded web client (needed by the end-to-end suite):
Logging can be adjusted without editing the script; `WEB_PORT` also serves
the embedded web client and `MAIL_DIR` turns on password recovery with the
email written to that directory (both used 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
WEB_PORT=9090 MAIL_DIR=/tmp/chess-mail test/run-test-server.sh bin/chess-server
```
### Pre-configured Users
@@ -226,20 +227,22 @@ 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.
a deep link to a checkmate, steps back through the browser history, replays
an anonymous game from the device list, checks that a phone-sized viewport
holds the board and the controls without scrolling, and resets a forgotten
password through the link in the emailed message (needs `MAIL_DIR`).
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 1: the test server with the web client and mail to a directory
WEB_PORT=9090 MAIL_DIR=/tmp/chess-mail 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
CHESS_E2E_MAIL_DIR=/tmp/chess-mail npm test # or, from the repository root: make test-e2e
```
Environment:
@@ -249,6 +252,9 @@ Environment:
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)
- `CHESS_E2E_MAIL_DIR`: the server's `-mail-dir` (`MAIL_DIR` of the test
server); the password-reset test reads the link from the newest file and
is skipped when unset
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
+91 -1
View File
@@ -6,6 +6,11 @@
const { test, expect } = require('@playwright/test');
const API = process.env.CHESS_E2E_API || 'http://localhost:8080';
// Where the server writes password-reset email (-mail-dir); the reset test
// is skipped without it.
const MAIL_DIR = process.env.CHESS_E2E_MAIL_DIR || '';
const fs = require('fs');
const path = require('path');
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.
@@ -174,6 +179,90 @@ test.describe('web replay', () => {
await expect(page).toHaveURL(/\/$/);
});
test('a phone fits the board and the controls without scrolling', async ({ browser }) => {
const context = await browser.newContext({ viewport: { width: 412, height: 780 }, isMobile: true, hasTouch: true });
const page = await context.newPage();
await page.goto('/');
const fits = () => page.evaluate(() => ({
w: document.documentElement.scrollWidth <= innerWidth,
h: document.documentElement.scrollHeight <= innerHeight,
}));
expect(await fits()).toEqual({ w: true, h: true });
// The controls sit in one row of short labels.
await expect(page.locator('#new-game-btn')).toHaveText('New');
const [newBox, resignBox] = await Promise.all([
page.locator('#new-game-btn').boundingBox(), page.locator('#resign-btn').boundingBox()]);
expect(Math.abs(newBox.y - resignBox.y)).toBeLessThan(2);
expect(newBox.height).toBeLessThan(50);
await expect(page.locator('.pane-live .toolbar')).toBeInViewport();
// A replay keeps its controls on screen too.
const created = await (await context.request.post(`${API}/api/games`, { data: { white: { type: 1 }, black: { type: 1 } } })).json();
await context.request.post(`${API}/api/games/${created.gameId}/moves`, { data: { move: 'e2e4' } });
await page.goto(`/?replay=${created.gameId}&ply=1`);
await expect(page.locator('body')).toHaveAttribute('data-mode', 'replay');
expect(await fits()).toEqual({ w: true, h: true });
await expect(page.locator('#replay-exit')).toBeInViewport();
await expect(page.locator('#replay-link')).toBeInViewport();
await context.close();
});
test('a forgotten password is reset through the emailed link', async ({ page, request }) => {
test.skip(!MAIL_DIR, 'CHESS_E2E_MAIL_DIR is not set (start the server with MAIL_DIR)');
const username = `reset${Date.now().toString(36)}${Math.floor(Math.random() * 1000)}`;
const email = `${username}@example.test`;
const created = await request.post(`${API}/api/auth/register`, {
data: { username, email, password: 'OldPass123' },
});
expect(created.status(), await created.text()).toBe(201);
await page.goto('/');
await expect(page.locator('#storage-dot')).toHaveAttribute('data-status', 'ok');
await page.click('#auth-btn');
// A wrong password keeps the focus in the password field.
await page.fill('#login-identifier', username);
await page.fill('#login-password', 'wrong');
await page.click('#login-submit-btn');
await expect(page.locator('#auth-modal-message')).toContainText('Wrong username or password');
await expect(page.locator('#login-password')).toBeFocused();
await expect(page.locator('#forgot-btn')).toBeVisible();
await page.click('#forgot-btn');
await page.fill('#recover-identifier', email);
const mails = () => (fs.existsSync(MAIL_DIR) ? fs.readdirSync(MAIL_DIR) : []);
const before = mails().length;
await page.click('#recover-submit-btn');
await expect(page.locator('#auth-modal-message')).toContainText('on its way');
await expect.poll(() => mails().length, { timeout: 10_000 }).toBeGreaterThan(before);
const files = fs.readdirSync(MAIL_DIR).map(f => path.join(MAIL_DIR, f))
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
const mail = fs.readFileSync(files[0], 'utf8');
expect(mail).toContain(`To: ${email}`);
const link = /(https?:\/\/\S+\?reset=[A-Za-z0-9_-]+)/.exec(mail)[1];
const origin = new URL(page.url()).origin;
await page.goto(link.replace(/^https?:\/\/[^/]+/, origin));
await expect(page.locator('#reset-overlay')).toHaveClass(/show/);
// The token is removed from the address at once.
expect(page.url()).not.toContain('reset=');
await page.fill('#reset-password', 'NewPass123');
await page.fill('#reset-password-2', 'NewPass124');
await page.click('#reset-submit-btn');
await expect(page.locator('#reset-modal-message')).toContainText('differ');
await page.fill('#reset-password-2', 'NewPass123');
await page.keyboard.press('Enter');
await expect(page.locator('#auth-label')).toHaveText(username);
await expect(page.locator('#reset-overlay')).not.toHaveClass(/show/, { timeout: 5_000 });
// The old password is gone, the new one and the link are not reusable.
const old = await request.post(`${API}/api/auth/login`, { data: { identifier: username, password: 'OldPass123' } });
expect(old.status()).toBe(401);
const fresh = await request.post(`${API}/api/auth/login`, { data: { identifier: username, password: 'NewPass123' } });
expect(fresh.status()).toBe(200);
await page.goto(link.replace(/^https?:\/\/[^/]+/, origin));
await page.fill('#reset-password', 'NewPass125');
await page.fill('#reset-password-2', 'NewPass125');
await page.click('#reset-submit-btn');
await expect(page.locator('#reset-modal-message')).toContainText('invalid or has expired');
});
test('an anonymous visitor replays from the device list', async ({ page }) => {
await page.goto('/');
await expect(page.locator('#auth-label')).toHaveText('Sign in');
@@ -193,6 +282,7 @@ test.describe('web 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)');
await expect(page.locator('#turn-text')).toHaveText('Your move');
await expect(page.locator('#turn-dot')).toHaveAttribute('data-status', 'black');
});
});
+10 -1
View File
@@ -10,8 +10,11 @@ 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).
# Set WEB_PORT to also serve the embedded web client (for test/e2e), and
# MAIL_DIR to enable password recovery with the email written to that
# directory instead of sent.
WEB_PORT=${WEB_PORT:-}
MAIL_DIR=${MAIL_DIR:-}
LOG_LEVEL=${LOG_LEVEL:-debug}
LOG_HTTP=${LOG_HTTP:-true}
@@ -98,6 +101,7 @@ echo "Configuration:"
echo " Executable: $CHESS_SERVER_EXEC"
echo " Database: \$CHESS_TEST_DSN (tables dropped on exit)"
echo " Port: $API_PORT${WEB_PORT:+ (web client on $WEB_PORT)}"
echo " Mail: ${MAIL_DIR:-off (set MAIL_DIR for password recovery)}"
echo " Mode: Development (relaxed rate limits; PostgreSQL storage)"
echo " Log level: $LOG_LEVEL (HTTP requests: $LOG_HTTP)"
echo " Purpose: Backend for chess-server tests"
@@ -115,6 +119,11 @@ echo ""
# Start chess-server in foreground with dev mode and storage
WEB_FLAGS=()
[ -n "$WEB_PORT" ] && WEB_FLAGS=(-serve -web-port "$WEB_PORT")
if [ -n "$MAIL_DIR" ]; then
mkdir -p "$MAIL_DIR"
WEB_FLAGS+=(-mail-dir "$MAIL_DIR" -mail-from "Chess <chess@localhost>" \
-public-url "http://localhost:${WEB_PORT:-9090}/")
fi
"$CHESS_SERVER_EXEC" \
-dev \
-log-level "$LOG_LEVEL" \