- 바둑: 9·13·19줄, 따내기·자충수 금지·슈퍼코(Zobrist)/한국식 삼패 무승부, 접바둑·덤, 패스, 사석 확인 단계(자동 제안·동의·이의 시 재개), 집/영역 계가, 초읽기·피셔 시계 (테스트 50개) - 쓰이지 않던 바둑 옵션(연결 끊김 유예 — 방 설정이 담당) 제거 - 홈 '방 만들기'가 게임 고르기 창을 거치도록 바뀐 것에 맞춰 E2E 수정, 장기 상차림 단계 E2E 안정화 - 전체: 테스트 230개, E2E 오목·관리자·4게임·접근성 모두 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
152 lines
6.5 KiB
TypeScript
152 lines
6.5 KiB
TypeScript
/**
|
||
* 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
|
||
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);
|