Playwright E2E suite (not working yet)

This commit is contained in:
seavor 2026-05-07 09:56:05 -05:00
parent f534cd79b7
commit 68050b56bc
13 changed files with 425 additions and 16 deletions

View file

@ -0,0 +1,41 @@
# Standalone compose used by the webclient e2e harness.
# Paths are relative to THIS file's directory (webclient/e2e/docker/), so the
# file must be invoked with `-f webclient/e2e/docker/docker-compose.e2e.yml`
# from anywhere — Docker Compose resolves relative mounts / build contexts
# against the compose file's own directory.
services:
mysql:
image: mysql:8
command: --sql_mode=
environment:
MYSQL_ROOT_PASSWORD: root-password
MYSQL_DATABASE: servatrice
MYSQL_USER: servatrice
MYSQL_PASSWORD: password
volumes:
- ../../../servatrice/servatrice.sql:/docker-entrypoint-initdb.d/servatrice.sql:ro
- cockatrice_e2e_mysql:/var/lib/mysql
servatrice:
build:
# Build context is the repo root so the Dockerfile can see the full
# C++ source tree (three levels up from this file).
context: ../../..
dockerfile: Dockerfile
image: cockatrice/servatrice:e2e
depends_on:
- mysql
ports:
- "4748:4748"
# Wait for MySQL's TCP port to accept connections before launching
# servatrice. The first-time `up --build` has to load servatrice.sql, which
# takes longer than any fixed sleep — without this wait, servatrice exits
# silently with code 0 when it can't reach the DB. The trailing `sleep 2`
# gives mysqld a moment after the port opens to finish accepting auth.
# global-setup.ts additionally polls the WebSocket port to avoid races.
entrypoint: "/bin/bash -c 'until (echo > /dev/tcp/mysql/3306) 2>/dev/null; do sleep 1; done; sleep 2; servatrice --config /tmp/servatrice.ini --log-to-console'"
volumes:
- ./servatrice-e2e.ini:/tmp/servatrice.ini:ro
volumes:
cockatrice_e2e_mysql:

View file

@ -0,0 +1,54 @@
; Servatrice config used only by the webclient e2e harness.
; Mounted into the servatrice container at /tmp/servatrice.ini by
; docker-compose.e2e.yml. Differs from servatrice-docker.ini by enabling
; registration, skipping email activation, and relaxing per-host rate limits
; so parallel Playwright workers from one machine don't trip them.
[server]
name="Cockatrice E2E"
logfile=server.log
writelog=0
[authentication]
; sql → registration + login hits the real accounts table, so the full
; register-then-login flow is exercised end-to-end.
method=sql
regonly=false
[users]
; Our test usernames are "e2e_" + 6 hex chars = 10 chars. Lower the min so
; that fits with room to spare if we ever shorten the prefix.
minnamelength=4
maxnamelength=12
allowlowercase=true
allowuppercase=true
allownumerics=true
allowedpunctuation=_.-
allowpunctuationprefix=false
disallowedwords="admin"
minpasswordlength=6
[registration]
enabled=true
requireemail=false
requireemailactivation=false
maxaccountsperemail=0
[database]
type=mysql
prefix=cockatrice
hostname=mysql
database=servatrice
user=servatrice
password=password
[security]
trusted_sources="127.0.0.1,::1"
enable_max_user_limit=false
max_users_per_address=0
max_tcp_users_per_address=0
max_websocket_users_per_address=0
[game]
store_replays=false
max_game_inactivity_time=120

View file

@ -0,0 +1,8 @@
import { waitForServatriceReady } from './helpers/servatrice';
export default async function globalSetup(): Promise<void> {
// The compose entrypoint sleeps 10s waiting for MySQL. On cold CI runs
// building the image on top of that can push the first WS frame well past
// 30s, so poll for up to 60s before giving up.
await waitForServatriceReady(60_000);
}

View file

@ -0,0 +1,48 @@
import type { Page } from '@playwright/test';
import { E2E_WS_URL } from './servatrice';
// Points the running webclient at the local servatrice without touching any
// webclient source. Strategy: navigate to the app once (which lets Dexie open
// the Webatrice IndexedDB at schema v1 and seed DefaultHosts), then wipe the
// `hosts` store and insert a single "Local E2E" row with lastSelected=true,
// then reload so useKnownHosts re-reads. On a fresh Playwright context the
// IDB is empty, so our seed is the only known host after reload — the Login
// form auto-selects it and fires its test-connection against :4748.
export async function seedLocalHostAndReload(page: Page, wsUrl: string = E2E_WS_URL): Promise<void> {
const parsed = new URL(wsUrl);
const host = parsed.hostname;
const port = parsed.port || '4748';
await page.goto('/');
await page.evaluate(async ({ host, port }) => {
await new Promise<void>((resolve, reject) => {
const open = indexedDB.open('Webatrice');
open.onerror = () => reject(open.error ?? new Error('failed to open Webatrice DB'));
open.onsuccess = () => {
const db = open.result;
if (!db.objectStoreNames.contains('hosts')) {
db.close();
reject(new Error('hosts object store missing — did Dexie finish opening?'));
return;
}
const tx = db.transaction('hosts', 'readwrite');
const store = tx.objectStore('hosts');
store.clear();
store.add({
name: 'Local E2E',
host,
port,
localHost: host,
localPort: port,
editable: false,
lastSelected: true,
});
tx.oncomplete = () => { db.close(); resolve(); };
tx.onerror = () => { db.close(); reject(tx.error ?? new Error('hosts write failed')); };
};
});
}, { host, port });
await page.reload();
}

View file

@ -0,0 +1,37 @@
import { WebSocket } from 'ws';
// The e2e docker-compose.yml publishes servatrice on localhost:4748.
export const E2E_WS_URL = 'ws://localhost:4748';
// Opens a WS to servatrice and resolves once the server sends *anything* back
// (ServerIdentification is the first frame). Used by global-setup to wait out
// the `sleep 10` in the docker-compose entrypoint before any spec runs.
export async function waitForServatriceReady(timeoutMs = 60_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const ok = await tryConnect(E2E_WS_URL).catch(() => false);
if (ok) {
return;
}
await sleep(1000);
}
throw new Error(`servatrice did not become ready at ${E2E_WS_URL} within ${timeoutMs}ms`);
}
function tryConnect(url: string): Promise<boolean> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
const done = (value: boolean, err?: Error) => {
try { ws.removeAllListeners(); ws.close(); } catch { /* ignore */ }
err ? reject(err) : resolve(value);
};
ws.once('message', () => done(true));
ws.once('error', (err) => done(false, err));
ws.once('close', () => done(false));
});
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}

View file

@ -0,0 +1,47 @@
import { randomBytes } from 'node:crypto';
import { expect, type Page } from '@playwright/test';
export interface TestUser {
userName: string;
password: string;
}
// Servatrice [users] rules (see webclient/e2e/docker/servatrice-e2e.ini):
// - 4-12 chars, lowercase/uppercase/numerics + "_.-", no leading punctuation
// - "admin" is blacklisted
// "e2e_" + 6 hex chars = 10 chars — fits comfortably and is unique per call.
export function generateUniqueUser(): TestUser {
return {
userName: 'e2e_' + randomBytes(3).toString('hex'),
password: 'TestPw123!',
};
}
export async function registerViaUi(page: Page, user: TestUser): Promise<void> {
await page.getByRole('button', { name: /register/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.locator('input[name="userName"]').fill(user.userName);
await dialog.locator('input[name="password"]').fill(user.password);
await dialog.locator('input[name="passwordConfirm"]').fill(user.password);
await dialog.getByRole('button', { name: /register/i }).click();
// With requireemailactivation=false the server returns RegistrationAccepted
// and the dialog closes. If activation were required an AccountActivation
// dialog would replace it — that's out of scope for this spec.
await expect(dialog).toBeHidden();
}
export async function loginViaUi(page: Page, user: TestUser): Promise<void> {
await page.locator('input[name="userName"]').fill(user.userName);
await page.locator('input[name="password"]').fill(user.password);
const loginButton = page.locator('button.loginForm-submit');
// The Login button is gated on a successful test-connection (fired
// automatically when the host loads). Wait it out rather than racing it.
await expect(loginButton).toBeEnabled();
await loginButton.click();
}

View file

@ -0,0 +1,28 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './specs',
fullyParallel: true,
workers: 2,
timeout: 60_000,
expect: { timeout: 15_000 },
reporter: [['list'], ['html', { outputFolder: '../coverage/e2e', open: 'never' }]],
globalSetup: './global-setup.ts',
use: {
baseURL: 'http://localhost:5173',
headless: true,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
webServer: {
// Run from the webclient root so Vite picks up its own vite.config.ts and
// `prestart` proto generation. Specs point the app at local servatrice by
// seeding IndexedDB via helpers/seedLocalHost.ts — no source changes needed.
command: 'npm run start',
cwd: '..',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});

View file

@ -0,0 +1,19 @@
import { test, expect } from '@playwright/test';
import { seedLocalHostAndReload } from '../helpers/seedLocalHost';
import { generateUniqueUser, loginViaUi, registerViaUi } from '../helpers/testUser';
test('a fresh user can register, then log in against local servatrice', async ({ page }) => {
const user = generateUniqueUser();
// Seed IndexedDB so the Login form auto-selects our Local E2E host instead
// of one of the production defaults. After this returns the app has been
// reloaded; AppShell uses MemoryRouter, so the address bar never reflects
// the active route — assert on DOM markers unique to each screen instead.
await seedLocalHostAndReload(page);
await expect(page.locator('.login-content')).toBeVisible();
await registerViaUi(page, user);
await loginViaUi(page, user);
await expect(page.locator('.serverRoomWrapper')).toBeVisible();
});

View file

@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["node"],
"rootDir": ".",
"noEmit": true
},
"include": ["**/*.ts"]
}