Files
joke-app/apps/server/src/test-utils.ts
EJClaw b1f3d7c500 M1 서버: 통합 테스트 + 방 단위 테스트, 종료 대기·중도 이탈 자리 처리 수정
- 실제 서버 + WS 클라이언트로 테스트 문서 3절 시나리오 검증(방 만들기·링크 입장·재접속·
  서버 강제 종료 후 복구·차례 위반·중복 cs·탭 교체·방장 이전·도배 제한·잘못된 메시지·
  Origin 검사·디스코드 콜백 state 검증·게스트 합치기)
- 시계를 조작하는 방 단위 테스트: 시간 초과 자동 착수, 연결 끊김 유예, 재시작 후 마감 30초 유예,
  게임 오류 격리(3회 → 일시 정지), 한 판 더 흑백 교대, 게임 중 나가기
- Bun server.stop()이 서버 쪽 WS 종료 뒤 끝나지 않는 문제: 대기 시간 1초로 제한
- 게임 중 나간 사람의 자리가 게임 종료 후 비워지지 않던 버그 수정

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

129 lines
4.5 KiB
TypeScript

/** Helpers for integration tests: boot a real server, act as browser clients. */
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ServerMessage } from '@bg/shared';
import { loadConfig } from './config';
import { startServer, type StartOptions } from './server';
export const ORIGIN = 'http://localhost:5173';
export function tempDbPath(): string {
const dir = mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), 'bg-test-'));
return join(dir, 'app.db');
}
export function boot(dbPath = tempDbPath(), extra: Partial<StartOptions> = {}) {
const config = loadConfig({ PORT: '0', DB_PATH: dbPath, PUBLIC_ORIGIN: ORIGIN });
const app = startServer({ config, log: () => {}, staticDir: null, ...extra });
const base = `http://localhost:${app.server.port}`;
return { ...app, base, dbPath, config };
}
export async function api(base: string, path: string, init: RequestInit & { cookie?: string } = {}) {
const headers = new Headers(init.headers);
headers.set('origin', ORIGIN);
if (init.cookie) headers.set('cookie', init.cookie);
if (init.body && !headers.has('content-type')) headers.set('content-type', 'application/json');
const res = await fetch(base + path, { ...init, headers, redirect: 'manual' });
const setCookie = res.headers.get('set-cookie');
let body: any = null;
try {
body = await res.clone().json();
} catch {
body = await res.text();
}
return { res, body, setCookie };
}
export async function guest(base: string, nickname: string): Promise<{ cookie: string; id: string }> {
const r = await api(base, '/api/auth/guest', { method: 'POST', body: JSON.stringify({ nickname }) });
if (r.res.status !== 200) throw new Error(`guest failed: ${JSON.stringify(r.body)}`);
const cookie = r.setCookie!.split(';')[0]!;
return { cookie, id: r.body.me.id };
}
export class Client {
ws!: WebSocket;
messages: ServerMessage[] = [];
private waiters: { pred: (m: ServerMessage) => boolean; resolve: (m: ServerMessage) => void }[] = [];
closed: { code: number; reason: string } | null = null;
cs = 0;
constructor(
private base: string,
public cookie: string,
public id: string,
) {}
async connect(): Promise<this> {
this.closed = null;
const url = this.base.replace('http', 'ws') + '/ws';
// Bun's WebSocket client accepts custom headers.
this.ws = new WebSocket(url, { headers: { cookie: this.cookie, origin: ORIGIN } } as unknown as string[]);
this.ws.onmessage = (e) => {
const m = JSON.parse(String(e.data)) as ServerMessage;
this.messages.push(m);
for (const w of [...this.waiters]) {
if (w.pred(m)) {
this.waiters.splice(this.waiters.indexOf(w), 1);
w.resolve(m);
}
}
};
this.ws.onclose = (e) => {
this.closed = { code: e.code, reason: e.reason };
};
await this.waitFor((m) => m.t === 'welcome');
return this;
}
send(m: unknown): void {
this.ws.send(JSON.stringify(m));
}
/** Resolves with the first message (already received after `since`, or future) matching pred. */
waitFor<T extends ServerMessage['t']>(
pred: (m: ServerMessage) => boolean,
timeout = 3000,
since = 0,
): Promise<Extract<ServerMessage, { t: T }>> {
const existing = this.messages.slice(since).find(pred);
if (existing) return Promise.resolve(existing as any);
return new Promise((resolve, reject) => {
const w = { pred, resolve: resolve as (m: ServerMessage) => void };
this.waiters.push(w);
setTimeout(() => {
const i = this.waiters.indexOf(w);
if (i >= 0) {
this.waiters.splice(i, 1);
reject(new Error(`timeout waiting; last: ${JSON.stringify(this.messages.slice(-3))}`));
}
}, timeout);
});
}
/** Waits for the next message of type t received after now. */
next<T extends ServerMessage['t']>(t: T, pred: (m: Extract<ServerMessage, { t: T }>) => boolean = () => true, timeout = 3000) {
const since = this.messages.length;
return this.waitFor<T>((m) => m.t === t && pred(m as any), timeout, since);
}
lastOf<T extends ServerMessage['t']>(t: T): Extract<ServerMessage, { t: T }> | undefined {
for (let i = this.messages.length - 1; i >= 0; i--) if (this.messages[i]!.t === t) return this.messages[i] as any;
return undefined;
}
act(a: unknown): number {
const cs = ++this.cs;
this.send({ t: 'act', cs, a });
return cs;
}
close(): void {
this.ws.close();
}
}
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));