import { spawn, ChildProcess } from 'child_process'; import { join } from 'path'; export interface ApiServerHarnessOptions { port?: number; env?: Record; } export class ApiServerHarness { private process: ChildProcess | null = null; private logs: string[] = []; private port: number; constructor(options: ApiServerHarnessOptions = {}) { this.port = options.port || 3001; } async start(): Promise { return new Promise((resolve, reject) => { const cwd = join(process.cwd(), 'apps/api'); this.process = spawn('npm', ['run', 'start:dev'], { cwd, env: { ...process.env, PORT: this.port.toString(), GRIDPILOT_API_PERSISTENCE: 'inmemory', ENABLE_BOOTSTRAP: 'true', }, shell: true, detached: true, }); let resolved = false; const checkReadiness = async () => { if (resolved) return; try { const res = await fetch(`http://localhost:${this.port}/health`); if (res.ok) { resolved = true; resolve(); } } catch (e) { // Not ready yet } }; this.process.stdout?.on('data', (data) => { const str = data.toString(); this.logs.push(str); if (str.includes('Nest application successfully started') || str.includes('started')) { checkReadiness(); } }); this.process.stderr?.on('data', (data) => { const str = data.toString(); this.logs.push(str); }); this.process.on('error', (err) => { if (!resolved) { resolved = true; reject(err); } }); this.process.on('exit', (code) => { if (!resolved && code !== 0 && code !== null) { resolved = true; reject(new Error(`API server exited with code ${code}`)); } }); // Timeout after 60 seconds setTimeout(() => { if (!resolved) { resolved = true; reject(new Error(`API server failed to start within 60s. Logs:\n${this.getLogTail(20)}`)); } }, 60000); }); } async stop(): Promise { if (this.process && this.process.pid) { try { process.kill(-this.process.pid); } catch (e) { this.process.kill(); } this.process = null; } } getLogTail(lines: number = 60): string { return this.logs.slice(-lines).join(''); } }