Files
joke-app/e2e/a11y.e2e.ts
EJClaw 2ed1a7ed33 M9: PWA — 홈 화면에 추가, 오프라인 안내
- 매니페스트·아이콘(192/512/maskable/apple-touch, scripts/pwa-icons.ts로 다시 만들기)
- 손으로 쓴 서비스 워커(apps/web/sw.js): 빌드 때 Vite 플러그인이 빌드 ID를 넣음.
  페이지는 네트워크만(오프라인이면 offline.html), /assets/*만 캐시 먼저(200개까지),
  /api·/ws·/healthz·/internal은 손대지 않음
- 오프라인 안내 페이지, 방 밖에서도 "인터넷 연결이 없어요" 띠
- 홈 아래 "홈 화면에 추가" 카드(설치 버튼, iOS는 공유 메뉴 안내, 닫으면 기억)
- 테스트: apps/web/src/sw.test.ts, e2e/pwa.e2e.ts(E2E_PORT), a11y.e2e.ts도 E2E_PORT 읽기
- 문서: 07 §11, 11 배포(no-cache 대상), README 확인 목록

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:12:23 +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 = Number(process.env.E2E_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);