/** * 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; /** Steps run before normal moves (e.g. janggi formation choice). */ setup?: (host: Page, guest: Page) => Promise; /** 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; } /** 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 { 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 `p` if it has something to do — answer an open decision sheet (고/스톱: 고 once, then 스톱; * choose a floor card; 흔들기/폭탄; 총통 → 그냥 치기; 국진) or play a hand card (green-bordered match first; phones * use tap-to-lift, so a lifted card is tapped again). Returns what it did, or null. */ async function gostopStep(p: Page, state: { go: number; stop: number; picks: number; plays: number; sheets: number }): Promise { const dialog = p.getByRole('dialog'); if (await dialog.count()) { const name = (await dialog.first().getAttribute('aria-label')) ?? ''; if (name === '게임 결과') return null; if (name === '고 또는 스톱') { const goFirst = state.go === 0; if (goFirst && state.stop === 0) await p.screenshot({ path: join(SHOTS, 'gostop-gostop-dialog.png') }); await dialog.getByRole('button', { name: goFirst ? /^고/ : /^스톱/ }).click({ timeout: 1500 }); if (goFirst) state.go++; else state.stop++; return goFirst ? 'go' : 'stop'; } if (name === '총통') { await dialog.getByRole('button', { name: /^그냥 치기/ }).click({ timeout: 1500 }); return 'chongtong'; } const pick = dialog.locator('.gs-pick button, .gs-big .btn').first(); if (await pick.count()) { if (state.sheets === 0) await p.screenshot({ path: join(SHOTS, 'gostop-choice-dialog.png') }); await pick.click({ timeout: 1500 }); state.sheets++; if (name.includes('고르기')) state.picks++; return name; } return null; } const status = (await p.locator('.turn-status').innerText().catch(() => '')).trim(); if (!status.includes('내 차례예요')) return null; const card = p.locator('.gs-hcard[data-match="true"]:not([disabled])').first(); const any = p.locator('.gs-hcard:not([disabled])').first(); const target = (await card.count()) ? card : any; if (!(await target.count())) { const dummy = p.getByRole('button', { name: /^빈패 내기/ }); if (await dummy.isEnabled().catch(() => false)) { await dummy.click({ timeout: 1500 }); state.plays++; return 'dummy'; } return null; } const label = await target.getAttribute('aria-label'); await target.click({ timeout: 1500 }); const lifted = p.locator(`.gs-hcard[data-lifted="true"][aria-label="${label}"]`); if (await lifted.count()) await lifted.click({ timeout: 1500 }); state.plays++; return 'play'; } const PLANS: Record = { gostop: { // 맞고: PC host + phone guest, 1판. Real turns through the UI — hand cards (tap twice on the phone), floor-card choices, // 흔들기/폭탄 sheets, one 고 and then 스톱 — until the hand ends (스톱 or 나가리) and the game result shows. lobby: async (host) => { await host.getByRole('group', { name: '판 수' }).getByRole('button', { name: '1판', exact: true }).click(); await host.getByRole('button', { name: '설정 저장' }).click(); await host.getByText(/^1판 · /).first().waitFor(); }, drive: async (pages) => { const [host, guest] = pages as [Page, Page]; const state = { go: 0, stop: 0, picks: 0, plays: 0, sheets: 0 }; let shot = false; const until = Date.now() + 240_000; const over = async () => (await host.getByRole('dialog', { name: '게임 결과' }).count()) > 0; while (Date.now() < until && !(await over())) { let acted = false; for (const p of pages) { const did = await gostopStep(p, state).catch(() => null); if (did) { acted = true; await p.waitForTimeout(250); break; } } if (state.plays >= 8 && !shot) { shot = true; await host.waitForTimeout(1500); await host.screenshot({ path: join(SHOTS, 'gostop-mid-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'gostop-mid-phone.png'), fullPage: true }); await guest.emulateMedia({ colorScheme: 'dark' }); await host.emulateMedia({ colorScheme: 'dark' }); await guest.screenshot({ path: join(SHOTS, 'gostop-mid-phone-dark.png'), fullPage: true }); await host.screenshot({ path: join(SHOTS, 'gostop-mid-pc-dark.png') }); await guest.emulateMedia({ colorScheme: 'light' }); await host.emulateMedia({ colorScheme: 'light' }); } if (!acted) await host.waitForTimeout(150); } if (!(await over())) throw new Error(`hand did not end (${JSON.stringify(state)})`); await guest.getByRole('dialog', { name: '게임 결과' }).waitFor(); await host.screenshot({ path: join(SHOTS, 'gostop-result-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'gostop-result-phone.png') }); // Close the result sheet to see the round breakdown underneath. await host.keyboard.press('Escape'); await host.getByText(/1판 결과/).first().waitFor(); await host.screenshot({ path: join(SHOTS, 'gostop-breakdown-pc.png') }); if (state.plays < 10) throw new Error(`only ${state.plays} cards were played`); console.log(` gostop: ${state.plays} plays, ${state.sheets} choice sheets (${state.picks} card picks), 고 ${state.go}, 스톱 ${state.stop}`); }, }, 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) 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`); }, }, '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(); 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) => (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);