/** * 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) => { 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: ``, }); }); 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.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);