import { spawn, type ChildProcess } from 'node:child_process'; import { mkdir, rm, readFile } from 'node:fs/promises'; import { createWriteStream } from 'node:fs'; import path from 'node:path'; import { createServer } from 'node:net'; import { fileURLToPath } from 'node:url'; import crypto from 'node:crypto'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); export interface SpawnedInstance { proc: ChildProcess; port: number; origin: string; // 'http://127.0.0.1:' domain: string; // 'home.test.local' / 'remote0.test.local' / ... dbPath: string; storagePath: string; jwtSecret: string; logPath: string; } export interface MultiRemoteHarness { home: SpawnedInstance; remotes: SpawnedInstance[]; runDir: string; cleanup: () => Promise; } // Convenience type alias for the common one-remote case. export interface TwoInstanceHarness { home: SpawnedInstance; remote: SpawnedInstance; remotes: SpawnedInstance[]; // for code that wants the array form runDir: string; cleanup: () => Promise; } async function allocateEphemeralPort(): Promise { return new Promise((resolve, reject) => { const srv = createServer(); srv.listen(0, '127.0.0.1', () => { const addr = srv.address(); if (addr && typeof addr === 'object') { const port = addr.port; srv.close(() => resolve(port)); } else { srv.close(() => reject(new Error('Could not allocate ephemeral port'))); } }); srv.on('error', reject); }); } async function waitForReady(origin: string, proc: ChildProcess, logPath: string, timeoutMs = 20_000): Promise { const deadline = Date.now() + timeoutMs; let exited = false; let exitInfo: { code: number | null; signal: NodeJS.Signals | null } | null = null; const onExit = (code: number | null, signal: NodeJS.Signals | null) => { exited = true; exitInfo = { code, signal }; }; proc.once('exit', onExit); try { while (Date.now() < deadline) { if (exited) { throw new Error( `Instance ${origin} exited during boot (code=${exitInfo?.code}, signal=${exitInfo?.signal}). ` + `See log at ${logPath}`, ); } try { const res = await fetch(`${origin}/api/instance/info`); if (res.ok) return; } catch { // not ready yet } await new Promise(r => setTimeout(r, 200)); } throw new Error(`Instance ${origin} did not become ready within ${timeoutMs}ms. See log at ${logPath}`); } finally { proc.off('exit', onExit); } } async function spawnInstance(opts: { domain: string; port: number; dbPath: string; storagePath: string; jwtSecret: string; logPath: string; disableRateLimits?: boolean; }): Promise { const env: Record = { ...process.env, NODE_ENV: 'test', ENABLE_TEST_ROUTES: '1', DISABLE_FEDERATION_WORKERS: '1', PORT: String(opts.port), HOST: '127.0.0.1', DOMAIN: opts.domain, DB_PATH: opts.dbPath, STORAGE_PATH: opts.storagePath, JWT_SECRET: opts.jwtSecret, LIVEKIT_URL: '', LIVEKIT_API_KEY: '', LIVEKIT_API_SECRET: '', }; // Default: bypass rate limits so unrelated tests don't exhaust the per-IP // bucket on 127.0.0.1. Test #15 (rate-limit assertion) opts out via // bootTwoInstancesWithRateLimits(). if (opts.disableRateLimits !== false) { env.DISABLE_RATE_LIMITS = '1'; } // From packages/server/test/helpers → packages/server is up two levels. const serverDir = path.resolve(__dirname, '../../'); const proc = spawn('pnpm', ['exec', 'tsx', 'src/index.ts'], { cwd: serverDir, env, stdio: ['ignore', 'pipe', 'pipe'], }); const logStream = createWriteStream(opts.logPath); proc.stdout!.pipe(logStream); proc.stderr!.pipe(logStream); const origin = `http://127.0.0.1:${opts.port}`; await waitForReady(origin, proc, opts.logPath); return { proc, port: opts.port, origin, domain: opts.domain, dbPath: opts.dbPath, storagePath: opts.storagePath, jwtSecret: opts.jwtSecret, logPath: opts.logPath, }; } export interface BootOptions { /** * If true, spawned instances do NOT receive `DISABLE_RATE_LIMITS=1`, so * `@fastify/rate-limit` enforces real per-IP/per-user buckets. Used only by * tests that assert rate-limit behaviour (Test #15). Default: false (bypass on). */ enableRateLimits?: boolean; } /** Boot home + N remotes. All instances ready before the function returns. */ export async function bootHomePlusRemotes( remoteCount: number, options: BootOptions = {}, ): Promise { if (remoteCount < 1) throw new Error('remoteCount must be >= 1'); const disableRateLimits = !options.enableRateLimits; const runId = crypto.randomBytes(4).toString('hex'); // From packages/server/test/helpers → repo root is up four levels: helpers → test → server → packages → repo-root. const runDir = path.resolve(__dirname, `../../../../tests/.tmp/${runId}`); await mkdir(`${runDir}/home-uploads`, { recursive: true }); const homePort = await allocateEphemeralPort(); const home = await spawnInstance({ domain: 'home.test.local', port: homePort, dbPath: `${runDir}/home.db`, storagePath: `${runDir}/home-uploads`, jwtSecret: crypto.randomBytes(32).toString('hex'), logPath: `${runDir}/home.log`, disableRateLimits, }); const remotes: SpawnedInstance[] = []; for (let i = 0; i < remoteCount; i++) { await mkdir(`${runDir}/remote${i}-uploads`, { recursive: true }); const port = await allocateEphemeralPort(); const r = await spawnInstance({ domain: `remote${i}.test.local`, port, dbPath: `${runDir}/remote${i}.db`, storagePath: `${runDir}/remote${i}-uploads`, jwtSecret: crypto.randomBytes(32).toString('hex'), logPath: `${runDir}/remote${i}.log`, disableRateLimits, }); remotes.push(r); } let cleanedUp = false; const cleanup = async () => { if (cleanedUp) return; cleanedUp = true; for (const inst of [home, ...remotes]) { if (!inst.proc.killed) { inst.proc.kill('SIGTERM'); await new Promise(r => setTimeout(r, 500)); if (!inst.proc.killed) inst.proc.kill('SIGKILL'); } } await rm(runDir, { recursive: true, force: true }); }; return { home, remotes, runDir, cleanup }; } /** Convenience: 1 home + 1 remote. Exposes both `remote` (singular) and `remotes` (array). */ export async function bootTwoInstances(options: BootOptions = {}): Promise { const m = await bootHomePlusRemotes(1, options); return { home: m.home, remote: m.remotes[0], remotes: m.remotes, runDir: m.runDir, cleanup: m.cleanup, }; } /** * Variant of `bootTwoInstances` that does NOT set `DISABLE_RATE_LIMITS`, so the * spawned instances enforce real `@fastify/rate-limit` buckets. Used by Test #15 * (rate-limit assertion) ONLY — every other test should use `bootTwoInstances` / * `bootHomePlusRemotes` so unrelated tests don't exhaust the shared loopback IP * bucket. Same shape as `bootTwoInstances`, just with a different env profile. */ export async function bootTwoInstancesWithRateLimits(): Promise { return bootTwoInstances({ enableRateLimits: true }); } /** * Read the contents of an instance's log file. Used by tests that need to assert * a specific request did or did not reach an instance (Task 14 / Test #1). */ export async function readInstanceLog(inst: SpawnedInstance): Promise { try { return await readFile(inst.logPath, 'utf8'); } catch { return ''; } } /** * Wait `delayMs` (to let any in-flight request settle into the log file), then * return whether `pattern` appears in `inst`'s log. Used to prove a request did * NOT happen — assert the result is false. */ export async function logMatched(inst: SpawnedInstance, pattern: RegExp, delayMs = 1_000): Promise { await new Promise(r => setTimeout(r, delayMs)); const log = await readInstanceLog(inst); return pattern.test(log); }