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 <noreply@anthropic.com>
This commit is contained in:
6
frontend/.gitignore
vendored
6
frontend/.gitignore
vendored
@@ -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
|
||||
|
||||
57
frontend/e2e/accessibility.spec.ts
Normal file
57
frontend/e2e/accessibility.spec.ts
Normal 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
17
frontend/e2e/fixtures.ts
Normal 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
|
||||
}
|
||||
107
frontend/e2e/global-setup.ts
Normal file
107
frontend/e2e/global-setup.ts
Normal 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)
|
||||
}
|
||||
21
frontend/e2e/global-teardown.ts
Normal file
21
frontend/e2e/global-teardown.ts
Normal 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
|
||||
}
|
||||
}
|
||||
71
frontend/e2e/mobile.spec.ts
Normal file
71
frontend/e2e/mobile.spec.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
89
frontend/package-lock.json
generated
89
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
27
frontend/playwright.config.ts
Normal file
27
frontend/playwright.config.ts
Normal file
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -22,5 +22,5 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
"include": ["vite.config.ts", "playwright.config.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user