/** * E2E (docs/10 §2): guest start → create room → second user joins by link → omok to the end. * Run: bun e2e/omok.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 ?? 4517); 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 PAD = 0.9; // board viewBox padding (coordinate labels) async function cellPoint(page: Page, x: number, y: number, n = 15) { const box = (await page.locator('svg[role="grid"]').boundingBox())!; const span = n - 1 + 2 * PAD; return { px: box.x + ((x + PAD) / span) * box.width, py: box.y + ((y + PAD) / span) * box.height }; } /** PC: one mouse click places. Phone: tap shows a preview, second tap confirms. */ async function tapCell(page: Page, x: number, y: number, touch: boolean) { const { px, py } = await cellPoint(page, x, y); if (touch) { await page.touchscreen.tap(px, py); await page.waitForTimeout(60); await page.touchscreen.tap(px, py); } else { await page.mouse.move(px, py); await page.mouse.click(px, py); } } try { // A = PC (primary target, docs/07 §3), B = phone. const ctxA = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'ko-KR' }); const ctxB = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, locale: 'ko-KR' }); const a = await ctxA.newPage(); const b = await ctxB.newPage(); const errors: string[] = []; for (const p of [a, b]) p.on('pageerror', (e) => errors.push(e.message)); let code = ''; await step('A: 홈 → 방 만들기 → 닉네임 → 대기실', async () => { await a.goto(ORIGIN + '/'); await a.getByText('게임 고르기').waitFor(); await a.screenshot({ path: join(SHOTS, '0-home-pc.png') }); await a.getByRole('button', { name: '+ 방 만들기' }).click(); await a.getByRole('dialog', { name: '게임 고르기' }).getByRole('button', { name: /오목/ }).click(); await a.getByLabel('내 이름(닉네임)').fill('호랑이'); await a.getByRole('button', { name: '이 이름으로 시작하기' }).click(); await a.waitForURL(/\/r\/\d{6}$/); code = a.url().slice(-6); await a.getByText('친구에게 이 코드를 알려 주세요').waitFor(); }); await step('B: 링크로 입장 → 닉네임 → 자동 착석', async () => { await b.goto(`${ORIGIN}/r/${code}`); await b.getByText('방에 초대받았어요').waitFor(); await b.getByLabel('내 이름(닉네임)').fill('고양이'); await b.getByRole('button', { name: '이 이름으로 입장하기' }).click(); await b.getByLabel('자리').getByText('고양이').waitFor(); await a.getByLabel('자리').getByText('고양이').waitFor(); await b.screenshot({ path: join(SHOTS, '5-lobby-phone.png') }); }); await step('A: 설정(방장 흑, 자유룰) → 시작', async () => { // PC: the host edits settings inline in the lobby. await a.getByRole('button', { name: '자유룰' }).click(); await a.getByRole('button', { name: '방장이 흑' }).click(); await a.getByRole('button', { name: '설정 저장' }).click(); await a.getByText('자유룰 · 15줄').waitFor(); await a.screenshot({ path: join(SHOTS, '1-lobby-pc.png') }); await a.getByRole('button', { name: '▶ 게임 시작' }).click(); await a.getByRole('button', { name: '시작하기' }).click(); await a.getByText('내 차례예요').waitFor(); await b.getByText('호랑이님이 생각 중이에요').waitFor(); }); await step('대국: 흑 가로 5목', async () => { for (let i = 0; i < 4; i++) { await tapCell(a, 5 + i, 7, false); await b.getByText('내 차례예요').waitFor(); await tapCell(b, 5 + i, 3, true); await a.getByText('내 차례예요').waitFor(); if (i === 1) { await a.locator('input[aria-label="메시지"]').fill('잘 부탁해요!'); await a.keyboard.press('Enter'); await b.waitForTimeout(100); const { px, py } = await cellPoint(a, 7, 9); await a.mouse.move(px, py); // hover preview stone await a.screenshot({ path: join(SHOTS, '2-game-pc.png') }); await b.screenshot({ path: join(SHOTS, '6-game-phone.png') }); } } }); await step('B: 새로고침해도 판이 그대로', async () => { await b.reload(); await b.getByText('호랑이님이 생각 중이에요').waitFor(); const stones = await b.locator('svg[role="grid"] circle[r="0.46"]').count(); if (stones !== 8) throw new Error(`expected 8 stones after reload, got ${stones}`); }); await step('흑 승리 → 결과 화면', async () => { await tapCell(a, 9, 7, false); await a.getByText('🎉 이겼어요!').waitFor(); await b.getByText('아쉽게 졌어요').waitFor(); await a.waitForTimeout(500); // let the sheet animation finish // Fairness check: the browser recomputes SHA-256(seed) and compares with the published hash. await a.getByText('공정성 확인').click(); await a.getByRole('button', { name: '내 브라우저에서 직접 확인하기' }).click(); await a.getByText('✓ 일치해요').waitFor(); await a.screenshot({ path: join(SHOTS, '3-result-pc.png') }); await b.screenshot({ path: join(SHOTS, '4-result-phone.png') }); }); await step('한 판 더 → 새 게임 시작', async () => { await a.getByRole('button', { name: /한 판 더/ }).click(); await b.getByRole('button', { name: /한 판 더/ }).click(); await a.getByText(/차례예요|생각 중이에요/).waitFor(); }); 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);