Files
joke-app/e2e/games.e2e.ts
EJClaw bd081936e0 M4: 다빈치 코드 규칙·화면·테스트
- 규칙 모듈(packages/games/src/davinci-code): 2~4명, 시작 색 고르기(동시 선택·15초),
  정렬 규칙(같은 숫자 검정/흰색 먼저 옵션), 조커 위치 직접 고르기, 맞힘 뒤 계속/멈춤,
  틀리면 가져온 타일 공개, 더미가 빈 뒤 자기 타일 공개 벌칙(옵션), 탈락 순서로 순위.
  숨은 타일은 view에 색·위치·틀린 추리만 보내고 타일 ID(숫자 포함)는 아는 사람에게만 보냄.
  시간 초과는 시스템 전용 timeout 행동으로 처리하고, 자동 추리는 그 사람의 view만으로
  후보를 계산해 결정적으로 고름. 3턴 연속 시간 초과면 마감 5초. 나가면 탈락·타일 공개.
- 테스트: 문서 10절 시나리오 전부, 시간 초과·연결 끊김·기권, 300판 이벤트/뷰 유출 검사,
  시드 1,000판 무작위 대국(타일 보존, 줄 정렬, 결정성, 내장 유출 검사, onTimeout 합법).
- 화면(apps/web/src/games/davinci-code): 상대 줄·더미·내 줄, 숫자 패드(0~11, 조커),
  한 번 더/멈추기, 조커 자리 고르기, 자기 타일 공개, 시작 색 고르기, 추리 기록, 규칙 그림.
- 등록: GAMES, 카탈로그(available), 웹 레지스트리, 아이콘, '곧 나와요'에서 제외.
- E2E: games.e2e.ts에 davinci-code 계획(PC 방장 + 폰 친구, 5턴 실제 진행).

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

240 lines
11 KiB
TypeScript

/**
* 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 {
/** Total players including the host (default 2). Extra players join on phones. */
players?: number;
/** Steps run before normal moves (e.g. janggi formation choice). */
setup?: (host: Page, guest: Page) => Promise<void>;
/** Board games: key sequences on the grid, in turn order. */
moves?: string[][];
/** Card games: custom driver over all pages (host first). */
drive?: (pages: Page[]) => Promise<void>;
}
/** Card games: whoever has the turn presses the first available safe action (check/call/…) until `rounds` actions were taken. */
async function pressActions(pages: Page[], labels: RegExp, rounds: number) {
for (let n = 0; n < rounds; n++) {
let done = false;
for (let t = 0; t < 150 && !done; t++) {
for (const p of pages) {
const btn = p.getByRole('button', { name: labels }).first();
if ((await btn.count()) && (await btn.isEnabled({ timeout: 200 }).catch(() => false))) {
if (await btn.click({ timeout: 1000 }).then(() => true, () => false)) {
done = true;
break;
}
}
}
if (!done) await pages[0]!.waitForTimeout(100);
}
if (!done) throw new Error(`no action button matching ${labels} became available (step ${n + 1})`);
await pages[0]!.waitForTimeout(150);
}
}
/** Waits until every page shows the hand result, then presses "다음 판으로" where it is offered. */
async function finishHand(pages: Page[]) {
for (const p of pages) await p.getByText('이번 판 결과').first().waitFor({ timeout: 10_000 });
}
const PLANS: Record<string, Plan> = {
seotda: {
// 3 players, default 2장 섯다 (1장씩 두 번): everyone checks both betting rounds → showdown.
players: 3,
drive: async (pages) => {
await pressActions(pages, /^(체크|콜)/, 6);
await finishHand(pages);
},
},
poker: {
// 3 players, default 7포커: 초이스 (discard 1st card, open 2nd), then everyone checks 4 streets → showdown.
players: 3,
drive: async (pages) => {
for (const p of pages) {
const cards = p.locator('.pk-mine button');
await cards.first().waitFor();
await cards.nth(0).click();
await cards.nth(1).click();
await p.getByRole('button', { name: '확인', exact: true }).click();
}
await pressActions(pages, /^체크/, 12);
await finishHand(pages);
},
},
'davinci-code': {
// 2 players, default options (choose colours): both pick 2+2 start tiles, then several real turns:
// draw from a pile, tap an opponent's hidden tile, pick a number, confirm; after a hit press "여기서 멈추기".
drive: async (pages) => {
for (const p of pages) {
await p.getByRole('button', { name: '검정 2개 · 흰색 2개' }).click();
await p.getByRole('button', { name: '이렇게 받기' }).click();
}
const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'));
for (let turn = 0; turn < 5; turn++) {
let mover: Page | null = null;
for (let t = 0; t < 100 && !mover; t++) {
for (const p of pages) if (!mover && (await myTurn(p))) mover = p;
if (!mover) await pages[0]!.waitForTimeout(100);
}
if (!mover) throw new Error(`turn ${turn + 1}: nobody has the turn`);
const pile = mover.getByRole('button', { name: turn % 2 ? /^흰색 타일 가져오기/ : /^검은색 타일 가져오기/ });
await pile.click();
await mover.getByText('맞힐 상대 타일을 눌러 주세요.').waitFor();
await mover.locator('.dv-opps .dv-pick.can').nth(turn % 3).click();
await mover.getByRole('button', { name: String((turn * 5) % 12), exact: true }).click();
if (turn < 2) await mover.screenshot({ path: join(SHOTS, `davinci-code-3-pad-${mover === pages[0] ? 'pc' : 'phone'}.png`), fullPage: true });
await mover.getByRole('button', { name: /이 숫자!$/ }).click();
// A hit → stop (the drawn tile goes in hidden); a miss ends the turn by itself.
for (let t = 0; t < 60; t++) {
const stop = mover.getByRole('button', { name: '여기서 멈추기' });
if (await stop.isVisible().catch(() => false)) await stop.click().catch(() => {});
if (!(await myTurn(mover))) break;
await mover.waitForTimeout(100);
}
if (await myTurn(mover)) throw new Error(`turn ${turn + 1}: turn did not pass`);
}
// Both screens show the guess log with every guess.
for (const p of pages) await p.getByText('추리 기록 (5)').waitFor();
},
},
othello: {
// cursor starts at d4 (3,3): black d3 = up; then white c3 = up, left
moves: [['ArrowUp', 'Enter'], ['ArrowUp', 'ArrowLeft', 'Enter']],
},
chess: {
// cursor starts on the e-file pawn of whoever is at the bottom: white e2-e4, black d7-d5 (board flipped)
moves: [['Enter', 'ArrowUp', 'ArrowUp', 'Enter'], ['Enter', 'ArrowUp', 'ArrowUp', 'Enter']],
},
baduk: {
// cursor starts at 천원: black plays there, white plays one point to the left
moves: [['Enter'], ['ArrowLeft', 'Enter']],
},
janggi: {
// Both sides pick 안상차림 (order may be han-first), then each pushes the centre 졸 one step.
setup: async (host, guest) => {
for (let round = 0; round < 20; round++) {
let picked = false;
for (const p of [host, guest]) {
const btn = p.getByRole('button', { name: /^안상차림/ });
// The card list disappears right after choosing, so every probe must be short and tolerant.
if ((await btn.count()) && (await btn.isEnabled({ timeout: 500 }).catch(() => false))) {
picked = await btn
.click({ timeout: 1000 })
.then(() => true)
.catch(() => false);
}
}
if (await host.evaluate(() => !document.body.innerText.includes('상차림을 골라'))) {
if (await guest.evaluate(() => !document.body.innerText.includes('상차림을 골라'))) return;
}
if (!picked) await host.waitForTimeout(200);
}
throw new Error('상차림 단계가 끝나지 않음');
},
moves: [
['ArrowUp', 'ArrowUp', 'Enter', 'ArrowUp', 'Enter'],
['ArrowUp', 'ArrowUp', 'Enter', 'ArrowUp', '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 guests: Page[] = [];
for (let g = 1; g < (plan.players ?? 2); g++) {
guests.push(await (await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, locale: 'ko-KR' })).newPage());
}
const guest = guests[0]!;
for (const p of [host, ...guests]) 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);
for (const [gi, g] of guests.entries()) {
const nick = gi === 0 ? '친구' : `친구${gi + 1}`;
await g.goto(`${ORIGIN}/r/${code}`);
await g.getByLabel('내 이름(닉네임)').fill(nick);
await g.getByRole('button', { name: '이 이름으로 입장하기' }).click();
await host.getByLabel('자리').getByText(nick, { exact: true }).waitFor();
}
await host.screenshot({ path: join(SHOTS, `${id}-0-lobby.png`) });
await host.getByRole('button', { name: '▶ 게임 시작' }).click();
await host.getByRole('button', { name: '시작하기' }).click();
if (plan.drive) {
await host.locator('.turn-status').waitFor();
await plan.drive([host, ...guests]);
} else {
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);