/** * E2E (docs/06 §12): practice against computer players from the UI. * 1) PC: 오목 상세 → [컴퓨터와 연습하기] → 대기실(컴퓨터 1) → 시작 → 컴퓨터가 여러 번 받아 둔다. * 2) 휴대폰: 원카드 연습 → [컴퓨터 넣기]로 2명, 하나 더 넣었다 빼기 → 시작 → 사람·컴퓨터가 여러 턴. * Run: E2E_PORT=4534 bun e2e/bots.e2e.ts (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 ?? 4534); const ORIGIN = `http://localhost:${PORT}`; const SHOTS = process.env.E2E_SHOTS ?? join(import.meta.dir, '../test-results'); mkdirSync(SHOTS, { recursive: true }); const dbDir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'bg-e2e-')); const app = startServer({ config: loadConfig({ PORT: String(PORT), DB_PATH: join(dbDir, '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; const step = async (name: string, fn: () => Promise) => { const t0 = performance.now(); try { await fn(); console.log(`✓ ${name} (${Math.round(performance.now() - t0)}ms)`); } catch (e) { failed = true; console.log(`✗ ${name}: ${(e as Error).message.split('\n')[0]}`); throw e; } }; const status = (p: Page) => p.locator('.turn-status').innerText().catch(() => ''); const resultShown = async (p: Page) => (await p.getByRole('dialog', { name: '게임 결과' }).count()) > 0; async function startPractice(p: Page, gameId: string, nick: string) { await p.goto(`${ORIGIN}/g/${gameId}`); await p.getByRole('button', { name: '🤖 컴퓨터와 연습하기' }).click(); await p.getByLabel('내 이름(닉네임)').fill(nick); await p.getByRole('button', { name: '이 이름으로 시작하기' }).click(); await p.waitForURL(/\/r\/\d{6}$/); await p.getByLabel('자리').getByText('컴퓨터 1').waitFor(); await p.getByText('연습 게임이에요. 기록에는 남지 않아요.').first().waitFor(); } // ---------------------------------------------------------------- omok (PC) const PAD = 0.9; // board viewBox padding (coordinate labels) async function stones(p: Page): Promise> { const pts = await p.locator('svg[role="grid"] circle[r="0.46"]').evaluateAll((els) => els.filter((e) => e.closest('g')?.getAttribute('opacity') !== '0.55').map((e) => [Number(e.getAttribute('cx')), Number(e.getAttribute('cy'))]), ); return new Set(pts.map(([x, y]) => y! * 15 + x!)); } async function clickCell(p: Page, x: number, y: number) { const box = (await p.locator('svg[role="grid"]').boundingBox())!; const span = 14 + 2 * PAD; const px = box.x + ((x + PAD) / span) * box.width; const py = box.y + ((y + PAD) / span) * box.height; await p.mouse.move(px, py); await p.mouse.click(px, py); } try { const pc = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'ko-KR' }); const phone = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, locale: 'ko-KR' }); const a = await pc.newPage(); const b = await phone.newPage(); const errors: string[] = []; for (const p of [a, b]) p.on('pageerror', (e) => errors.push(e.message)); await step('오목: 상세 → 컴퓨터와 연습하기 → 대기실', async () => { await startPractice(a, 'omok', '호랑이'); await a.getByText('🤖 컴퓨터').first().waitFor(); // The host is seated first, the computer next; no public toggle in a practice room. await a.getByText('컴퓨터가 있는 방은 친구만 들어와요.').waitFor(); await a.screenshot({ path: join(SHOTS, 'bots-1-lobby-pc.png') }); }); await step('오목: 시작 → 컴퓨터가 5번 받아 둔다', async () => { await a.getByRole('button', { name: '▶ 게임 시작' }).click(); await a.locator('svg[role="grid"]').waitFor(); let answers = 0; // Cells around the centre, nearest first. const order = Array.from({ length: 225 }, (_, i) => i).sort((i, j) => Math.hypot((i % 15) - 7, Math.floor(i / 15) - 7) - Math.hypot((j % 15) - 7, Math.floor(j / 15) - 7)); for (let turn = 0; turn < 8 && answers < 5; turn++) { await a.waitForFunction(() => /내 차례예요|게임이 끝|이겼|졌/.test(document.querySelector('.turn-status')?.textContent ?? ''), null, { timeout: 8000 }); if (await resultShown(a)) break; const before = await stones(a); const cell = order.find((i) => !before.has(i) && (i % 15) % 2 === turn % 2)!; await clickCell(a, cell % 15, Math.floor(cell / 15)); // Our stone, then the computer's answer (it thinks ~1 s). await a.waitForFunction((n) => document.querySelectorAll('svg[role="grid"] g:not([opacity="0.55"]) > circle[r="0.46"]').length >= n + 2 || !!document.querySelector('[role="dialog"]'), before.size, { timeout: 8000 }); if (await resultShown(a)) break; answers++; if (answers === 2) await a.screenshot({ path: join(SHOTS, 'bots-2-omok-pc.png') }); } if (answers < 3) throw new Error(`the computer answered only ${answers} times`); console.log(` omok: computer answered ${answers} times`); }); // ---------------------------------------------------------------- one-card (phone) await step('원카드: 연습 방 → 컴퓨터 넣기·빼기 → 3명', async () => { await startPractice(b, 'one-card', '고양이'); const seats = b.getByLabel('자리'); await seats.getByRole('button', { name: '🤖 컴퓨터 넣기' }).click(); await seats.getByText('컴퓨터 2').waitFor(); await seats.getByRole('button', { name: '🤖 컴퓨터 넣기' }).click(); await seats.getByText('컴퓨터 3').waitFor(); await seats.getByRole('button', { name: '컴퓨터 3 빼기' }).click(); await seats.getByText('컴퓨터 3').waitFor({ state: 'detached' }); await b.screenshot({ path: join(SHOTS, 'bots-3-lobby-phone.png'), fullPage: true }); }); await step('원카드: 시작 → 내 차례 4번, 컴퓨터도 둔다', async () => { await b.getByRole('button', { name: '▶ 게임 시작' }).click(); await b.locator('.turn-status').waitFor(); let mine = 0; let botTurns = 0; let last = ''; for (let t = 0; t < 400 && mine < 4; t++) { if (await resultShown(b)) break; const one = b.locator('.oc-onebtn'); if ((await one.count()) && (await one.isEnabled().catch(() => false))) await one.click({ timeout: 1000 }).catch(() => {}); const s = await status(b); if (s !== last && /컴퓨터 \d님 차례예요/.test(s)) botTurns++; last = s; if (!s.startsWith('내 차례예요')) { const next = b.getByRole('button', { name: /다음 판/ }); if (await next.count()) await next.first().click().catch(() => {}); await b.waitForTimeout(150); continue; } const accept = b.getByRole('button', { name: /^받아들이기/ }); const card = b.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 = b.locator('.oc-pick button').first(); if (await pick.waitFor({ timeout: 400 }).then(() => true, () => false)) await pick.click(); } else { const pass = b.getByRole('button', { name: '넘기기' }); if (await pass.count()) await pass.click(); else await b.getByRole('button', { name: /장 먹기$/ }).first().click(); } await b.waitForFunction((x) => document.querySelector('.turn-status')?.textContent?.trim() !== x, s.trim(), { timeout: 8000 }); mine++; if (mine === 2) await b.screenshot({ path: join(SHOTS, 'bots-4-one-card-phone.png') }); } if (mine < 4 && !(await resultShown(b))) throw new Error(`only ${mine} of my turns were played`); if (botTurns < 2) throw new Error(`computers took only ${botTurns} turns`); console.log(` one-card: my turns ${mine}, computer turns seen ${botTurns}`); }); if (errors.length) throw new Error(`page errors: ${errors.join(' | ')}`); } catch (e) { failed = true; console.error(e); } finally { await browser.close(); await app.stop(); } console.log(failed ? 'E2E FAILED' : 'E2E PASSED'); process.exit(failed ? 1 : 0);