/** * Accessibility check with axe-core (docs/10 §2): home, game page, lobby, game screen (PC + phone). * Fails on "serious"/"critical" violations. Run: bun e2e/a11y.e2e.ts */ import { chromium, type Page } from 'playwright-core'; import { mkdtempSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { loadConfig } from '../apps/server/src/config'; import { startServer } from '../apps/server/src/server'; const PORT = 4520; const ORIGIN = `http://localhost:${PORT}`; const AXE = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8'); const app = startServer({ config: loadConfig({ PORT: String(PORT), DB_PATH: join(mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'bg-a11y-')), 'app.db'), PUBLIC_ORIGIN: ORIGIN }), log: () => {}, staticDir: join(import.meta.dir, '../apps/web/dist'), }); const browser = await chromium.launch({ executablePath: process.env.CHROME ?? '/usr/bin/google-chrome', headless: true }); let bad = 0; async function audit(page: Page, name: string) { await page.addScriptTag({ content: AXE }); const res = await page.evaluate(async () => (window as any).axe.run(document, { resultTypes: ['violations'] })); const serious = res.violations.filter((v: any) => v.impact === 'serious' || v.impact === 'critical'); const minor = res.violations.length - serious.length; if (serious.length) { bad += serious.length; console.log(`✗ ${name}: ${serious.map((v: any) => `${v.id}(${v.nodes.length}) ${v.nodes[0]?.target?.join(' ')}`).join('; ')}`); } else console.log(`✓ ${name}${minor ? ` (경미: ${res.violations.map((v: any) => v.id).join(', ')})` : ''}`); } for (const [label, ctxOpts] of [ ['PC', { viewport: { width: 1440, height: 900 } }], ['phone', { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }], ] as const) { for (const theme of ['light', 'dark'] as const) { const ctx = await browser.newContext({ ...ctxOpts, locale: 'ko-KR', colorScheme: theme }); const p = await ctx.newPage(); await p.goto(ORIGIN + '/'); await p.getByText('게임 고르기').waitFor(); await audit(p, `${label}/${theme} 홈`); await p.goto(ORIGIN + '/g/omok'); await p.getByRole('button', { name: /친구랑 하기/ }).waitFor(); await audit(p, `${label}/${theme} 게임 소개`); await p.getByRole('button', { name: /친구랑 하기/ }).click(); await p.getByLabel('내 이름(닉네임)').fill(`검사${label}${theme}`.slice(0, 12)); await p.getByRole('button', { name: '이 이름으로 시작하기' }).click(); await p.waitForURL(/\/r\/\d{6}$/); await p.getByText('친구에게 이 코드를 알려 주세요').waitFor(); await audit(p, `${label}/${theme} 대기실`); await ctx.close(); } } await browser.close(); await app.stop(); console.log(bad ? `A11Y FAILED (${bad})` : 'A11Y PASSED'); process.exit(bad ? 1 : 0);