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>
This commit is contained in:
102
e2e/games.e2e.ts
Normal file
102
e2e/games.e2e.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* M2 smoke E2E for every 2-player game (docs/10 §2): PC host creates the room from the game page,
|
||||
* a phone friend joins by link, host starts, both see the board, a few legal moves are played via
|
||||
* the keyboard (role="grid" boards support arrows + Enter) and reach the opponent.
|
||||
* Run: bun e2e/games.e2e.ts [gameId...] (needs apps/web/dist built)
|
||||
*/
|
||||
import { chromium, type Page } from 'playwright-core';
|
||||
import { mkdirSync, mkdtempSync } 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 ?? 4519);
|
||||
const ORIGIN = `http://localhost:${PORT}`;
|
||||
const SHOTS = process.env.E2E_SHOTS ?? join(import.meta.dir, '../test-results');
|
||||
mkdirSync(SHOTS, { recursive: true });
|
||||
|
||||
/** Per game: key sequences on the grid, in turn order (first mover, second mover, ...). */
|
||||
interface Plan {
|
||||
/** Steps run before normal moves (e.g. janggi formation choice). */
|
||||
setup?: (host: Page, guest: Page) => Promise<void>;
|
||||
moves: string[][];
|
||||
}
|
||||
|
||||
const PLANS: Record<string, Plan> = {
|
||||
othello: {
|
||||
// cursor starts at d4 (3,3): black d3 = up; then white c3 = up, left
|
||||
moves: [['ArrowUp', 'Enter'], ['ArrowUp', 'ArrowLeft', 'Enter']],
|
||||
},
|
||||
};
|
||||
|
||||
const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS);
|
||||
const app = startServer({
|
||||
config: loadConfig({ PORT: String(PORT), DB_PATH: join(mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'bg-e2e-')), '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 failed = false;
|
||||
|
||||
async function keys(page: Page, seq: string[]) {
|
||||
await page.locator('[role="grid"]').first().focus();
|
||||
for (const k of seq) await page.keyboard.press(k);
|
||||
}
|
||||
|
||||
for (const id of games) {
|
||||
const plan = PLANS[id];
|
||||
if (!plan) {
|
||||
console.log(`✗ ${id}: no plan`);
|
||||
failed = true;
|
||||
continue;
|
||||
}
|
||||
const errors: string[] = [];
|
||||
const host = await (await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'ko-KR' })).newPage();
|
||||
const guest = await (await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, locale: 'ko-KR' })).newPage();
|
||||
for (const p of [host, guest]) p.on('pageerror', (e) => errors.push(`${id}: ${e.message}`));
|
||||
try {
|
||||
await host.goto(`${ORIGIN}/g/${id}`);
|
||||
await host.getByRole('button', { name: /친구랑 하기/ }).click();
|
||||
await host.getByLabel('내 이름(닉네임)').fill('방장');
|
||||
await host.getByRole('button', { name: '이 이름으로 시작하기' }).click();
|
||||
await host.waitForURL(/\/r\/\d{6}$/);
|
||||
const code = host.url().slice(-6);
|
||||
await guest.goto(`${ORIGIN}/r/${code}`);
|
||||
await guest.getByLabel('내 이름(닉네임)').fill('친구');
|
||||
await guest.getByRole('button', { name: '이 이름으로 입장하기' }).click();
|
||||
await host.getByLabel('자리').getByText('친구').waitFor();
|
||||
await host.screenshot({ path: join(SHOTS, `${id}-0-lobby.png`) });
|
||||
await host.getByRole('button', { name: '▶ 게임 시작' }).click();
|
||||
await host.getByRole('button', { name: '시작하기' }).click();
|
||||
await host.locator('[role="grid"]').first().waitFor();
|
||||
await guest.locator('[role="grid"]').first().waitFor();
|
||||
if (plan.setup) await plan.setup(host, guest);
|
||||
const status = async (p: Page) => (await p.locator('.turn-status').innerText()).trim();
|
||||
// Sides may be random: whoever shows "내 차례예요" plays the next sequence.
|
||||
const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'));
|
||||
for (const seq of plan.moves) {
|
||||
let mover: Page | null = null;
|
||||
for (let t = 0; t < 100 && !mover; t++) {
|
||||
if (await myTurn(host)) mover = host;
|
||||
else if (await myTurn(guest)) mover = guest;
|
||||
else await host.waitForTimeout(100);
|
||||
}
|
||||
if (!mover) throw new Error('nobody has the turn');
|
||||
await keys(mover, seq);
|
||||
await mover.waitForFunction(() => !document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'), null, { timeout: 5000 });
|
||||
}
|
||||
await host.screenshot({ path: join(SHOTS, `${id}-1-game-pc.png`) });
|
||||
await guest.screenshot({ path: join(SHOTS, `${id}-2-game-phone.png`) });
|
||||
if (errors.length) throw new Error(errors.join(' | '));
|
||||
console.log(`✓ ${id} (host: ${await status(host)})`);
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
console.log(`✗ ${id}: ${(e as Error).message.split('\n')[0]}`);
|
||||
await host.screenshot({ path: join(SHOTS, `${id}-fail-host.png`) }).catch(() => {});
|
||||
await guest.screenshot({ path: join(SHOTS, `${id}-fail-guest.png`) }).catch(() => {});
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
await app.stop();
|
||||
console.log(failed ? 'E2E FAILED' : 'E2E PASSED');
|
||||
process.exit(failed ? 1 : 0);
|
||||
Reference in New Issue
Block a user