M1 웹 + 배포: 홈·코드 입장·대기실·오목 화면·결과·계정, E2E, Docker/Caddy
- 웹(React+Vite): 홈(큰 버튼 2개·하던 게임 돌아가기), 6자리 코드 입력(붙여넣기 감지·자동 입장), 링크 입장(닉네임 한 칸), 대기실(코드/링크 복사·공유·QR·자리·준비·방장 메뉴·설정), 오목(SVG 판·두 번 눌러 확정·금수/위험 표시·시계·무르기·무승부·기권·키보드 조작·스크린리더 안내), 결과(한 판 더 투표·방 전적·공정성 확인), 채팅·반응, 계정(닉네임 변경·글자 크기·테마·소리·진동) - WS 클라이언트: 지수 백오프+지터 재연결, 화면 복귀/온라인 즉시 재연결, seq 순서 처리, ping/pong 지연·시계 보정, 서버 재시작 안내, 다른 창 접속 처리 - @bg/shared/lite: 브라우저용 진입점(zod 제외) → 첫 화면 JS 약 90KB gzip - e2e/omok.e2e.ts: 브라우저 두 개(모바일·PC)로 방 만들기→링크 입장→대국→새로고침 복구→승리→한 판 더 - deploy: Dockerfile(운영 의존성만, 267MB), docker-compose(app+Caddy), Caddyfile(보안 헤더·CSP), .env.example - 프록시 뒤에서만 X-Forwarded-For 신뢰(TRUST_PROXY=1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
129
e2e/omok.e2e.ts
Normal file
129
e2e/omok.e2e.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
};
|
||||
|
||||
async function tapCell(page: Page, x: number, y: number, n = 15) {
|
||||
const box = (await page.locator('svg[role="grid"]').boundingBox())!;
|
||||
const span = n - 1 + 1.2;
|
||||
const px = box.x + ((x + 0.6) / span) * box.width;
|
||||
const py = box.y + ((y + 0.6) / span) * box.height;
|
||||
await page.mouse.click(px, py);
|
||||
await page.waitForTimeout(60);
|
||||
await page.mouse.click(px, py); // second tap confirms (tap-confirm mode)
|
||||
}
|
||||
|
||||
try {
|
||||
const ctxA = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, locale: 'ko-KR' });
|
||||
const ctxB = await browser.newContext({ viewport: { width: 1280, height: 860 }, 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.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 a.screenshot({ path: join(SHOTS, '1-lobby-mobile.png') });
|
||||
});
|
||||
|
||||
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.getByText('고양이').first().waitFor();
|
||||
await a.getByText('고양이').waitFor();
|
||||
});
|
||||
|
||||
await step('A: 설정(방장 흑, 자유룰) → 시작', async () => {
|
||||
await a.getByRole('button', { name: '설정 바꾸기' }).click();
|
||||
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();
|
||||
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);
|
||||
await b.getByText('내 차례예요').waitFor();
|
||||
await tapCell(b, 5 + i, 3);
|
||||
await a.getByText('내 차례예요').waitFor();
|
||||
if (i === 1) await a.screenshot({ path: join(SHOTS, '2-game-mobile.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);
|
||||
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-mobile.png') });
|
||||
await b.screenshot({ path: join(SHOTS, '4-result-desktop.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);
|
||||
Reference in New Issue
Block a user