253 lines
12 KiB
TypeScript
253 lines
12 KiB
TypeScript
/**
|
|
* E2E (docs/09 §8): game record replay.
|
|
* 1) 오목 to the end with two browsers (PC + phone), open 다시 보기 from the result sheet and from 내 정보,
|
|
* step through it (buttons, slider, keyboard, play) and check the board changes.
|
|
* 2) 원카드 (hidden hands): each player's replay shows only their own cards.
|
|
* Run: E2E_PORT=4532 bun e2e/replay.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 ?? 4532);
|
|
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 expectEq = (got: unknown, want: unknown, what: string) => {
|
|
if (JSON.stringify(got) !== JSON.stringify(want)) throw new Error(`${what}: expected ${JSON.stringify(want)}, got ${JSON.stringify(got)}`);
|
|
};
|
|
|
|
const PAD = 0.9;
|
|
async function tapCell(page: Page, x: number, y: number, touch: boolean, n = 15) {
|
|
const box = (await page.locator('svg[role="grid"]').boundingBox())!;
|
|
const span = n - 1 + 2 * PAD;
|
|
const px = box.x + ((x + PAD) / span) * box.width;
|
|
const py = box.y + ((y + PAD) / span) * box.height;
|
|
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);
|
|
}
|
|
}
|
|
const stones = (p: Page) => p.locator('svg[role="grid"] circle[r="0.46"]').count();
|
|
async function waitStones(p: Page, n: number) {
|
|
await p.waitForFunction((n) => document.querySelectorAll('svg[role="grid"] circle[r="0.46"]').length === n, n, { timeout: 5000 }).catch(async () => {
|
|
throw new Error(`expected ${n} stones, got ${await stones(p)}`);
|
|
});
|
|
}
|
|
const count = (p: Page) => p.locator('.replay-count').innerText();
|
|
/** Everything the board shows (text + accessible names); the side panel's 특수 카드 legend names cards too. */
|
|
const tableText = () => {
|
|
const main = document.querySelector('.game-main')!;
|
|
return main.textContent + [...main.querySelectorAll('[aria-label]')].map((e) => e.getAttribute('aria-label')).join('|');
|
|
};
|
|
/** One-card hand: card names of the face-up cards in "내 카드". */
|
|
const hand = (p: Page) =>
|
|
p.locator('button.oc-card').evaluateAll((els) => els.map((e) => (e.getAttribute('aria-label') ?? '').split(',')[0]!.trim()).sort());
|
|
|
|
try {
|
|
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' });
|
|
// The first-game "30초 규칙 요약" cards are tested in e2e/tutorial.e2e.ts; mark them seen here.
|
|
for (const ctx of [ctxA, ctxB]) await ctx.addInitScript(() => { for (const g of ['omok', 'one-card', 'seotda']) localStorage.setItem(`bg:qs:${g}`, '1'); });
|
|
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 replayUrl = '';
|
|
|
|
await step('오목: 방 만들기 → 입장 → 흑 가로 5목', async () => {
|
|
await a.goto(`${ORIGIN}/g/omok`);
|
|
await a.getByRole('button', { name: /친구랑 하기/ }).click();
|
|
await a.getByLabel('내 이름(닉네임)').fill('호랑이');
|
|
await a.getByRole('button', { name: '이 이름으로 시작하기' }).click();
|
|
await a.waitForURL(/\/r\/\d{6}$/);
|
|
const code = a.url().slice(-6);
|
|
await b.goto(`${ORIGIN}/r/${code}`);
|
|
await b.getByLabel('내 이름(닉네임)').fill('고양이');
|
|
await b.getByRole('button', { name: '이 이름으로 입장하기' }).click();
|
|
await a.getByLabel('자리').getByText('고양이').waitFor();
|
|
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.getByRole('button', { name: '▶ 게임 시작' }).click();
|
|
await a.getByRole('button', { name: '시작하기' }).click();
|
|
for (let i = 0; i < 4; i++) {
|
|
await a.getByText('내 차례예요').waitFor();
|
|
await tapCell(a, 5 + i, 7, false);
|
|
await b.getByText('내 차례예요').waitFor();
|
|
await tapCell(b, 5 + i, 3, true);
|
|
}
|
|
await a.getByText('내 차례예요').waitFor();
|
|
await tapCell(a, 9, 7, false);
|
|
await a.getByText('🎉 이겼어요!').waitFor();
|
|
await b.getByText('아쉽게 졌어요').waitFor();
|
|
});
|
|
|
|
await step('A(PC): 결과 화면 → 처음부터 다시 보기 → 한 수씩 넘기기', async () => {
|
|
await a.waitForTimeout(400); // sheet animation
|
|
await a.getByRole('link', { name: /처음부터 다시 보기/ }).click();
|
|
await a.waitForURL(/\/replay\/[0-9A-Z]{26}$/);
|
|
replayUrl = a.url();
|
|
await a.getByText('게임을 시작했어요.').waitFor();
|
|
expectEq((await count(a)).replace(/\s+/g, ' '), '시작 / 전체 9번', 'count at start');
|
|
await waitStones(a, 0);
|
|
await a.getByRole('button', { name: '다음' }).click();
|
|
await waitStones(a, 1);
|
|
await a.getByText('내가 움직였어요.').waitFor();
|
|
await a.getByRole('button', { name: '다음' }).click();
|
|
await waitStones(a, 2);
|
|
await a.getByText('고양이님이 움직였어요.').waitFor();
|
|
await a.getByRole('button', { name: '끝으로' }).click();
|
|
await waitStones(a, 9);
|
|
await a.getByText(/^끝: /).waitFor();
|
|
if (!(await a.getByRole('button', { name: '다음' }).isDisabled())) throw new Error('다음 should be disabled at the end');
|
|
await a.getByRole('button', { name: '이전' }).click();
|
|
await waitStones(a, 8);
|
|
await a.screenshot({ path: join(SHOTS, 'replay-1-omok-pc.png') });
|
|
});
|
|
|
|
await step('A(PC): 슬라이더 · 방향키 · 재생/멈춤 · 판은 눌러도 그대로', async () => {
|
|
await a.getByLabel('몇 번째 장면인지 고르기').fill('4');
|
|
await waitStones(a, 4);
|
|
expectEq((await count(a)).replace(/\s+/g, ' '), '4번째 / 전체 9번', 'count after slider');
|
|
await a.locator('body').click({ position: { x: 5, y: 300 } });
|
|
await a.keyboard.press('ArrowRight');
|
|
await waitStones(a, 5);
|
|
await a.keyboard.press('Home');
|
|
await waitStones(a, 0);
|
|
await a.getByRole('button', { name: '재생' }).click();
|
|
await waitStones(a, 2);
|
|
await a.getByRole('button', { name: '멈춤' }).click();
|
|
const paused = await stones(a);
|
|
await a.waitForTimeout(1500);
|
|
expectEq(await stones(a), paused, 'stones after pause');
|
|
// Clicking the board in a replay does nothing (read-only).
|
|
await tapCell(a, 0, 0, false);
|
|
await a.waitForTimeout(300);
|
|
expectEq(await stones(a), paused, 'stones after clicking the board');
|
|
await a.getByRole('button', { name: '← 뒤로' }).click();
|
|
await a.waitForURL(/\/r\/\d{6}$/);
|
|
});
|
|
|
|
await step('B(폰): 내 정보 → 내 게임 기록 → 다시 보기', async () => {
|
|
await b.goto(`${ORIGIN}/me`);
|
|
const list = b.getByRole('region', { name: '내 게임 기록' });
|
|
await list.getByText('졌어요').waitFor();
|
|
await list.getByText('호랑이').waitFor();
|
|
await b.screenshot({ path: join(SHOTS, 'replay-2-mygames-phone.png'), fullPage: true });
|
|
await list.getByRole('link', { name: /다시 보기/ }).first().click();
|
|
await b.waitForURL(replayUrl);
|
|
await b.getByText('게임을 시작했어요.').waitFor();
|
|
await waitStones(b, 0);
|
|
await b.getByRole('button', { name: '다음' }).tap();
|
|
await waitStones(b, 1);
|
|
await b.getByText('호랑이님이 움직였어요.').waitFor();
|
|
await b.getByRole('button', { name: '다음' }).tap();
|
|
await waitStones(b, 2);
|
|
await b.getByText('내가 움직였어요.').waitFor();
|
|
await b.screenshot({ path: join(SHOTS, 'replay-3-omok-phone.png') });
|
|
await b.getByRole('button', { name: '끝으로' }).tap();
|
|
await waitStones(b, 9);
|
|
});
|
|
|
|
await step('남의 게임 다시 보기는 막힘', async () => {
|
|
const c = await (await browser.newContext({ locale: 'ko-KR' })).newPage();
|
|
await c.goto(`${ORIGIN}/`);
|
|
await c.goto(`${ORIGIN}/me`);
|
|
await c.getByRole('link', { name: '게스트로 시작하기' }).click();
|
|
await c.getByLabel('내 이름(닉네임)').fill('구경꾼');
|
|
await c.getByRole('button', { name: /시작하기/ }).click();
|
|
await c.getByText('아직 다시 볼 게임이 없어요.').waitFor();
|
|
await c.goto(replayUrl);
|
|
await c.getByText('이 게임에 참여한 사람만 다시 볼 수 있어요.').waitFor();
|
|
await c.context().close();
|
|
});
|
|
|
|
let liveA: string[] = [];
|
|
let liveB: string[] = [];
|
|
await step('원카드: 두 사람 시작 → 손패 기억 → 한 사람이 나가서 끝', async () => {
|
|
// Leave the omok room first so both are free.
|
|
await a.getByRole('button', { name: '방 나가기' }).click();
|
|
await a.waitForURL(`${ORIGIN}/`);
|
|
await b.goto(`${ORIGIN}/g/one-card`);
|
|
await b.getByRole('button', { name: /친구랑 하기/ }).click();
|
|
await b.waitForURL(/\/r\/\d{6}$/);
|
|
const code = b.url().slice(-6);
|
|
await a.goto(`${ORIGIN}/r/${code}`);
|
|
await b.getByLabel('자리').getByText('호랑이').waitFor();
|
|
await b.getByRole('button', { name: '▶ 게임 시작' }).click();
|
|
await b.getByRole('button', { name: '시작하기' }).click();
|
|
await a.locator('button.oc-card').first().waitFor();
|
|
await b.locator('button.oc-card').first().waitFor();
|
|
liveA = await hand(a);
|
|
liveB = await hand(b);
|
|
if (liveA.length < 5 || liveB.length < 5) throw new Error(`hands too small: ${liveA.length}, ${liveB.length}`);
|
|
if (liveA.some((c) => liveB.includes(c))) throw new Error('hands overlap');
|
|
// B leaves mid-game → A wins.
|
|
await b.getByRole('button', { name: '메뉴' }).tap();
|
|
await b.getByRole('button', { name: '🚪 방 나가기' }).tap();
|
|
await b.getByRole('button', { name: '나가기', exact: true }).tap();
|
|
await b.waitForURL(`${ORIGIN}/`);
|
|
await a.getByText('🎉 이겼어요!').waitFor();
|
|
});
|
|
|
|
await step('원카드 다시 보기: 각자 자기 카드만 보임', async () => {
|
|
await a.waitForTimeout(400);
|
|
await a.getByRole('link', { name: /처음부터 다시 보기/ }).click();
|
|
await a.waitForURL(/\/replay\//);
|
|
await a.getByText('게임을 시작했어요.').waitFor();
|
|
expectEq(await hand(a), liveA, "A's replay hand at the start");
|
|
const pageA = await a.evaluate(tableText);
|
|
for (const c of liveB) if (pageA.includes(c)) throw new Error(`A's replay shows B's card ${c}: …${pageA.slice(Math.max(0, pageA.indexOf(c) - 80), pageA.indexOf(c) + 40)}…`);
|
|
await a.getByRole('button', { name: '끝으로' }).click();
|
|
await a.getByText(/자동으로 넘겼어요/).waitFor();
|
|
await a.screenshot({ path: join(SHOTS, 'replay-4-onecard-pc.png') });
|
|
|
|
await b.goto(`${ORIGIN}/me`);
|
|
await b.getByRole('region', { name: '내 게임 기록' }).getByRole('link', { name: /원카드.*다시 보기/ }).click();
|
|
await b.getByText('게임을 시작했어요.').waitFor();
|
|
expectEq(await hand(b), liveB, "B's replay hand at the start");
|
|
const pageB = await b.evaluate(tableText);
|
|
for (const c of liveA) if (pageB.includes(c)) throw new Error(`B's replay shows A's card ${c}`);
|
|
await b.screenshot({ path: join(SHOTS, 'replay-5-onecard-phone.png') });
|
|
});
|
|
|
|
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);
|