/** * CLIContext - Persistent Process Management for CLI Testing * * Maintains a single long-lived CLI process per test that accepts multiple commands. * Solves resource leak problems from spawning fresh processes per command. * * Inspired by nixt (interactive API, filesystem helpers, middleware hooks) and * execa (rich errors, verbose mode), but with persistent process architecture. */ import { type ChildProcess, spawn } from 'node:child_process'; import { EventEmitter } from 'node:events'; import { mkdir, rm, stat, writeFile as writeFileFs } from 'node:fs/promises'; import type { CLIResult } from './cli-result'; import { CLIResultImpl } from './cli-result'; /** * Command execution options */ export interface RunOptions { /** Timeout in milliseconds (default: 30000) */ timeout?: number; } /** * Response builder for interactive prompts * Provides fluent API: cli.on(pattern).respond(text) */ export class ResponseBuilder { constructor( private cli: CLIContext, private pattern: string | RegExp, ) {} /** * Respond to the prompt with given text * Waits for pattern to appear, then sends response * * Now enabled with persistent process! */ async respond(text: string, options: { timeout?: number } = {}): Promise { await this.cli.expectOutput(this.pattern, options); if (text) { await this.cli.sendKeys(text); } } } /** * Trace entry for debugging */ interface TraceEntry { type: 'command' | 'output' | 'error' | 'input'; timestamp: number; data: unknown; } /** * Command response from CLI server */ interface CommandResponse { id: number; success: boolean; message?: string; error?: string; details?: string; exitCode: number; duration: number; } /** * CLIContext - Persistent CLI process manager * * Uses true persistent process with CLI server mode * * @example * ```typescript * const cli = await CLIContext.create(); * * // Run commands (reuses same process) * await cli.run('module add homebridge').expectSuccess(); * await cli.run('module list'); * * // Interactive prompts (now enabled!) * await cli.on(/Enter hostname:/).respond('iot\n'); * * // Filesystem helpers (nixt-inspired) * await cli.mkdir('/tmp/test'); * await cli.writeFile('/tmp/config.json', '{}'); * * // Cleanup * await cli.dispose(); * ``` */ export class CLIContext { private process: ChildProcess | null = null; private outputBuffer = ''; private errorBuffer = ''; private trace: TraceEntry[] = []; private verbose = false; private commandId = 0; private pendingResponses = new Map< number, { resolve: (response: CommandResponse) => void; reject: (error: Error) => void; } >(); private hooks: { beforeEach: Array<() => Promise>; afterEach: Array<() => Promise>; } = { beforeEach: [], afterEach: [], }; private static customMethods: Record unknown> = {}; private events = new EventEmitter(); private constructor( private cliPath: string, private env: Record, ) {} /** * Create new CLI context with persistent process * * @param cliPath - Path to CLI entry point (default: src/cli/index.ts) * @param env - Environment variables for CLI process * @returns New CLIContext instance */ static async create( cliPath = 'src/cli/index.ts', env: Record = {}, ): Promise { const context = new CLIContext(cliPath, env); await context.startProcess(); return context; } /** * Start the persistent CLI process * * Starts true persistent process with CLI server mode. * Process stays alive and accepts commands via stdin/stdout protocol. */ private async startProcess(): Promise { // Set up ready promise BEFORE spawning to avoid race condition let readyResolve: () => void; let readyReject: (error: Error) => void; const readyPromise = new Promise((resolve, reject) => { readyResolve = resolve; readyReject = reject; }); const timeoutDuration = 10000; const timeout = setTimeout(() => { readyReject( new Error( `CLI process failed to start within ${timeoutDuration / 1000} seconds.\n` + `Check that CLI server mode is working: CLI_SERVER_MODE=true bun run ${this.cliPath}`, ), ); }, timeoutDuration); // Set up event listeners before spawn const onReady = () => { clearTimeout(timeout); readyResolve(); }; const onSpawnError = (error: Error) => { clearTimeout(timeout); this.events.off('ready', onReady); this.events.off('spawn-exit', onSpawnExit); readyReject(error); }; const onSpawnExit = (code: number) => { clearTimeout(timeout); this.events.off('ready', onReady); this.events.off('spawn-error', onSpawnError); readyReject(new Error(`CLI process exited prematurely with code ${code}`)); }; this.events.on('ready', onReady); this.events.on('spawn-error', onSpawnError); this.events.on('spawn-exit', onSpawnExit); this.process = spawn('bun', ['run', this.cliPath], { env: { ...process.env, ...this.env, CLI_SERVER_MODE: 'true', // Enable server mode }, stdio: ['pipe', 'pipe', 'pipe'], }); if (!this.process.stdout || !this.process.stderr || !this.process.stdin) { throw new Error('Failed to create process streams'); } // Handle stdout - parse JSON responses let stdoutBuffer = ''; this.process.stdout.on('data', (data: Buffer) => { const text = data.toString(); stdoutBuffer += text; // Capture all output for expectOutput() to search this.outputBuffer += text; // Process complete JSON lines const lines = stdoutBuffer.split('\n'); stdoutBuffer = lines.pop() || ''; // Keep incomplete line in buffer for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; // Only process JSON lines (ignore non-JSON output like "Database initialized...") if (!trimmed.startsWith('{')) { if (this.verbose) { console.log('[CLIContext] Non-JSON output:', trimmed); } continue; } try { const response = JSON.parse(trimmed); // Ready signal if (response.type === 'ready') { if (this.verbose) { console.log('[CLIContext] Process ready, PID:', response.pid); } this.events.emit('ready'); continue; } // Command response if (typeof response.id === 'number') { const pending = this.pendingResponses.get(response.id); if (pending) { this.pendingResponses.delete(response.id); pending.resolve(response as CommandResponse); } } } catch (error) { if (this.verbose) { console.error('[CLIContext] Failed to parse JSON:', trimmed, error); } } } }); // Handle stderr - log errors this.process.stderr.on('data', (data: Buffer) => { const text = data.toString(); this.errorBuffer += text; if (this.verbose) { console.error('[CLI stderr]', text); } this.trace.push({ type: 'error', timestamp: Date.now(), data: text, }); }); // Handle process exit this.process.on('exit', (code: number | null, signal: string | null) => { if (this.verbose) { console.log('[CLIContext] Process exited, code:', code, 'signal:', signal); } this.trace.push({ type: 'command', timestamp: Date.now(), data: { event: 'process_exit', code, signal }, }); // Reject all pending commands for (const [_id, pending] of this.pendingResponses.entries()) { pending.reject(new Error(`CLI process exited (code: ${code}, signal: ${signal})`)); } this.pendingResponses.clear(); // Emit events for readyPromise to catch this.events.emit('spawn-exit', code); }); // Handle process errors this.process.on('error', (error) => { if (this.verbose) { console.error('[CLIContext] Process error:', error); } this.events.emit('spawn-error', error); }); // Wait for ready signal await readyPromise; if (this.verbose) { console.log('[CLIContext] Persistent process started successfully'); } } /** * Run a command (uses persistent process) * * @param command - Command to execute * @param options - Execution options * @returns CLIResult with output and assertions */ async run(command: string, options: RunOptions = {}): Promise { if (!this.process || !this.process.stdin) { throw new Error('CLI process not started'); } // Run before hooks for (const hook of this.hooks.beforeEach) { await hook(); } const startTime = Date.now(); const id = ++this.commandId; this.trace.push({ type: 'command', timestamp: startTime, data: { command, id }, }); if (this.verbose) { console.log(`[CLI command #${id}] ${command}`); } // Send command to server const request = JSON.stringify({ command, id }); this.process.stdin.write(`${request}\n`); this.trace.push({ type: 'input', timestamp: Date.now(), data: request, }); // Wait for response const timeout = options.timeout ?? 30000; const response = await Promise.race([ // Response promise new Promise((resolve, reject) => { this.pendingResponses.set(id, { resolve, reject }); }), // Timeout promise. Name the command and the actual elapsed time, not // just the budget — a bare "timed out after 30000ms" reads as a hang // in the command under test, indistinguishable from a real deploy // defect. `Date.now() - startTime` at fire time is normally ~= timeout, // but under CI load the event loop can be too busy to run this // callback promptly, so a MUCH larger elapsed-than-budget is itself a // load signal, not a fluke to explain away (celilo#804). new Promise((_, reject) => setTimeout(() => { const elapsed = Date.now() - startTime; reject( new Error( `Command #${id} "${command}" timed out after ${timeout}ms (elapsed ${elapsed}ms)`, ), ); }, timeout), ), ]); // Clean up if timeout raced this.pendingResponses.delete(id); const duration = Date.now() - startTime; if (this.verbose) { console.log(`[CLI response #${id}] exitCode=${response.exitCode}, duration=${duration}ms`); } // Create result const result = new CLIResultImpl( command, response.message || '', response.error || '', response.exitCode, duration, false, // Server mode doesn't timeout, we handle it at request level ); // Run after hooks for (const hook of this.hooks.afterEach) { await hook(); } return result; } /** * Run command expecting failure * Syntactic sugar for run() with expectFailure() */ async runExpectingFailure(command: string, options: RunOptions = {}): Promise { const result = await this.run(command, options); return result.expectFailure(); } /** * Wait for output pattern to appear (nixt-inspired) * Returns ResponseBuilder for fluent API */ on(pattern: string | RegExp): ResponseBuilder { return new ResponseBuilder(this, pattern); } /** * Wait for output pattern to appear * * Now enabled with persistent process! * Monitors outputBuffer for pattern match. * * @param pattern - String or regex to match in output * @param options - Options with timeout */ async expectOutput(pattern: string | RegExp, options: { timeout?: number } = {}): Promise { const timeout = options.timeout ?? 5000; const startTime = Date.now(); return new Promise((resolve, reject) => { const checkOutput = () => { const matches = typeof pattern === 'string' ? this.outputBuffer.includes(pattern) : pattern.test(this.outputBuffer); if (matches) { resolve(); return; } if (Date.now() - startTime > timeout) { reject( new Error(`Timeout waiting for pattern: ${pattern}\nOutput: ${this.outputBuffer}`), ); return; } setTimeout(checkOutput, 50); }; checkOutput(); }); } /** * Send keys to stdin * * Now enabled with persistent process! * Sends input directly to the CLI process stdin. */ async sendKeys(input: string): Promise { if (!this.process || !this.process.stdin) { throw new Error('CLI process not started'); } this.process.stdin.write(input); this.trace.push({ type: 'input', timestamp: Date.now(), data: input, }); if (this.verbose) { console.log(`[CLI input] ${input}`); } } /** * Register middleware hook to run before each command (nixt-inspired) */ beforeEach(fn: () => Promise): this { this.hooks.beforeEach.push(fn); return this; } /** * Register middleware hook to run after each command (nixt-inspired) */ afterEach(fn: () => Promise): this { this.hooks.afterEach.push(fn); return this; } /** * Create directory (nixt-inspired filesystem helper) */ async mkdir(path: string): Promise { await mkdir(path, { recursive: true }); this.trace.push({ type: 'command', timestamp: Date.now(), data: { filesystem: 'mkdir', path }, }); } /** * Write file (nixt-inspired filesystem helper) */ async writeFile(path: string, content: string): Promise { await writeFileFs(path, content, 'utf-8'); this.trace.push({ type: 'command', timestamp: Date.now(), data: { filesystem: 'writeFile', path, size: content.length }, }); } /** * Remove directory (nixt-inspired filesystem helper) */ async rmdir(path: string): Promise { await rm(path, { recursive: true, force: true }); this.trace.push({ type: 'command', timestamp: Date.now(), data: { filesystem: 'rmdir', path }, }); } /** * Remove file (nixt-inspired filesystem helper) */ async unlink(path: string): Promise { await rm(path, { force: true }); this.trace.push({ type: 'command', timestamp: Date.now(), data: { filesystem: 'unlink', path }, }); } /** * Check if path exists (nixt-inspired filesystem helper) */ async exists(path: string): Promise { try { await stat(path); return true; } catch { return false; } } /** * Enable/disable verbose mode (execa-inspired) * When enabled, logs all I/O to console */ setVerbose(enabled: boolean): this { this.verbose = enabled; return this; } /** * Clone context with same configuration (nixt-inspired) * Creates new context with same env and hooks * * Starts a new persistent process for the cloned context */ async clone(): Promise { const cloned = new CLIContext(this.cliPath, { ...this.env }); cloned.hooks = { beforeEach: [...this.hooks.beforeEach], afterEach: [...this.hooks.afterEach], }; cloned.verbose = this.verbose; await cloned.startProcess(); return cloned; } /** * Export execution trace for debugging * * @param format - Output format (json, html, markdown) * @returns Formatted trace string */ exportTrace(format: 'json' | 'html' | 'markdown'): string { switch (format) { case 'json': return JSON.stringify(this.trace, null, 2); case 'html': return this.traceToHtml(); case 'markdown': return this.traceToMarkdown(); default: throw new Error(`Unknown format: ${format}`); } } private traceToHtml(): string { // TODO: Implement HTML trace viewer return `
${JSON.stringify(this.trace, null, 2)}
`; } private traceToMarkdown(): string { let md = '# CLI Execution Trace\n\n'; for (const entry of this.trace) { const time = new Date(entry.timestamp).toISOString(); md += `## ${time} - ${entry.type}\n\n`; md += '```\n'; md += typeof entry.data === 'string' ? entry.data : JSON.stringify(entry.data, null, 2); md += '\n```\n\n'; } return md; } /** * Cleanup and terminate process * Sends exit command to server and waits for graceful shutdown */ async dispose(): Promise { if (this.verbose) { console.log('[CLIContext] Disposing...'); } if (this.process?.stdin) { try { // Send exit command const id = ++this.commandId; const request = JSON.stringify({ command: '__exit__', id }); this.process.stdin.write(`${request}\n`); // Wait for exit (with timeout) await Promise.race([ new Promise((resolve) => { this.process?.once('exit', () => resolve()); }), new Promise((resolve) => setTimeout(resolve, 1000)), ]); } catch (error) { // Ignore errors during shutdown if (this.verbose) { console.warn('[CLIContext] Error during dispose:', error); } } // Force kill if still alive if (this.process && !this.process.killed) { this.process.kill(); } } this.process = null; if (this.verbose) { console.log('[CLIContext] Disposed'); } } /** * Register custom method (nixt-inspired module system) * * @example * ```typescript * CLIContext.register('expectModuleInstalled', function(id: string) { * return this.run('module list').expectStdout(new RegExp(id)); * }); * * await cli.expectModuleInstalled('homebridge'); * ``` */ static register(name: string, fn: (...args: unknown[]) => unknown): void; static register(modules: Record unknown>): void; static register( nameOrModules: string | Record unknown>, fn?: (...args: unknown[]) => unknown, ): void { if (typeof nameOrModules === 'string' && fn) { CLIContext.customMethods[nameOrModules] = fn; // Add to prototype (CLIContext.prototype as unknown as Record unknown>)[ nameOrModules ] = fn; } else if (typeof nameOrModules === 'object') { for (const [name, func] of Object.entries(nameOrModules)) { CLIContext.register(name, func); } } } }