diff --git a/webclient/.gitignore b/webclient/.gitignore index ed0268c3b..fc4e6b035 100644 --- a/webclient/.gitignore +++ b/webclient/.gitignore @@ -15,6 +15,7 @@ # testing /coverage +/test-results # production /build diff --git a/webclient/README.md b/webclient/README.md index 774b84ffe..4cf461f33 100644 --- a/webclient/README.md +++ b/webclient/README.md @@ -15,6 +15,7 @@ React 19 + TypeScript, built with [Vite](https://vite.dev/) 8. State via Redux T ## Prerequisites - Node.js and npm +- Docker Desktop — only required for `npm run test:e2e` (spins up servatrice + MySQL via docker compose) - Run every command below from the `webclient/` directory ## Getting started @@ -40,11 +41,18 @@ npm start - `npm run test:watch` — Vitest in watch mode - `npm run test:integration` — integration specs via `vitest.integration.config.ts` - `npm run test:coverage` / `npm run test:integration:coverage` — the above with v8 coverage +- `npm run test:e2e` — full Playwright e2e pipeline (requires Docker Desktop): `test:e2e:up` → `test:e2e:run` → `test:e2e:down`, preserving the run's exit code +- `npm run test:e2e:run` — run specs against an already-running stack (use this in dev loops) +- `npm run test:e2e:open` — Playwright UI mode for debugging specs interactively +- `npm run test:e2e:up` / `npm run test:e2e:down` — bring the dockerized servatrice + MySQL stack up / down (volumes are wiped on `down`) + +The `pretest:e2e` hook runs `playwright install --with-deps chromium` automatically on first invocation, so no manual browser install is needed. ### Quality - `npm run lint` / `npm run lint:fix` — ESLint over `src/` -- `npm run golden` — `lint` + `test` + `test:integration`; the CI-equivalent gate to run before declaring work done +- `npm run golden` — `lint` + `test` + `test:integration`; the fast CI-equivalent gate to run before declaring work done +- `npm run golden:full` — `golden` + `test:e2e`; the full pre-merge gate (requires Docker Desktop) ### Codegen & i18n diff --git a/webclient/e2e/docker/docker-compose.e2e.yml b/webclient/e2e/docker/docker-compose.e2e.yml new file mode 100644 index 000000000..62c5c6f39 --- /dev/null +++ b/webclient/e2e/docker/docker-compose.e2e.yml @@ -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: diff --git a/webclient/e2e/docker/servatrice-e2e.ini b/webclient/e2e/docker/servatrice-e2e.ini new file mode 100644 index 000000000..6979ef290 --- /dev/null +++ b/webclient/e2e/docker/servatrice-e2e.ini @@ -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 diff --git a/webclient/e2e/global-setup.ts b/webclient/e2e/global-setup.ts new file mode 100644 index 000000000..4cf06e08d --- /dev/null +++ b/webclient/e2e/global-setup.ts @@ -0,0 +1,8 @@ +import { waitForServatriceReady } from './helpers/servatrice'; + +export default async function globalSetup(): Promise { + // 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); +} diff --git a/webclient/e2e/helpers/seedLocalHost.ts b/webclient/e2e/helpers/seedLocalHost.ts new file mode 100644 index 000000000..eae63d698 --- /dev/null +++ b/webclient/e2e/helpers/seedLocalHost.ts @@ -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 { + 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((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(); +} diff --git a/webclient/e2e/helpers/servatrice.ts b/webclient/e2e/helpers/servatrice.ts new file mode 100644 index 000000000..a4dc0b895 --- /dev/null +++ b/webclient/e2e/helpers/servatrice.ts @@ -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 { + 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 { + 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 { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/webclient/e2e/helpers/testUser.ts b/webclient/e2e/helpers/testUser.ts new file mode 100644 index 000000000..3c35eebee --- /dev/null +++ b/webclient/e2e/helpers/testUser.ts @@ -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 { + 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 { + 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(); +} diff --git a/webclient/e2e/playwright.config.ts b/webclient/e2e/playwright.config.ts new file mode 100644 index 000000000..2f81593dd --- /dev/null +++ b/webclient/e2e/playwright.config.ts @@ -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'] } }], +}); diff --git a/webclient/e2e/specs/register-and-login.spec.ts b/webclient/e2e/specs/register-and-login.spec.ts new file mode 100644 index 000000000..1b72846f3 --- /dev/null +++ b/webclient/e2e/specs/register-and-login.spec.ts @@ -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(); +}); diff --git a/webclient/e2e/tsconfig.json b/webclient/e2e/tsconfig.json new file mode 100644 index 000000000..ace98ba30 --- /dev/null +++ b/webclient/e2e/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["node"], + "rootDir": ".", + "noEmit": true + }, + "include": ["**/*.ts"] +} diff --git a/webclient/package-lock.json b/webclient/package-lock.json index d36442220..49ce37c9e 100644 --- a/webclient/package-lock.json +++ b/webclient/package-lock.json @@ -42,6 +42,7 @@ "@bufbuild/protoc-gen-es": "^2.11.0", "@eslint/js": "^10.0.1", "@mui/types": "^9.0.0", + "@playwright/test": "^1.51.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^16.3.2", @@ -52,6 +53,7 @@ "@types/react-dom": "^19.0.0", "@types/react-virtualized-auto-sizer": "^1.0.1", "@types/react-window": "^1.8.5", + "@types/ws": "^8.5.12", "@typescript-eslint/eslint-plugin": "^8.58.2", "@typescript-eslint/parser": "^8.58.2", "@vitejs/plugin-react": "^6.0.1", @@ -68,7 +70,8 @@ "typescript": "~6.0", "typescript-eslint": "^8.58.2", "vite": "^8.0.8", - "vitest": "^4.1.4" + "vitest": "^4.1.4", + "ws": "^8.18.0" } }, "node_modules/@adobe/css-tools": { @@ -1359,6 +1362,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -2159,6 +2178,16 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.58.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", @@ -5044,6 +5073,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", @@ -6154,6 +6230,28 @@ "dev": true, "license": "MIT" }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/webclient/package.json b/webclient/package.json index 2312f5694..c66612ed8 100644 --- a/webclient/package.json +++ b/webclient/package.json @@ -3,27 +3,35 @@ "version": "1.0.0", "private": true, "scripts": { - "prebuild": "npm run proto:generate && node prebuild.js", - "prestart": "npm run proto:generate && node prebuild.js", "build": "vite build", - "start": "vite", - "preview": "vite preview", + "diagram": "npm run diagram:simple && npm run diagram:detailed && npm run diagram:flow", + "diagram:simple": "npx -y -p @mermaid-js/mermaid-cli -p puppeteer mmdc -i architecture/simple.mmd -o architecture/simple.png -b white -s 2", + "diagram:detailed": "npx -y -p @mermaid-js/mermaid-cli -p puppeteer mmdc -i architecture/detailed.mmd -o architecture/detailed.png -b white -s 2", + "diagram:flow": "npx -y -p @mermaid-js/mermaid-cli -p puppeteer mmdc -i architecture/flow.mmd -o architecture/flow.png -b white -s 2", + "golden": "npm run lint && npm run test && npm run test:integration", + "golden:coverage": "npm run lint && npm run test:coverage && npm run test:integration:coverage", + "golden:full": "npm run golden && npm run test:e2e", + "lint": "eslint src", + "lint:fix": "eslint src --fix", "test": "vitest run", "test:coverage": "npm run test -- --coverage", "test:watch": "vitest", "test:integration": "vitest run --config vitest.integration.config.ts", "test:integration:coverage": "npm run test:integration -- --coverage", - "lint": "eslint src", - "lint:fix": "eslint src --fix", - "golden": "npm run lint && npm run test && npm run test:integration", - "golden:coverage": "npm run lint && npm run test:coverage && npm run test:integration:coverage", + "test:e2e": "bash -c \"npm run test:e2e:up && (npm run test:e2e:run; EC=$?; npm run test:e2e:down; exit $EC)\"", + "test:e2e:open": "playwright test --config e2e/playwright.config.ts --ui", + "test:e2e:run": "playwright test --config e2e/playwright.config.ts", + "test:e2e:up": "docker compose -f e2e/docker/docker-compose.e2e.yml up -d --build", + "test:e2e:down": "docker compose -f e2e/docker/docker-compose.e2e.yml down -v", + "test:e2e:install-browsers": "npx playwright install --with-deps chromium", + "pretest:e2e": "npm run test:e2e:install-browsers", + "prebuild": "npm run proto:generate && node prebuild.js", "prepare": "cd .. && husky", - "translate": "node prebuild.js -i18nOnly", + "prestart": "npm run proto:generate && node prebuild.js", + "preview": "vite preview", "proto:generate": "npx buf generate", - "diagram": "npm run diagram:simple && npm run diagram:detailed && npm run diagram:flow", - "diagram:simple": "npx -y -p @mermaid-js/mermaid-cli -p puppeteer mmdc -i architecture/simple.mmd -o architecture/simple.png -b white -s 2", - "diagram:detailed": "npx -y -p @mermaid-js/mermaid-cli -p puppeteer mmdc -i architecture/detailed.mmd -o architecture/detailed.png -b white -s 2", - "diagram:flow": "npx -y -p @mermaid-js/mermaid-cli -p puppeteer mmdc -i architecture/flow.mmd -o architecture/flow.png -b white -s 2" + "start": "vite", + "translate": "node prebuild.js -i18nOnly" }, "dependencies": { "@bufbuild/protobuf": "^2.11.0", @@ -59,6 +67,8 @@ "@bufbuild/buf": "^1.68.1", "@bufbuild/protoc-gen-es": "^2.11.0", "@eslint/js": "^10.0.1", + "@playwright/test": "^1.51.0", + "@types/ws": "^8.5.12", "@mui/types": "^9.0.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.4.0", @@ -86,7 +96,8 @@ "typescript": "~6.0", "typescript-eslint": "^8.58.2", "vite": "^8.0.8", - "vitest": "^4.1.4" + "vitest": "^4.1.4", + "ws": "^8.18.0" }, "browserslist": { "production": [