import { spawn, ChildProcess } from 'child_process'; import { join } from 'path'; export interface WebsiteServerHarnessOptions { port?: number; env?: Record; cwd?: string; } export class WebsiteServerHarness { private process: ChildProcess | null = null; private logs: string[] = []; private port: number; private options: WebsiteServerHarnessOptions; constructor(options: WebsiteServerHarnessOptions = {}) { this.options = options; this.port = options.port || 3000; } async start(): Promise { return new Promise((resolve, reject) => { const cwd = join(process.cwd(), 'apps/website'); // Use 'npm run dev' or 'npm run start' depending on environment // For integration tests, 'dev' is often easier if we don't want to build first, // but 'start' is more realistic for SSR. // Assuming 'npm run dev' for now as it's faster for local tests. this.process = spawn('npm', ['run', 'dev', '--', '-p', this.port.toString()], { cwd, env: { ...process.env, ...this.options.env, PORT: this.port.toString(), }, shell: true, detached: true, // Start in a new process group }); let resolved = false; const checkReadiness = async () => { if (resolved) return; try { const res = await fetch(`http://localhost:${this.port}`, { method: 'HEAD' }); if (res.ok || res.status === 307 || res.status === 200) { 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('ready') || str.includes('started') || str.includes('Local:')) { checkReadiness(); } }); this.process.stderr?.on('data', (data) => { const str = data.toString(); this.logs.push(str); // Don't console.error here as it might be noisy, but keep in logs }); 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(`Website server exited with code ${code}`)); } }); // Timeout after 60 seconds (Next.js dev can be slow) setTimeout(() => { if (!resolved) { resolved = true; reject(new Error(`Website server failed to start within 60s. Logs:\n${this.getLogTail(20)}`)); } }, 60000); }); } async stop(): Promise { if (this.process && this.process.pid) { try { // Kill the process group since we used detached: true process.kill(-this.process.pid); } catch (e) { // Fallback to normal kill this.process.kill(); } this.process = null; } } getLogs(): string[] { return this.logs; } getLogTail(lines: number = 60): string { return this.logs.slice(-lines).join(''); } hasErrorPatterns(): boolean { const errorPatterns = [ 'uncaughtException', 'unhandledRejection', // 'Error: ', // Too broad, catches expected API errors ]; // Only fail on actual process-level errors or unexpected server crashes return this.logs.some(log => errorPatterns.some(pattern => log.includes(pattern))); } }