Files
joke-app/e2e/omok.e2e.ts
EJClaw 19c163f4d2 시간 제한 없음은 정말 무제한, 대기실 설정은 바꾸면 바로 적용, 모르는 사람 기다리기(빠른 시작·공개 방) 제거
- 오목에서 [시간 제한 없음]을 골라도 30초가 흐르던 문제: 대기실 설정이 [설정 저장]을 눌러야
  서버에 반영되어, 누르지 않고 [게임 시작]하면 기본값(한 수 30초)으로 시작했다. 이제 설정은
  바꾸는 즉시 서버에 보낸다(저장 버튼 없음, 휴대폰 시트는 [닫기]만). 빠르게 여러 번 눌러도
  먼저 온 방 정보가 새 값을 되돌리지 않게 lib/option-sync.ts로 맞춘다.
- 시간 제한 없음(오목·체스·장기·오델로·바둑·쿼리도): 무응답 한도(idleLimitSec, 5~10분 뒤 시간패)와
  그 카운트다운을 없앴다. 체스는 첫 수 제한도 피셔일 때만. 연결이 끊긴 사람의 접속 유예는 그대로.
- 홈의 [지금 열린 방] 탭, 게임 소개의 [아무나와 빠른 시작]·기다리는 방 목록, 대기실의 방 공개
  설정을 뺐다(방은 모두 친구만). 서버 API는 남겨 두고 화면에서만 부르지 않는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:42:46 +09:00

161 lines
7.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));
// The first-game "30초 규칙 요약" cards are tested in e2e/tutorial.e2e.ts.
for (const p of [a, b]) await p.addInitScript(() => localStorage.setItem('bg:qs:omok', '1'));
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. Every change applies at once (no save button).
await a.getByRole('button', { name: '자유룰' }).click();
await a.getByRole('button', { name: '방장이 흑' }).click();
await a.getByRole('button', { name: '시간 제한 없음' }).click();
await a.getByText('자유룰 · 15줄 · 시간 제한 없음').waitFor();
await b.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();
// 시간 제한 없음: no countdown anywhere.
for (const p of [a, b]) if ((await p.getByRole('timer').count()) !== 0) throw new Error('시간 제한 없음인데 타이머가 보여요');
});
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);