Files
joke-app/e2e/admin.e2e.ts
EJClaw 19c163f4d2 시간 제한 없음은 정말 무제한, 대기실 설정은 바꾸면 바로 적용, 모르는 사람 기다리기(빠른 시작·공개 방) 제거
- 오목에서 [시간 제한 없음]을 골라도 30초가 흐르던 문제: 대기실 설정이 [설정 저장]을 눌러야
  서버에 반영되어, 누르지 않고 [게임 시작]하면 기본값(한 수 30초)으로 시작했다. 이제 설정은
  바꾸는 즉시 서버에 보낸다(저장 버튼 없음, 휴대폰 시트는 [닫기]만). 빠르게 여러 번 눌러도
  먼저 온 방 정보가 새 값을 되돌리지 않게 lib/option-sync.ts로 맞춘다.
- 시간 제한 없음(오목·체스·장기·오델로·바둑·쿼리도): 무응답 한도(idleLimitSec, 5~10분 뒤 시간패)와
  그 카운트다운을 없앴다. 체스는 첫 수 제한도 피셔일 때만. 연결이 끊긴 사람의 접속 유예는 그대로.
- 홈의 [지금 열린 방] 탭, 게임 소개의 [아무나와 빠른 시작]·기다리는 방 목록, 대기실의 방 공개
  설정을 뺐다(방은 모두 친구만). 서버 API는 남겨 두고 화면에서만 부르지 않는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:42:46 +09:00

149 lines
7.8 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 });
/** Made-up ID; the real superadmin IDs live only in deploy/.env. */
const SUPER = '900000000000000001';
const config = loadConfig({ PORT: String(PORT), DB_PATH: join(mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'bg-e2e-')), 'app.db'), PUBLIC_ORIGIN: ORIGIN, SUPERADMIN_DISCORD_IDS: SUPER });
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');
// Changes apply at once; the server keeps the locked rule and the sheet follows it.
await g.getByRole('button', { name: '한국식(쌍삼 금지)' }).click();
await g.waitForTimeout(300);
if ((await g.getByRole('button', { name: '렌주룰' }).getAttribute('aria-pressed')) !== 'true') throw new Error('locked rule not restored in the sheet');
await g.getByRole('button', { name: '닫기' }).click();
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);