Files
joke-app/e2e/admin.e2e.ts
EJClaw 373231b553 위협 가이드를 관리자 전용 옵션으로(기본 꺼짐) + 최고관리자 추가 + 계정 메뉴에 관리자 메뉴
- 엔진: GameDefinition.adminOnlyOptions — 방은 항상 관리자 값 사용(잠금처럼 강제, 잠금 안내는 안 띄움)
- 오목·체스·장기 threatHints, 바둑 atariWarning: 기본 false, 대기실 설정에서 뺌, 관리자 화면 게임 기본 규칙에서만 켬
- 체스·장기 hints는 off / moves만 남김
- 최고관리자 기본값에 1352267557213573160 추가(.env.example·문서 포함)
- 상단 계정 메뉴: 관리자·최고관리자면 [🛠 관리자 메뉴] → /admin
- 테스트: admin.test(관리자 전용 옵션 강제, 두 번째 최고관리자), admin.e2e(계정 메뉴, 위협 가이드 켜기 저장)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:59:32 +09:00

146 lines
7.5 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('상단 계정 메뉴에 [관리자 메뉴] → 관리자 화면', async () => {
await a.goto(ORIGIN + '/');
await a.getByRole('button', { name: /내 계정:/ }).click();
await a.getByRole('menuitem', { name: /관리자 메뉴/ }).click();
await a.waitForURL(`${ORIGIN}/admin`);
await a.getByText('지금 접속').waitFor();
});
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');
const threat = a.locator('div.stack:has(> div.row > strong:text-is("위협 가이드(관리자 전용): 상대 3·4를 막을 자리 표시"))').first();
await threat.getByRole('button', { name: '켜기', exact: true }).click();
await a.screenshot({ path: join(SHOTS, 'admin-2-game-editor.png') });
await a.getByRole('button', { name: '저장', exact: true }).click();
await a.getByText('저장했어요.').waitFor();
const saved = await a.evaluate(async () => (await (await fetch('/api/admin/games')).json()).games.find((x: any) => x.id === 'omok').effective.defaultOptions.threatHints);
if (saved !== true) throw new Error(`threatHints not saved: ${saved}`);
});
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();
// The admin turned the threat guide on; the host has no switch for it (checked below).
// Trying to switch to 한국식 is overridden by the server.
await g.getByRole('button', { name: '설정 바꾸기' }).click();
if (await g.getByText(/위험한 자리 알려|위협 가이드/).count()) throw new Error('host sees a threat-guide switch');
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);