Files
joke-app/e2e/omok.e2e.ts
EJClaw f81f20a4bf 웹: PC 중심 화면으로 재구성
- 공통 상단 메뉴(사이트 이름·홈·게임 목록·관리자·어디서나 코드 입력·내 정보)
- 홈: 방 만들기 + 코드 입력 카드, 게임 격자, 지금 열린 공개 방 목록(/api/public-rooms)
- 대기실: 두 칸(초대·설정 바로 수정·규칙 요약 / 자리·시작 버튼·채팅 항상 보임)
- 게임 화면: 판(화면 높이에 맞춤, 좌표 표시) + 오른쪽 패널(상태·선수·시계·버튼·기보·채팅)
- 오목: 마우스 올리면 미리 보기 돌, PC는 한 번 클릭으로 착수(두 번 눌러 확정은 터치 기기 기본값,
  기기별 설정), GameUI에 Side 패널 추가
- 채팅: PC는 패널로 항상 표시(Enter로 바로 입력), 휴대폰은 시트
- 관리자: 왼쪽 사이드바, 사용자 표, 규칙 입력 두 칸 격자, 저장 바 고정
- 좁은 화면은 같은 화면을 한 칸으로 접어서 지원(방 상단 줄 줄바꿈 수정)
- E2E: PC 1440×900 기준(방장 PC, 친구 휴대폰), 관리자 E2E도 PC

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 05:04:00 +09:00

151 lines
6.4 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.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);