Files
joke-app/e2e/admin.e2e.ts
EJClaw a10139b51a M2: 바둑 추가, 4게임 E2E 안정화, 로드맵 M2 결과 기록
- 바둑: 9·13·19줄, 따내기·자충수 금지·슈퍼코(Zobrist)/한국식 삼패 무승부, 접바둑·덤, 패스,
  사석 확인 단계(자동 제안·동의·이의 시 재개), 집/영역 계가, 초읽기·피셔 시계 (테스트 50개)
- 쓰이지 않던 바둑 옵션(연결 끊김 유예 — 방 설정이 담당) 제거
- 홈 '방 만들기'가 게임 고르기 창을 거치도록 바뀐 것에 맞춰 E2E 수정, 장기 상차림 단계 E2E 안정화
- 전체: 테스트 230개, E2E 오목·관리자·4게임·접근성 모두 통과

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

132 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.
/**
* Admin E2E (docs/14-admin.md §9): superadmin logs in with (mocked) Discord, edits omok defaults
* with a lock, adds an admin; a guest's new room reflects the settings.
* Run: bun e2e/admin.e2e.ts (needs apps/web/dist built)
*/
import { chromium } 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 ?? 4518);
const ORIGIN = `http://localhost:${PORT}`;
const SHOTS = process.env.E2E_SHOTS ?? join(import.meta.dir, '../test-results');
mkdirSync(SHOTS, { recursive: true });
const SUPER = '293719842274541579';
const config = loadConfig({ PORT: String(PORT), DB_PATH: join(mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'bg-e2e-')), 'app.db'), PUBLIC_ORIGIN: ORIGIN });
config.discord = { clientId: 'e2e', clientSecret: 'e2e' };
const app = startServer({
config,
log: () => {},
staticDir: join(import.meta.dir, '../apps/web/dist'),
discordExchange: async (code) => ({ id: code, username: 'owner', global_name: '사금향', avatar: null }),
});
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) {
console.log(`✗ ${name}: ${(e as Error).message.split('\n')[0]}`);
throw e;
}
};
try {
const adminCtx = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'ko-KR' });
// Fake Discord: Playwright does not route redirect targets, so intercept our own /start, let the server
// set its state cookie, and bounce straight back to the callback with the superadmin's ID as the code.
await adminCtx.route(`${ORIGIN}/api/auth/discord/start*`, async (route) => {
const res = await fetch(route.request().url(), { redirect: 'manual' });
const loc = new URL(res.headers.get('location')!);
const back = `${ORIGIN}/api/auth/discord/callback?code=${SUPER}&state=${loc.searchParams.get('state')}`;
await route.fulfill({
status: 200,
headers: { 'content-type': 'text/html', 'set-cookie': res.headers.get('set-cookie') ?? '' },
body: `<script>location.replace(${JSON.stringify(back)})</script>`,
});
});
const a = await adminCtx.newPage();
const errors: string[] = [];
a.on('pageerror', (e) => errors.push(e.message));
await step('일반 사용자는 관리자 페이지 접근 불가', async () => {
await a.goto(ORIGIN + '/admin');
await a.getByText('권한이 없어요').waitFor();
});
await step('슈퍼어드민 디스코드 로그인 → 계정 페이지에 관리자 버튼', async () => {
await a.goto(ORIGIN + '/me');
await a.getByRole('link', { name: '디스코드로 로그인' }).click();
await a.waitForURL(`${ORIGIN}/me`);
await a.getByRole('button', { name: /관리자 페이지 \(슈퍼어드민\)/ }).click();
await a.getByText('지금 접속').waitFor();
await a.screenshot({ path: join(SHOTS, 'admin-1-dashboard.png') });
});
await step('게임 설정: 오목 렌주룰 고정 + 한 수 45초 저장', async () => {
await a.locator('.admin-side').getByRole('link', { name: '게임', exact: true }).click();
await a.getByRole('button', { name: /오목/ }).click();
await a.getByText('기본 규칙').waitFor();
const rule = a.locator('div.stack:has(> div.row > strong:text-is("규칙"))').first();
await rule.locator('select').selectOption('renju');
await rule.getByLabel(/방장 변경 금지/).check();
const per = a.locator('div.stack:has(> div.row > strong:text-is("한 수 제한(초)"))').first();
await per.locator('input[type=number]').fill('45');
await a.screenshot({ path: join(SHOTS, 'admin-2-game-editor.png') });
await a.getByRole('button', { name: '저장', exact: true }).click();
await a.getByText('저장했어요.').waitFor();
});
await step('관리자 탭: 디스코드 ID로 어드민 추가', async () => {
await a.locator('.admin-side').getByRole('link', { name: '관리자', exact: true }).click();
await a.getByLabel('디스코드 ID(숫자)').fill('222222222222222222');
await a.getByLabel('메모(선택)').fill('친구');
await a.getByRole('button', { name: '추가', exact: true }).click();
await a.getByText('222222222222222222 · 친구').waitFor();
await a.screenshot({ path: join(SHOTS, 'admin-3-admins.png') });
});
await step('기록 탭에 변경 내역', async () => {
await a.locator('.admin-side').getByRole('link', { name: '기록', exact: true }).click();
await a.getByText('어드민 추가').waitFor();
await a.getByText('게임 설정 변경').waitFor();
});
await step('게스트의 새 오목 방: 렌주룰 · 45초 + 잠금 안내', async () => {
const g = await (await browser.newContext({ viewport: { width: 390, height: 844 }, locale: 'ko-KR' })).newPage();
g.on('pageerror', (e) => errors.push(e.message));
await g.goto(ORIGIN + '/');
await g.getByRole('button', { name: '+ 방 만들기' }).click();
await g.getByRole('dialog', { name: '게임 고르기' }).getByRole('button', { name: /오목/ }).click();
await g.getByLabel('내 이름(닉네임)').fill('손님');
await g.getByRole('button', { name: '이 이름으로 시작하기' }).click();
await g.waitForURL(/\/r\/\d{6}$/);
await g.getByText('렌주룰 · 15줄 · 한 수 45초').waitFor();
await g.getByText('일부 규칙은 관리자가 정해 두어서').waitFor();
// Trying to switch to 한국식 is overridden by the server.
await g.getByRole('button', { name: '설정 바꾸기' }).click();
await g.getByRole('button', { name: '한국식(쌍삼 금지)' }).click();
await g.getByRole('button', { name: '저장' }).click();
await g.waitForTimeout(300);
await g.getByText('렌주룰 · 15줄 · 한 수 45초').waitFor();
await g.goto(ORIGIN + '/admin');
await g.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);