Add Playwright accessibility and mobile layout e2e tests
All checks were successful
CI / backend-lint (push) Successful in 49s
CI / actions-lint (push) Successful in 15s
CI / frontend-lint (push) Successful in 1m2s

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 20:08:17 +01:00
parent a381633413
commit a7ec49fcad
12 changed files with 442 additions and 7 deletions

View File

@@ -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)
})
}
})
}

17
frontend/e2e/fixtures.ts Normal file
View File

@@ -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
}

View File

@@ -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<T>(
path: string,
options?: RequestInit,
): Promise<T> {
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<T>
}
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<Array<{ id: number; name: string }>>('/games')
const game = games[0]
if (!game) throw new Error('No games found after seeding')
const routes = await api<Array<{ id: number; name: string; parentRouteId: number | null }>>(
`/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)
}

View File

@@ -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
}
}

View File

@@ -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,
})
})
}
})
}