From a7ec49fcad94dc5950f58c740380feafaca79dc7 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Fri, 20 Feb 2026 20:08:17 +0100 Subject: [PATCH] Add Playwright accessibility and mobile layout e2e tests Set up end-to-end test infrastructure with Docker Compose test environment, Playwright config, and automated global setup/teardown that seeds a test database and creates fixtures via the API. Tests cover 11 pages across both dark/light themes for WCAG 2.0 AA accessibility (axe-core), and across 3 viewports (mobile, tablet, desktop) for horizontal overflow and touch target validation. Co-Authored-By: Claude Opus 4.6 --- ...ernize-website-design-and-look-and-feel.md | 10 +- docker-compose.test.yml | 36 ++++++ frontend/.gitignore | 6 + frontend/e2e/accessibility.spec.ts | 57 ++++++++++ frontend/e2e/fixtures.ts | 17 +++ frontend/e2e/global-setup.ts | 107 ++++++++++++++++++ frontend/e2e/global-teardown.ts | 21 ++++ frontend/e2e/mobile.spec.ts | 71 ++++++++++++ frontend/package-lock.json | 89 +++++++++++++++ frontend/package.json | 6 +- frontend/playwright.config.ts | 27 +++++ frontend/tsconfig.node.json | 2 +- 12 files changed, 442 insertions(+), 7 deletions(-) create mode 100644 docker-compose.test.yml create mode 100644 frontend/e2e/accessibility.spec.ts create mode 100644 frontend/e2e/fixtures.ts create mode 100644 frontend/e2e/global-setup.ts create mode 100644 frontend/e2e/global-teardown.ts create mode 100644 frontend/e2e/mobile.spec.ts create mode 100644 frontend/playwright.config.ts diff --git a/.beans/nuzlocke-tracker-dpw7--modernize-website-design-and-look-and-feel.md b/.beans/nuzlocke-tracker-dpw7--modernize-website-design-and-look-and-feel.md index 8b5d374..6b70016 100644 --- a/.beans/nuzlocke-tracker-dpw7--modernize-website-design-and-look-and-feel.md +++ b/.beans/nuzlocke-tracker-dpw7--modernize-website-design-and-look-and-feel.md @@ -1,11 +1,11 @@ --- # nuzlocke-tracker-dpw7 title: Modernize website design and look-and-feel -status: in-progress +status: completed type: feature priority: normal created_at: 2026-02-17T19:16:39Z -updated_at: 2026-02-17T21:04:45Z +updated_at: 2026-02-20T19:05:21Z --- Overhaul the UI to a dark-first, techy aesthetic with a cohesive brand identity derived from the ANT steel ant logo. @@ -93,9 +93,9 @@ Self-host **Geist** (or Inter/JetBrains Mono pairing): - [x] Update badge/indicator styles (TypeBadge, RuleBadges, EncounterMethodBadge) - [x] Add dark/light mode toggle to nav - [x] Polish hover states and transitions across all interactive elements -- [ ] Add automated Playwright accessibility and mobile layout tests -- [ ] Verify accessibility (contrast ratios, focus indicators) -- [ ] Verify mobile layout and touch targets +- [x] Add automated Playwright accessibility and mobile layout tests +- [x] Verify accessibility (contrast ratios, focus indicators) +- [x] Verify mobile layout and touch targets ## Automated verification approach diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..8ee3a13 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,36 @@ +services: + test-db: + image: postgres:16-alpine + ports: + - "5433:5432" + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=nuzlocke_test + tmpfs: + - /var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 2s + timeout: 5s + retries: 10 + restart: "no" + + test-api: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "8000:8000" + environment: + - DATABASE_URL=postgresql+asyncpg://postgres:postgres@test-db:5432/nuzlocke_test + - DEBUG=true + depends_on: + test-db: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8000/ || exit 1"] + interval: 3s + timeout: 5s + retries: 15 + restart: "no" diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..858498d 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -12,6 +12,12 @@ dist dist-ssr *.local +# Playwright +e2e/.fixtures.json +e2e/screenshots/ +test-results/ +playwright-report/ + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts new file mode 100644 index 0000000..889489c --- /dev/null +++ b/frontend/e2e/accessibility.spec.ts @@ -0,0 +1,57 @@ +import AxeBuilder from '@axe-core/playwright' +import { expect, test } from '@playwright/test' + +import { loadFixtures } from './fixtures' + +const fixtures = loadFixtures() + +const pages = [ + { name: 'Home', path: '/' }, + { name: 'RunList', path: '/runs' }, + { name: 'NewRun', path: '/runs/new' }, + { name: 'RunEncounters', path: `/runs/${fixtures.runId}` }, + { name: 'GenlockeList', path: '/genlockes' }, + { name: 'NewGenlocke', path: '/genlockes/new' }, + { name: 'GenlockeDetail', path: `/genlockes/${fixtures.genlockeId}` }, + { name: 'Stats', path: '/stats' }, + { name: 'AdminGames', path: '/admin/games' }, + { name: 'AdminPokemon', path: '/admin/pokemon' }, + { name: 'AdminEvolutions', path: '/admin/evolutions' }, +] + +const themes = ['dark', 'light'] as const + +for (const theme of themes) { + test.describe(`Accessibility — ${theme} mode`, () => { + test.use({ + storageState: undefined, + }) + + for (const { name, path } of pages) { + test(`${name} (${path}) has no WCAG violations`, async ({ page }) => { + // Set theme before navigation + await page.addInitScript((t) => { + localStorage.setItem('ant-theme', t) + }, theme) + + await page.goto(path, { waitUntil: 'networkidle' }) + + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa']) + .analyze() + + const violations = results.violations.map((v) => ({ + id: v.id, + impact: v.impact, + description: v.description, + nodes: v.nodes.length, + })) + + expect( + violations, + `${name} (${theme}): ${violations.length} accessibility violations found:\n${JSON.stringify(violations, null, 2)}`, + ).toHaveLength(0) + }) + } + }) +} diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts new file mode 100644 index 0000000..e03974b --- /dev/null +++ b/frontend/e2e/fixtures.ts @@ -0,0 +1,17 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +interface Fixtures { + gameId: number + runId: number + genlockeId: number +} + +let cached: Fixtures | null = null + +export function loadFixtures(): Fixtures { + if (cached) return cached + const raw = readFileSync(resolve(__dirname, '.fixtures.json'), 'utf-8') + cached = JSON.parse(raw) as Fixtures + return cached +} diff --git a/frontend/e2e/global-setup.ts b/frontend/e2e/global-setup.ts new file mode 100644 index 0000000..49eb03b --- /dev/null +++ b/frontend/e2e/global-setup.ts @@ -0,0 +1,107 @@ +import { execSync } from 'node:child_process' +import { writeFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const API_BASE = 'http://localhost:8000/api/v1' +const COMPOSE_FILE = resolve(__dirname, '../../docker-compose.test.yml') +const FIXTURES_PATH = resolve(__dirname, '.fixtures.json') + +function run(cmd: string): string { + console.log(`[setup] ${cmd}`) + return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'inherit'] }) +} + +async function api( + path: string, + options?: RequestInit, +): Promise { + const res = await fetch(`${API_BASE}${path}`, { + headers: { 'Content-Type': 'application/json' }, + ...options, + }) + if (!res.ok) { + const body = await res.text() + throw new Error(`API ${options?.method ?? 'GET'} ${path} → ${res.status}: ${body}`) + } + return res.json() as Promise +} + +export default async function globalSetup() { + // 1. Start test DB + API + run(`docker compose -f ${COMPOSE_FILE} up -d --wait`) + + // 2. Run migrations + run( + `docker compose -f ${COMPOSE_FILE} exec -T test-api alembic -c /app/alembic.ini upgrade head`, + ) + + // 3. Seed reference data (run from /app/src where the app package lives) + run( + `docker compose -f ${COMPOSE_FILE} exec -T -w /app/src test-api python -m app.seeds`, + ) + + // 4. Create test fixtures via API + const games = await api>('/games') + const game = games[0] + if (!game) throw new Error('No games found after seeding') + + const routes = await api>( + `/games/${game.id}/routes?flat=true`, + ) + // Pick leaf routes (no children — a route is a leaf if no other route has it as parent) + const parentIds = new Set(routes.map((r) => r.parentRouteId).filter(Boolean)) + const leafRoutes = routes.filter((r) => !parentIds.has(r.id)) + if (leafRoutes.length < 3) throw new Error(`Need ≥3 leaf routes, found ${leafRoutes.length}`) + + const pokemonRes = await api<{ items: Array<{ id: number; name: string }> }>( + '/pokemon?limit=10', + ) + const pokemon = pokemonRes.items + if (pokemon.length < 3) throw new Error(`Need ≥3 pokemon, found ${pokemon.length}`) + + // Create a test run + const testRun = await api<{ id: number }>('/runs', { + method: 'POST', + body: JSON.stringify({ + gameId: game.id, + name: 'E2E Test Run', + rules: { duplicatesClause: true, shinyClause: true }, + }), + }) + + // Create encounters: caught, fainted, missed + const statuses = ['caught', 'fainted', 'missed'] as const + for (let i = 0; i < 3; i++) { + await api(`/runs/${testRun.id}/encounters`, { + method: 'POST', + body: JSON.stringify({ + routeId: leafRoutes[i]!.id, + pokemonId: pokemon[i]!.id, + nickname: `Test ${statuses[i]}`, + status: statuses[i], + catchLevel: statuses[i] === 'missed' ? null : 5 + i * 10, + }), + }) + } + + // Create a genlocke with 2 game legs + const secondGame = games[1] ?? game + const genlocke = await api<{ id: number }>('/genlockes', { + method: 'POST', + body: JSON.stringify({ + name: 'E2E Test Genlocke', + gameIds: [game.id, secondGame.id], + genlockeRules: {}, + nuzlockeRules: { duplicatesClause: true }, + }), + }) + + // 5. Write fixtures file + const fixtures = { + gameId: game.id, + runId: testRun.id, + genlockeId: genlocke.id, + } + writeFileSync(FIXTURES_PATH, JSON.stringify(fixtures, null, 2)) + console.log('[setup] Fixtures written:', fixtures) +} diff --git a/frontend/e2e/global-teardown.ts b/frontend/e2e/global-teardown.ts new file mode 100644 index 0000000..723639d --- /dev/null +++ b/frontend/e2e/global-teardown.ts @@ -0,0 +1,21 @@ +import { execSync } from 'node:child_process' +import { rmSync } from 'node:fs' +import { resolve } from 'node:path' + +const COMPOSE_FILE = resolve(__dirname, '../../docker-compose.test.yml') +const FIXTURES_PATH = resolve(__dirname, '.fixtures.json') + +export default async function globalTeardown() { + console.log('[teardown] Stopping test containers...') + execSync(`docker compose -f ${COMPOSE_FILE} down -v`, { + encoding: 'utf-8', + stdio: 'inherit', + }) + + try { + rmSync(FIXTURES_PATH) + console.log('[teardown] Removed fixtures file') + } catch { + // File may not exist if setup failed + } +} diff --git a/frontend/e2e/mobile.spec.ts b/frontend/e2e/mobile.spec.ts new file mode 100644 index 0000000..b65628c --- /dev/null +++ b/frontend/e2e/mobile.spec.ts @@ -0,0 +1,71 @@ +import AxeBuilder from '@axe-core/playwright' +import { expect, test } from '@playwright/test' + +import { loadFixtures } from './fixtures' + +const fixtures = loadFixtures() + +const pages = [ + { name: 'Home', path: '/' }, + { name: 'RunList', path: '/runs' }, + { name: 'NewRun', path: '/runs/new' }, + { name: 'RunEncounters', path: `/runs/${fixtures.runId}` }, + { name: 'GenlockeList', path: '/genlockes' }, + { name: 'NewGenlocke', path: '/genlockes/new' }, + { name: 'GenlockeDetail', path: `/genlockes/${fixtures.genlockeId}` }, + { name: 'Stats', path: '/stats' }, + { name: 'AdminGames', path: '/admin/games' }, + { name: 'AdminPokemon', path: '/admin/pokemon' }, + { name: 'AdminEvolutions', path: '/admin/evolutions' }, +] + +const viewports = [ + { name: 'mobile', width: 375, height: 667 }, + { name: 'tablet', width: 768, height: 1024 }, + { name: 'desktop', width: 1280, height: 800 }, +] as const + +for (const viewport of viewports) { + test.describe(`Mobile layout — ${viewport.name} (${viewport.width}x${viewport.height})`, () => { + test.use({ + viewport: { width: viewport.width, height: viewport.height }, + }) + + for (const { name, path } of pages) { + test(`${name} (${path}) has no overflow or touch target issues`, async ({ page }) => { + await page.goto(path, { waitUntil: 'networkidle' }) + + // Assert no horizontal overflow + const overflow = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + innerWidth: window.innerWidth, + })) + expect( + overflow.scrollWidth, + `${name} at ${viewport.name}: horizontal overflow detected (scrollWidth=${overflow.scrollWidth}, innerWidth=${overflow.innerWidth})`, + ).toBeLessThanOrEqual(overflow.innerWidth) + + // Run axe-core target-size rule for touch target validation + const axeResults = await new AxeBuilder({ page }) + .withRules(['target-size']) + .analyze() + + const violations = axeResults.violations.map((v) => ({ + id: v.id, + impact: v.impact, + nodes: v.nodes.length, + })) + expect( + violations, + `${name} at ${viewport.name}: ${violations.length} touch target violations:\n${JSON.stringify(violations, null, 2)}`, + ).toHaveLength(0) + + // Capture full-page screenshot + await page.screenshot({ + path: `e2e/screenshots/${viewport.name}/${name.toLowerCase()}.png`, + fullPage: true, + }) + }) + } + }) +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e3707a0..3eb80ea 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,8 @@ "sonner": "2.0.7" }, "devDependencies": { + "@axe-core/playwright": "4.11.1", + "@playwright/test": "1.58.2", "@tailwindcss/vite": "4.1.18", "@types/node": "24.10.10", "@types/react": "19.2.11", @@ -31,6 +33,19 @@ "vitest": "4.0.18" } }, + "node_modules/@axe-core/playwright": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.1.tgz", + "integrity": "sha512-mKEfoUIB1MkVTht0BGZFXtSAEKXMJoDkyV5YZ9jbBmZCcWDz71tegNsdTkIN8zc/yMi5Gm2kx7Z5YQ9PfWNAWw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.11.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -1506,6 +1521,22 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.2", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", @@ -2472,6 +2503,16 @@ "node": ">=12" } }, + "node_modules/axe-core": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", @@ -3234,6 +3275,54 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "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.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index 232c130..69ed26e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,9 @@ "format:check": "oxfmt --check src/", "preview": "vite preview", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "@dnd-kit/core": "6.3.1", @@ -24,6 +26,8 @@ "sonner": "2.0.7" }, "devDependencies": { + "@axe-core/playwright": "4.11.1", + "@playwright/test": "1.58.2", "@tailwindcss/vite": "4.1.18", "@types/node": "24.10.10", "@types/react": "19.2.11", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..667a408 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + globalSetup: './e2e/global-setup.ts', + globalTeardown: './e2e/global-teardown.ts', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npm run dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + }, +}) diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index 8a67f62..220c586 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -22,5 +22,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "playwright.config.ts"] }