Files
joke-app/e2e/a11y.e2e.ts
EJClaw c5c30d4ada M2: 오셀로 추가, 게임 공통 E2E, 접근성 검사(axe) 통과
- 오셀로: 8방향 뒤집기, 자동 패스, 종료·점수(빈칸 승자 몫 옵션), 한 수/피셔 시계·자동 착수,
  무르기·무승부·기권, 놓을 곳 점·뒤집힐 돌 미리 보기·뒤집기 애니메이션 (테스트 31개)
- 카탈로그에 오셀로·체스·장기·바둑 추가(엔진이 등록된 게임만 화면에 나옴)
- e2e/games.e2e.ts: PC 방장 + 휴대폰 친구, 게임 소개→방 만들기→링크 입장→시작→차례대로 첫 수
- e2e/a11y.e2e.ts: 홈·게임 소개·대기실 × PC/휴대폰 × 밝게/어둡게, axe 위반 0건
  (h1, 랜드마크, 제목 순서 정리)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 05:19:05 +09:00

59 lines
2.8 KiB
TypeScript

/**
* 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);