Files
joke-app/e2e/omok.e2e.ts

156 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<void>) => {
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);