- 실제 서버 + WS 클라이언트로 테스트 문서 3절 시나리오 검증(방 만들기·링크 입장·재접속· 서버 강제 종료 후 복구·차례 위반·중복 cs·탭 교체·방장 이전·도배 제한·잘못된 메시지· Origin 검사·디스코드 콜백 state 검증·게스트 합치기) - 시계를 조작하는 방 단위 테스트: 시간 초과 자동 착수, 연결 끊김 유예, 재시작 후 마감 30초 유예, 게임 오류 격리(3회 → 일시 정지), 한 판 더 흑백 교대, 게임 중 나가기 - Bun server.stop()이 서버 쪽 WS 종료 뒤 끝나지 않는 문제: 대기 시간 1초로 제한 - 게임 중 나간 사람의 자리가 게임 종료 후 비워지지 않던 버그 수정 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
129 lines
4.5 KiB
TypeScript
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));
|