Files
joke-app/e2e/admin.e2e.ts
EJClaw f81f20a4bf 웹: PC 중심 화면으로 재구성
- 공통 상단 메뉴(사이트 이름·홈·게임 목록·관리자·어디서나 코드 입력·내 정보)
- 홈: 방 만들기 + 코드 입력 카드, 게임 격자, 지금 열린 공개 방 목록(/api/public-rooms)
- 대기실: 두 칸(초대·설정 바로 수정·규칙 요약 / 자리·시작 버튼·채팅 항상 보임)
- 게임 화면: 판(화면 높이에 맞춤, 좌표 표시) + 오른쪽 패널(상태·선수·시계·버튼·기보·채팅)
- 오목: 마우스 올리면 미리 보기 돌, PC는 한 번 클릭으로 착수(두 번 눌러 확정은 터치 기기 기본값,
  기기별 설정), GameUI에 Side 패널 추가
- 채팅: PC는 패널로 항상 표시(Enter로 바로 입력), 휴대폰은 시트
- 관리자: 왼쪽 사이드바, 사용자 표, 규칙 입력 두 칸 격자, 저장 바 고정
- 좁은 화면은 같은 화면을 한 칸으로 접어서 지원(방 상단 줄 줄바꿈 수정)
- E2E: PC 1440×900 기준(방장 PC, 친구 휴대폰), 관리자 E2E도 PC

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

131 lines
6.3 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.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);