- 방 막대에 [🚪 나가기]를 두고 메뉴에서는 뺀다. [← 홈]도 같은 동작. 한 번 누르면 바로 나가고, 게임 중에 자리에 앉아 있을 때만 기권 확인을 묻는다. 휴대폰은 방 링크 복사를 메뉴로 옮겨 한 줄에 맞춘다. - 자리·관전자·결과 화면의 이름을 누르면 기록 카드(이 게임 판·승·패·무·승률, 이 방 전적, 전체 합계). GET /api/users/:id/stats (로그인 필요). - 게스트 경고: 이름 정하기, 로그인 페이지, /me, 게스트가 있는 대기실. - 관리자 사용자 상세: 전체 기록, 게임별 기록 표, 최근 게임(GET /api/admin/users/:id/games). 목록에 전적 열(PC). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
264 lines
14 KiB
TypeScript
264 lines
14 KiB
TypeScript
/**
|
|
* E2E (docs/06 §12): practice against computer players from the UI.
|
|
* 1) PC: 오목 상세 → [컴퓨터와 연습하기] → 대기실(컴퓨터 1) → 시작 → 컴퓨터가 여러 번 받아 둔다.
|
|
* 2) 휴대폰: 원카드 연습 → [컴퓨터 넣기]로 2명, 하나 더 넣었다 빼기 → 시작 → 사람·컴퓨터가 여러 턴.
|
|
* Run: E2E_PORT=4534 bun e2e/bots.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 ?? 4534);
|
|
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 status = (p: Page) => p.locator('.turn-status').innerText().catch(() => '');
|
|
const resultShown = async (p: Page) => (await p.getByRole('dialog', { name: '게임 결과' }).count()) > 0;
|
|
|
|
async function startPractice(p: Page, gameId: string, nick: string) {
|
|
await p.goto(`${ORIGIN}/g/${gameId}`);
|
|
await p.getByRole('button', { name: '🤖 컴퓨터와 연습하기' }).click();
|
|
await p.getByLabel('내 이름(닉네임)').fill(nick);
|
|
await p.getByRole('button', { name: '이 이름으로 시작하기' }).click();
|
|
await p.waitForURL(/\/r\/\d{6}$/);
|
|
await p.getByLabel('자리').getByText('컴퓨터 1').waitFor();
|
|
await p.getByText('연습 게임이에요. 기록에는 남지 않아요.').first().waitFor();
|
|
}
|
|
|
|
// ---------------------------------------------------------------- omok (PC)
|
|
const PAD = 0.9; // board viewBox padding (coordinate labels)
|
|
async function stones(p: Page): Promise<Set<number>> {
|
|
const pts = await p.locator('svg[role="grid"] circle[r="0.46"]').evaluateAll((els) =>
|
|
els.filter((e) => e.closest('g')?.getAttribute('opacity') !== '0.55').map((e) => [Number(e.getAttribute('cx')), Number(e.getAttribute('cy'))]),
|
|
);
|
|
return new Set(pts.map(([x, y]) => y! * 15 + x!));
|
|
}
|
|
async function clickCell(p: Page, x: number, y: number) {
|
|
const box = (await p.locator('svg[role="grid"]').boundingBox())!;
|
|
const span = 14 + 2 * PAD;
|
|
const px = box.x + ((x + PAD) / span) * box.width;
|
|
const py = box.y + ((y + PAD) / span) * box.height;
|
|
await p.mouse.move(px, py);
|
|
await p.mouse.click(px, py);
|
|
}
|
|
|
|
try {
|
|
const pc = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'ko-KR' });
|
|
const phone = 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 [pc, phone]) await ctx.addInitScript(() => { for (const g of ['omok', 'one-card']) localStorage.setItem(`bg:qs:${g}`, '1'); });
|
|
const a = await pc.newPage();
|
|
const b = await phone.newPage();
|
|
const errors: string[] = [];
|
|
for (const p of [a, b]) p.on('pageerror', (e) => errors.push(e.message));
|
|
|
|
await step('오목: 상세 → 컴퓨터와 연습하기 → 대기실', async () => {
|
|
await startPractice(a, 'omok', '호랑이');
|
|
await a.getByText('🤖 컴퓨터').first().waitFor();
|
|
// The host is seated first, the computer next; no public toggle in a practice room.
|
|
await a.getByText('컴퓨터가 있는 방은 친구만 들어와요.').waitFor();
|
|
await a.screenshot({ path: join(SHOTS, 'bots-1-lobby-pc.png') });
|
|
});
|
|
|
|
await step('오목: 시작 → 컴퓨터가 3번 받아 둔다', async () => {
|
|
await a.getByRole('button', { name: '▶ 게임 시작' }).click();
|
|
await a.locator('svg[role="grid"]').waitFor();
|
|
let answers = 0;
|
|
// Cells around the centre, nearest first.
|
|
const order = Array.from({ length: 225 }, (_, i) => i).sort((i, j) => Math.hypot((i % 15) - 7, Math.floor(i / 15) - 7) - Math.hypot((j % 15) - 7, Math.floor(j / 15) - 7));
|
|
// Three answers at most: even moving first the computer has only four stones, so the game is still running for
|
|
// the steps below.
|
|
for (let turn = 0; turn < 8 && answers < 3; turn++) {
|
|
await a.waitForFunction(() => /내 차례예요|게임이 끝|이겼|졌/.test(document.querySelector('.turn-status')?.textContent ?? ''), null, { timeout: 8000 });
|
|
if (await resultShown(a)) break;
|
|
const before = await stones(a);
|
|
const cell = order.find((i) => !before.has(i) && (i % 15) % 2 === turn % 2)!;
|
|
await clickCell(a, cell % 15, Math.floor(cell / 15));
|
|
// Our stone, then the computer's answer (it thinks ~1 s).
|
|
await a.waitForFunction((n) => document.querySelectorAll('svg[role="grid"] g:not([opacity="0.55"]) > circle[r="0.46"]').length >= n + 2 || !!document.querySelector('[role="dialog"]'), before.size, { timeout: 8000 });
|
|
if (await resultShown(a)) break;
|
|
answers++;
|
|
if (answers === 2) await a.screenshot({ path: join(SHOTS, 'bots-2-omok-pc.png') });
|
|
}
|
|
if (answers < 3) throw new Error(`the computer answered only ${answers} times`);
|
|
console.log(` omok: computer answered ${answers} times`);
|
|
});
|
|
|
|
// ---------------------------------------------------------------- leaving practice rooms (right after omok: its turn timer must not run out first)
|
|
const codeOf = (p: Page) => new URL(p.url()).pathname.split('/').pop()!;
|
|
const userId = (nick: string) => app.db.query<{ id: string }, [string]>('SELECT id FROM users WHERE nickname = ?').get(nick)?.id;
|
|
|
|
await step('오목(게임 중): 사이트 로고로 홈·새로고침 → 자리는 남고 접속은 끊김, 사이트가 열려 있으면 30분 뒤 정리', async () => {
|
|
const code = codeOf(a);
|
|
await a.locator('.site-logo').click();
|
|
await a.waitForURL(`${ORIGIN}/`);
|
|
const room = app.rooms.get(code)!;
|
|
for (let i = 0; i < 50 && room.conns.size; i++) await Bun.sleep(20);
|
|
if (room.conns.size !== 0) throw new Error(`room still counts ${room.conns.size} connection(s)`);
|
|
if (room.seatOf(userId('호랑이')!) < 0) throw new Error('seat was not kept');
|
|
app.rooms.sweep();
|
|
if (!app.rooms.get(code)) throw new Error('closed too early');
|
|
// A fresh page load on the home page reconnects (the seat is there), so the room still sees the site as open.
|
|
const id = userId('호랑이')!;
|
|
await a.reload();
|
|
await a.getByRole('link', { name: /하던 게임으로 돌아가기/ }).waitFor();
|
|
for (let i = 0; i < 100 && !app.gateway.isOnline(id); i++) await Bun.sleep(20);
|
|
if (!app.gateway.isOnline(id)) throw new Error('home page did not reconnect');
|
|
if (room.playersOfflineSince((u) => app.gateway.offlineSince(u)) !== null) throw new Error('site counted as closed');
|
|
room.emptySince = Date.now() - 29 * 60_000;
|
|
app.rooms.sweep();
|
|
if (!app.rooms.get(code)) throw new Error('running practice game closed before 30 minutes');
|
|
room.emptySince = Date.now() - 31 * 60_000;
|
|
app.rooms.sweep();
|
|
if (app.rooms.get(code)) throw new Error('practice room nobody looks at was not closed');
|
|
});
|
|
|
|
await step('오목(게임 중): 창을 닫으면 1분 뒤 정리 대상', async () => {
|
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'ko-KR' });
|
|
await ctx.addInitScript(() => localStorage.setItem('bg:qs:omok', '1'));
|
|
const c = await ctx.newPage();
|
|
c.on('pageerror', (e) => errors.push(e.message));
|
|
await startPractice(c, 'omok', '사자');
|
|
await c.getByRole('button', { name: '▶ 게임 시작' }).click();
|
|
await c.locator('svg[role="grid"]').waitFor();
|
|
const room = app.rooms.get(codeOf(c))!;
|
|
const id = userId('사자')!;
|
|
const off = () => room.playersOfflineSince((u) => app.gateway.offlineSince(u));
|
|
if (off() !== null) throw new Error('counted as gone while playing');
|
|
const before = Date.now();
|
|
await ctx.close();
|
|
for (let i = 0; i < 100 && off() === null; i++) await Bun.sleep(20);
|
|
const since = off();
|
|
if (since === null || since < before) throw new Error(`closing the window was not seen (${since})`);
|
|
if (app.gateway.isOnline(id)) throw new Error('still online after closing');
|
|
app.rooms.sweep();
|
|
if (!app.rooms.get(room.code)) throw new Error('closed before the minute was up');
|
|
});
|
|
|
|
// ---------------------------------------------------------------- one-card (phone)
|
|
await step('원카드: 연습 방 → 컴퓨터 넣기·빼기 → 3명', async () => {
|
|
await startPractice(b, 'one-card', '고양이');
|
|
const seats = b.getByLabel('자리');
|
|
await seats.getByRole('button', { name: '🤖 컴퓨터 넣기' }).click();
|
|
await seats.getByText('컴퓨터 2').waitFor();
|
|
await seats.getByRole('button', { name: '🤖 컴퓨터 넣기' }).click();
|
|
await seats.getByText('컴퓨터 3').waitFor();
|
|
await seats.getByRole('button', { name: '컴퓨터 3 빼기' }).click();
|
|
await seats.getByText('컴퓨터 3').waitFor({ state: 'detached' });
|
|
await b.screenshot({ path: join(SHOTS, 'bots-3-lobby-phone.png'), fullPage: true });
|
|
});
|
|
|
|
await step('원카드: 시작 → 내 차례 4번, 컴퓨터도 둔다', async () => {
|
|
await b.getByRole('button', { name: '▶ 게임 시작' }).click();
|
|
await b.locator('.turn-status').waitFor();
|
|
let mine = 0;
|
|
let botTurns = 0;
|
|
let last = '';
|
|
for (let t = 0; t < 400 && mine < 4; t++) {
|
|
if (await resultShown(b)) break;
|
|
const one = b.locator('.oc-onebtn');
|
|
if ((await one.count()) && (await one.isEnabled().catch(() => false))) await one.click({ timeout: 1000 }).catch(() => {});
|
|
const s = await status(b);
|
|
if (s !== last && /컴퓨터 \d님 차례예요/.test(s)) botTurns++;
|
|
last = s;
|
|
if (!s.startsWith('내 차례예요')) {
|
|
const next = b.getByRole('button', { name: /다음 판/ });
|
|
if (await next.count()) await next.first().click().catch(() => {});
|
|
await b.waitForTimeout(150);
|
|
continue;
|
|
}
|
|
const accept = b.getByRole('button', { name: /^받아들이기/ });
|
|
const card = b.locator('button.oc-card[data-legal="true"]').first();
|
|
if (await accept.count()) await accept.click();
|
|
else if (await card.count()) {
|
|
await card.click();
|
|
await card.click();
|
|
const pick = b.locator('.oc-pick button').first();
|
|
if (await pick.waitFor({ timeout: 400 }).then(() => true, () => false)) await pick.click();
|
|
} else {
|
|
const pass = b.getByRole('button', { name: '넘기기' });
|
|
if (await pass.count()) await pass.click();
|
|
else await b.getByRole('button', { name: /장 먹기$/ }).first().click();
|
|
}
|
|
await b.waitForFunction((x) => document.querySelector('.turn-status')?.textContent?.trim() !== x, s.trim(), { timeout: 8000 });
|
|
mine++;
|
|
if (mine === 2) await b.screenshot({ path: join(SHOTS, 'bots-4-one-card-phone.png') });
|
|
}
|
|
if (mine < 4 && !(await resultShown(b))) throw new Error(`only ${mine} of my turns were played`);
|
|
if (botTurns < 2) throw new Error(`computers took only ${botTurns} turns`);
|
|
console.log(` one-card: my turns ${mine}, computer turns seen ${botTurns}`);
|
|
});
|
|
|
|
// ---------------------------------------------------------------- leaving with ← 홈 / 🚪 나가기, guest logout
|
|
await step('원카드(게임 중): ← 홈 → 기권 확인 → 방이 바로 닫힘', async () => {
|
|
const code = codeOf(b);
|
|
if (!app.rooms.get(code)) throw new Error('room missing before leaving');
|
|
const result = b.getByRole('dialog', { name: '게임 결과' });
|
|
if (await result.count()) await result.getByRole('button', { name: '방 나가기' }).click();
|
|
else {
|
|
await b.getByRole('button', { name: '방 나가고 홈으로' }).click();
|
|
await b.getByText('게임 중에 나가면 기권으로 처리돼요.').waitFor();
|
|
await b.getByRole('button', { name: '나가기', exact: true }).click();
|
|
}
|
|
await b.waitForURL(`${ORIGIN}/`);
|
|
for (let i = 0; i < 50 && app.rooms.get(code); i++) await Bun.sleep(20);
|
|
if (app.rooms.get(code)) throw new Error('room still open after everyone left');
|
|
});
|
|
|
|
await step('대기실: 🚪 나가기 한 번에 나감(확인 없음) → 방이 바로 닫힘', async () => {
|
|
await b.goto(`${ORIGIN}/g/one-card`);
|
|
await b.getByRole('button', { name: '🤖 컴퓨터와 연습하기' }).click();
|
|
await b.waitForURL(/\/r\/\d{6}$/);
|
|
await b.getByLabel('자리').getByText('컴퓨터 1').waitFor();
|
|
const code = codeOf(b);
|
|
await b.getByRole('button', { name: '🚪 나가기' }).click();
|
|
await b.waitForURL(`${ORIGIN}/`);
|
|
for (let i = 0; i < 50 && app.rooms.get(code); i++) await Bun.sleep(20);
|
|
if (app.rooms.get(code)) throw new Error('room still open after leaving the lobby');
|
|
});
|
|
|
|
await step('게스트 로그아웃 → 계정 삭제', async () => {
|
|
const id = userId('호랑이')!;
|
|
await a.getByRole('button', { name: '내 계정: 호랑이' }).click();
|
|
await a.getByRole('menuitem', { name: '🚪 로그아웃' }).click();
|
|
await a.getByText('게스트 계정은 로그아웃하면 이름과 기록이 모두 지워져요.').waitFor();
|
|
await a.getByRole('button', { name: '로그아웃', exact: true }).last().click();
|
|
await a.getByRole('link', { name: /로그인/ }).first().waitFor();
|
|
if (app.db.query('SELECT 1 FROM users WHERE id = ?').get(id)) throw new Error('guest row still there');
|
|
});
|
|
|
|
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);
|