/** 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 = {}) { 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.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( pred: (m: ServerMessage) => boolean, timeout = 3000, since = 0, ): Promise> { 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: T, pred: (m: Extract) => boolean = () => true, timeout = 3000) { const since = this.messages.length; return this.waitFor((m) => m.t === t && pred(m as any), timeout, since); } lastOf(t: T): Extract | 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));