- packages/games/src/marble: 판 데이터(board.ts, 40칸·가격표·행운카드 20장), 상태 기계(rules.ts), 게임 정의(index.ts). 더블·3더블 무인도, 무인도/탈출권/탈출비, 우주여행·탑승료, 사회복지기금, 통행료(관광지 2곳 이상 2배·면제권), 빚 갚기·파산(채권자에게 매각 대금), 인수·랜드마크·독점 승리·교환 옵션, 라운드 제한과 총자산 순위(동점이면 포인트 비교). 카드 더미 순서는 비공개(남은 장 수만), 시스템 전용 TIMEOUT(굴리기/안 사기/싼 것부터 매각/거절), onLeave = 기권. - 테스트 80개: 문서 §10 체크리스트 전 항목, 1,000판 시드 무작위 대국(포인트 장부·소유 일관성· onTimeout 합법), 엔진 randomPlayout·리플레이 결정성, 시간 초과만으로도 끝나는지. - 화면: SVG 정사각형 판(도시 이름·소유 색·건물 표시·말), 가운데 주사위·결정 프롬프트, 행운카드 공개, 우주여행 칸 고르기와 미리 보기, 빚 갚기 막대·매각 목록, 교환 시트, 진행 기록, PC·휴대폰·다크 모드. 화폐 대신 놀이용 포인트(P) 표기, no-money-wording 검사 대상에 추가. - 등록: GAMES, CATALOG(주사위 땅따먹기), 웹 registry, GAME_ICON, @bg/games/marble/board export. - E2E marble 계획: PC 방장 + 휴대폰 손님, 15라운드·200P로 결과 화면까지 실제 진행. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
588 lines
29 KiB
TypeScript
588 lines
29 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;
|
|
/** Host changes room options before starting (inline settings form on PC). */
|
|
lobby?: (host: Page) => Promise<void>;
|
|
/** 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 });
|
|
}
|
|
|
|
/** 숫자 타일: whoever has the turn tries "도와줘" → "새 묶음" → "확정"; otherwise takes one tile. */
|
|
async function rummikubTurns(pages: Page[], turns: number) {
|
|
const stats = { plays: 0, draws: 0, undo: false };
|
|
const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'));
|
|
const bar = (p: Page) => p.getByRole('group', { name: '내 차례 행동' });
|
|
/** Waits until a bar button is enabled / disabled (server answered). */
|
|
const barButton = (p: Page, name: string, enabled: boolean) =>
|
|
p.waitForFunction(
|
|
([n, e]) => [...document.querySelectorAll('.rk-bar button')].some((b) => b.textContent === n && b.hasAttribute('disabled') !== e),
|
|
[name, enabled] as const,
|
|
{ timeout: 5000 },
|
|
);
|
|
for (let n = 0; n < turns; n++) {
|
|
let mover: Page | null = null;
|
|
for (let t = 0; t < 150 && !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(`nobody has the turn (turn ${n + 1})`);
|
|
await bar(mover).waitFor();
|
|
let played = false;
|
|
await bar(mover).getByRole('button', { name: '도와줘' }).click();
|
|
const picked = await mover.locator('.rk-rack button[aria-pressed="true"]').count();
|
|
if (picked > 0) {
|
|
// Tap one picked tile twice (deselect, select again) — the tap-to-select path.
|
|
const first = mover.locator('.rk-rack button[aria-pressed="true"]').first();
|
|
const label = await first.getAttribute('aria-label');
|
|
await first.click();
|
|
await mover.locator(`.rk-rack button[aria-label="${label}"]`).first().click();
|
|
await bar(mover).getByRole('button', { name: '새 묶음' }).click();
|
|
await mover.locator('.rk-set').last().waitFor();
|
|
if (!stats.undo) {
|
|
// Undo once (back to the turn start), then place again.
|
|
await barButton(mover, '되돌리기', true);
|
|
await bar(mover).getByRole('button', { name: '되돌리기' }).click();
|
|
await barButton(mover, '처음으로', false);
|
|
stats.undo = true;
|
|
await bar(mover).getByRole('button', { name: '도와줘' }).click();
|
|
await bar(mover).getByRole('button', { name: '새 묶음' }).click();
|
|
}
|
|
const commit = bar(mover).getByRole('button', { name: '확정' });
|
|
await mover
|
|
.waitForFunction(() => [...document.querySelectorAll('.rk-bar button')].some((b) => b.textContent === '확정' && b.classList.contains('btn-primary') && !b.hasAttribute('disabled')), null, { timeout: 5000 })
|
|
.then(
|
|
() => (played = true),
|
|
() => {},
|
|
);
|
|
if (played) {
|
|
await commit.click();
|
|
stats.plays++;
|
|
} else {
|
|
await bar(mover).getByRole('button', { name: '처음으로' }).click();
|
|
await mover.waitForTimeout(300);
|
|
}
|
|
}
|
|
if (!played) {
|
|
await bar(mover).getByRole('button', { name: /^(한 장 가져오기|넘기기)$/ }).click();
|
|
const sheet = mover.getByRole('button', { name: /^되돌리고/ });
|
|
if (await sheet.count()) await sheet.click();
|
|
stats.draws++;
|
|
}
|
|
await mover.waitForFunction(() => !document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'), null, { timeout: 5000 });
|
|
}
|
|
console.log(` rummikub: ${stats.plays} plays, ${stats.draws} draws, undo ${stats.undo ? 'used' : 'not used'}`);
|
|
return stats;
|
|
}
|
|
|
|
/**
|
|
* 윷놀이: one UI step for whoever can act on `p` — press the throw button, or pick a result chip →
|
|
* a glowing piece (or the waiting-piece tray) → "이동하기" (the two-step move confirmation).
|
|
*/
|
|
async function yutStep(p: Page): Promise<boolean> {
|
|
const throwBtn = p.getByRole('button', { name: /윷 던지기|한 번 더 던지기/ });
|
|
if ((await throwBtn.count()) && (await throwBtn.isEnabled().catch(() => false))) {
|
|
return throwBtn.click({ timeout: 1500 }).then(() => true, () => false);
|
|
}
|
|
const chip = p.locator('.yut-chip:not([disabled])').first();
|
|
if (!(await chip.count())) return false;
|
|
try {
|
|
await chip.click({ timeout: 1500 });
|
|
const src = p.locator('g.yut-hit[tabindex="0"], .yut-tray-btn.can').first();
|
|
await src.waitFor({ timeout: 2000 });
|
|
// Keyboard: Enter on a piece shows where it lands, Enter again on the same piece moves it.
|
|
const h = await src.elementHandle({ timeout: 1000 });
|
|
await h!.focus();
|
|
await p.keyboard.press('Enter');
|
|
await p.getByRole('button', { name: /^이동하기/ }).waitFor({ timeout: 1500 });
|
|
await h!.focus();
|
|
await p.keyboard.press('Enter');
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 주사위 땅따먹기: one UI step for whoever can act on `p` — answer a trade, sell, buy/build (or skip),
|
|
* pick a space-travel square on the board, escape the island or roll. Returns the label pressed.
|
|
*/
|
|
const tag = (p: Page) => ((p.viewportSize()?.width ?? 0) < 600 ? 'phone' : 'pc');
|
|
async function marbleStep(p: Page, stats: Record<string, number>): Promise<string | null> {
|
|
const prompt = p.locator('.mb-prompt.mine');
|
|
if (!(await prompt.count())) return null;
|
|
const btn = (name: string | RegExp) => prompt.getByRole('button', { name, exact: typeof name === 'string' });
|
|
const press = async (name: string | RegExp) => {
|
|
const b = btn(name);
|
|
if (!(await b.count()) || !(await b.first().isEnabled().catch(() => false))) return false;
|
|
return b.first().click({ timeout: 1500 }).then(() => true, () => false);
|
|
};
|
|
// Space travel: tap a glowing square on the board, check the preview, fly there.
|
|
if (await btn(/^출발로 가기/).count()) {
|
|
const cell = p.locator('g.mb-cell.pick').nth(5 + (stats.space ?? 0) * 7);
|
|
await cell.click({ timeout: 1500 }).catch(() => {});
|
|
const go = p.getByRole('button', { name: '🚀 여기로 가기' });
|
|
if (await go.waitFor({ timeout: 1500 }).then(() => true, () => false)) {
|
|
await p.getByText('여기로 가면').waitFor({ timeout: 1000 });
|
|
if (!stats.space) await p.screenshot({ path: join(SHOTS, `marble-space-${tag(p)}.png`) });
|
|
await go.click();
|
|
stats.space = (stats.space ?? 0) + 1;
|
|
return 'space';
|
|
}
|
|
return (await press(/^출발로 가기/)) ? 'space-start' : null;
|
|
}
|
|
// First buy prompt on each screen size, for the visual check.
|
|
const shotKey = `shot-${tag(p)}`;
|
|
if (!stats[shotKey] && (await btn('사기').count())) {
|
|
stats[shotKey] = 1;
|
|
await p.screenshot({ path: join(SHOTS, `marble-buy-${tag(p)}.png`) });
|
|
}
|
|
for (const name of ['거절', /^가장 싼 것부터 팔기/, '사기', '짓기', '인수하기', '🎲 탈출 굴리기', '🎲 주사위 굴리기', '🎲 한 번 더 굴리기', '안 사기', '안 짓기', '안 하기'] as const) {
|
|
if (await press(name)) {
|
|
const key = typeof name === 'string' ? name : 'sell';
|
|
stats[key] = (stats[key] ?? 0) + 1;
|
|
return key;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const PLANS: Record<string, Plan> = {
|
|
rummikub: {
|
|
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
|
|
lobby: async (host) => {
|
|
await host.getByRole('group', { name: '첫 등록 최소 점수' }).getByRole('button', { name: '없음' }).click();
|
|
await host.getByRole('button', { name: '설정 저장' }).click();
|
|
await host.getByText('첫 등록 점수 없음').first().waitFor();
|
|
},
|
|
drive: async (pages) => {
|
|
const stats = await rummikubTurns(pages, 10);
|
|
if (stats.plays + stats.draws < 10) throw new Error('not enough turns');
|
|
},
|
|
},
|
|
yut: {
|
|
// 말 2개로 줄여서 PC 방장 + 휴대폰 친구가 실제 화면 조작으로 끝까지 둔다(중간 화면도 저장).
|
|
lobby: async (host) => {
|
|
await host.getByRole('button', { name: '2개', exact: true }).click();
|
|
await host.getByRole('button', { name: '설정 저장' }).click();
|
|
await host.getByText(/말 2개/).first().waitFor();
|
|
},
|
|
drive: async (pages) => {
|
|
const [host, guest] = pages as [Page, Page];
|
|
let actions = 0;
|
|
let shot = false;
|
|
const until = Date.now() + 300_000;
|
|
const over = () => host.getByText(/이겼어요|아쉽게 졌어요/).count();
|
|
while (Date.now() < until && !(await over())) {
|
|
let acted = false;
|
|
for (const p of pages) {
|
|
if (await yutStep(p)) {
|
|
acted = true;
|
|
actions++;
|
|
break;
|
|
}
|
|
}
|
|
if (actions >= 16 && !shot) {
|
|
shot = true;
|
|
await host.waitForTimeout(400);
|
|
await host.screenshot({ path: join(SHOTS, 'yut-mid-pc.png') });
|
|
await guest.screenshot({ path: join(SHOTS, 'yut-mid-phone.png'), fullPage: true });
|
|
await guest.emulateMedia({ colorScheme: 'dark' });
|
|
await guest.screenshot({ path: join(SHOTS, 'yut-mid-phone-dark.png') });
|
|
await guest.emulateMedia({ colorScheme: 'light' });
|
|
}
|
|
if (acted && !stats.toll && (await host.locator('.mb-log li', { hasText: /→.*통행료/ }).count())) stats.toll = 1;
|
|
if (!acted) await host.waitForTimeout(120);
|
|
}
|
|
if (!(await over())) throw new Error(`game did not finish (${actions} UI actions)`);
|
|
await guest.getByText(/이겼어요|아쉽게 졌어요/).waitFor();
|
|
console.log(` yut: finished after ${actions} UI actions`);
|
|
},
|
|
},
|
|
marble: {
|
|
// PC host + phone guest, 15 rounds (shortest limit) played through the UI to the result: roll, buy or
|
|
// build (skip when short), pay tolls, sell when short, space travel via a board square. Mid-game shots.
|
|
lobby: async (host) => {
|
|
await host.getByRole('group', { name: '라운드 제한' }).getByRole('button', { name: '15라운드' }).click();
|
|
await host.getByRole('group', { name: '시작 포인트' }).getByRole('button', { name: '200P' }).click();
|
|
await host.getByRole('button', { name: '설정 저장' }).click();
|
|
await host.getByText(/^15라운드 · 시작 200P/).first().waitFor();
|
|
},
|
|
drive: async (pages) => {
|
|
const [host, guest] = pages as [Page, Page];
|
|
const stats: Record<string, number> = {};
|
|
let actions = 0;
|
|
let shot = false;
|
|
const until = Date.now() + 420_000;
|
|
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
|
|
while (Date.now() < until && !(await over())) {
|
|
let acted = false;
|
|
for (const p of pages) {
|
|
if (await marbleStep(p, stats)) {
|
|
acted = true;
|
|
actions++;
|
|
await p.waitForTimeout(80);
|
|
break;
|
|
}
|
|
}
|
|
if (actions >= 40 && !shot && (stats['사기'] ?? 0) >= 3) {
|
|
shot = true;
|
|
await host.waitForTimeout(1300);
|
|
await host.screenshot({ path: join(SHOTS, 'marble-mid-pc.png') });
|
|
await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone.png') });
|
|
await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone-full.png'), fullPage: true });
|
|
await host.emulateMedia({ colorScheme: 'dark' });
|
|
await guest.emulateMedia({ colorScheme: 'dark' });
|
|
await host.waitForTimeout(400); // let the button colour transitions settle
|
|
await host.screenshot({ path: join(SHOTS, 'marble-mid-pc-dark.png') });
|
|
await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone-dark.png') });
|
|
await host.emulateMedia({ colorScheme: 'light' });
|
|
await guest.emulateMedia({ colorScheme: 'light' });
|
|
}
|
|
if (acted && !stats.toll && (await host.locator('.mb-log li', { hasText: /→.*통행료/ }).count())) stats.toll = 1;
|
|
if (!acted) await host.waitForTimeout(120);
|
|
}
|
|
if (!(await over())) throw new Error(`game did not finish (${actions} UI actions: ${JSON.stringify(stats)})`);
|
|
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
|
|
await host.waitForTimeout(600); // result dialog fade-in
|
|
await host.screenshot({ path: join(SHOTS, 'marble-result-pc.png') });
|
|
await guest.screenshot({ path: join(SHOTS, 'marble-result-phone.png') });
|
|
const rolls = (stats['🎲 주사위 굴리기'] ?? 0) + (stats['🎲 한 번 더 굴리기'] ?? 0) + (stats['🎲 탈출 굴리기'] ?? 0);
|
|
if (rolls < 15 || !stats['사기']) throw new Error(`too few real turns: ${JSON.stringify(stats)}`);
|
|
console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`);
|
|
},
|
|
},
|
|
'one-card': {
|
|
// 3 players, default 원카드: whoever has the turn plays a highlighted card (tap to lift, tap again to play,
|
|
// pick a suit for 7) or draws; the 원카드 button is pressed whenever it lights up. 60 turns or until the game ends.
|
|
players: 3,
|
|
drive: async (pages) => {
|
|
const statusOf = (p: Page) => p.locator('.turn-status').innerText().catch(() => '');
|
|
let turns = 0;
|
|
let plays = 0;
|
|
let draws = 0;
|
|
let finished = false;
|
|
for (let t = 0; t < 600 && turns < 60; t++) {
|
|
if (await pages[0]!.getByRole('dialog', { name: '게임 결과' }).count()) {
|
|
finished = true;
|
|
break;
|
|
}
|
|
let acted = false;
|
|
for (const p of pages) {
|
|
const one = p.locator('.oc-onebtn');
|
|
if ((await one.count()) && (await one.isEnabled().catch(() => false))) await one.click({ timeout: 1000 }).catch(() => {});
|
|
const before = await statusOf(p);
|
|
if (!before.includes('내 차례예요')) continue;
|
|
const accept = p.getByRole('button', { name: /^받아들이기/ });
|
|
const card = p.locator('button.oc-card[data-legal="true"]').first();
|
|
if (await accept.count()) await accept.click();
|
|
else if (await card.count()) {
|
|
await card.click();
|
|
await card.click();
|
|
const pick = p.locator('.oc-pick button').first();
|
|
if (await pick.waitFor({ timeout: 400 }).then(() => true, () => false)) await pick.click();
|
|
plays++;
|
|
} else {
|
|
const pass = p.getByRole('button', { name: '넘기기' });
|
|
if (await pass.count()) await pass.click();
|
|
else await p.getByRole('button', { name: /장 먹기$/ }).first().click();
|
|
draws++;
|
|
}
|
|
await p.waitForFunction((s) => document.querySelector('.turn-status')?.textContent?.trim() !== s, before.trim(), { timeout: 5000 }).catch(() => {});
|
|
turns++;
|
|
acted = true;
|
|
break;
|
|
}
|
|
if (!acted) await pages[0]!.waitForTimeout(150);
|
|
}
|
|
if (!finished && turns < 30) throw new Error(`only ${turns} turns were played`);
|
|
if (finished) await pages[0]!.screenshot({ path: join(SHOTS, 'one-card-3-result-pc.png') });
|
|
console.log(` one-card: ${turns} turns (${plays} plays, ${draws} draws)${finished ? ', game finished' : ''}`);
|
|
},
|
|
},
|
|
'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();
|
|
},
|
|
},
|
|
yacht: {
|
|
// PC host + phone guest play a whole 12-round game through the UI: roll, sometimes hold + re-roll, then
|
|
// write the best open category (phone uses touch taps, so the confirm sheet path is exercised too).
|
|
drive: async (pages) => {
|
|
const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'));
|
|
const shot = new Set<string>();
|
|
for (let turn = 0; turn < 24; turn++) {
|
|
let mover: Page | null = null;
|
|
for (let t = 0; t < 150 && !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(`nobody has the turn (turn ${turn + 1})`);
|
|
const phone = mover !== pages[0];
|
|
const press = (l: ReturnType<Page['locator']>) => (phone ? l.tap() : l.click());
|
|
await press(mover.getByRole('button', { name: /^🎲 굴리기/ }));
|
|
await mover.locator('.yt-die[aria-pressed]').first().waitFor();
|
|
if (turn % 3 !== 2) {
|
|
// Hold the first die, check it shows as held, then roll the rest again.
|
|
await press(mover.locator('.yt-die').first());
|
|
await mover.locator('.yt-die').first().and(mover.locator('[aria-pressed="true"]')).waitFor();
|
|
await press(mover.getByRole('button', { name: /^🎲 굴리기 \(남은 2번\)/ }));
|
|
await mover.getByRole('button', { name: /^🎲 굴리기 \(남은 1번\)/ }).waitFor();
|
|
await mover.waitForTimeout(700); // roll animation
|
|
}
|
|
const cells = mover.locator('button.yt-cell:not([disabled])');
|
|
await cells.first().waitFor();
|
|
// Mid-game screenshots of a turn in progress (held die + preview cells), light and dark.
|
|
const tag = phone ? 'phone' : 'pc';
|
|
if (turn >= 8 && !shot.has(tag)) {
|
|
shot.add(tag);
|
|
await mover.screenshot({ path: join(SHOTS, `yacht-mid-${tag}.png`), fullPage: phone });
|
|
await mover.emulateMedia({ colorScheme: 'dark' });
|
|
await mover.waitForTimeout(200);
|
|
await mover.screenshot({ path: join(SHOTS, `yacht-mid-${tag}-dark.png`), fullPage: phone });
|
|
await mover.emulateMedia({ colorScheme: 'light' });
|
|
}
|
|
const values = await cells.allInnerTexts();
|
|
let best = 0;
|
|
values.forEach((v, i) => Number(v) > Number(values[best]) && (best = i));
|
|
await press(cells.nth(best));
|
|
const confirm = mover.getByRole('button', { name: '기록하기', exact: true });
|
|
if (await confirm.isVisible({ timeout: 600 }).catch(() => false)) await confirm.click();
|
|
await mover.waitForFunction(() => !document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'), null, { timeout: 5000 });
|
|
}
|
|
for (const p of pages) await p.getByRole('dialog', { name: '게임 결과' }).waitFor({ timeout: 10_000 });
|
|
const sheet = await pages[0]!.locator('.yt-table tr.yt-total').innerText();
|
|
if (!/\d/.test(sheet)) throw new Error('no totals on the score sheet');
|
|
},
|
|
},
|
|
seotda: {
|
|
// 3 players, default 2장 섯다 (1장씩 두 번): everyone checks both betting rounds → showdown.
|
|
// A tie (or 구사) starts a 재경기 with the same pot, so keep checking until the hand result shows up.
|
|
players: 3,
|
|
drive: async (pages) => {
|
|
await pressActions(pages, /^(체크|콜)/, 6);
|
|
for (let extra = 0; extra < 30 && !(await pages[0]!.getByText('이번 판 결과').count()); extra++) {
|
|
await pressActions(pages, /^(체크|콜)/, 1).catch(() => {});
|
|
}
|
|
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);
|
|
},
|
|
},
|
|
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();
|
|
}
|
|
if (plan.lobby) await plan.lobby(host);
|
|
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);
|