- 바둑: 9·13·19줄, 따내기·자충수 금지·슈퍼코(Zobrist)/한국식 삼패 무승부, 접바둑·덤, 패스, 사석 확인 단계(자동 제안·동의·이의 시 재개), 집/영역 계가, 초읽기·피셔 시계 (테스트 50개) - 쓰이지 않던 바둑 옵션(연결 끊김 유예 — 방 설정이 담당) 제거 - 홈 '방 만들기'가 게임 고르기 창을 거치도록 바뀐 것에 맞춰 E2E 수정, 장기 상차림 단계 E2E 안정화 - 전체: 테스트 230개, E2E 오목·관리자·4게임·접근성 모두 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
132 lines
6.4 KiB
TypeScript
132 lines
6.4 KiB
TypeScript
/**
|
||
* 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);
|