/** * 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; moves: string[][]; } const PLANS: Record = { 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']], }, 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: /^안상차림/ }); if ((await btn.count()) && (await btn.isEnabled())) { await btn.click(); picked = true; } } 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 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);