import { execa } from 'execa'; import { join } from 'path'; import { EventEmitter } from 'events'; export interface CLIExecutionResult { stdout: string; stderr: string; exitCode: number; executionTime: number; timedOut: boolean; isTerminated: boolean; signal?: string; } export interface CLIExecutionOptions { timeout?: number; env?: Record; cwd?: string; input?: string; killSignal?: NodeJS.Signals; maxBuffer?: number; encoding?: BufferEncoding; } export interface ProcessInfo { pid?: number; command: string; args: string[]; startTime: number; status: 'running' | 'completed' | 'killed' | 'timeout' | 'error'; endTime?: number; duration?: number; } export class CLIExecutionError extends Error { constructor( message: string, public readonly result: CLIExecutionResult, public readonly processInfo: ProcessInfo ) { super(message); this.name = 'CLIExecutionError'; } } export class CLIExecutor extends EventEmitter { private readonly cliPath: string; private readonly activeProcesses: Map = new Map(); private readonly processInfoMap: Map = new Map(); private processCounter = 0; private readonly maxConcurrentProcesses: number; constructor(cliPath?: string, maxConcurrentProcesses: number = 10) { super(); this.cliPath = cliPath || (global as any).TEST_CONFIG?.CLI_PATH || join(__dirname, '../../../../bin/ocp.js'); this.maxConcurrentProcesses = maxConcurrentProcesses; } async execute(args: string[], options: CLIExecutionOptions = {}): Promise { // Check process limit if (this.activeProcesses.size >= this.maxConcurrentProcesses) { throw new Error(`Maximum concurrent processes limit reached (${this.maxConcurrentProcesses})`); } const processId = ++this.processCounter; const startTime = Date.now(); const timeout = options.timeout || (global as any).TEST_CONFIG?.DEFAULT_TIMEOUT || 10000; const processInfo: ProcessInfo = { command: 'node', args: [this.cliPath, ...args], startTime, status: 'running' }; // Log command being executed console.log(`[E2E] Executing: ocp ${args.join(' ')}`); try { // Prepare environment variables const processEnv = this.prepareEnvironment(options.env); const execaOptions = { env: processEnv, cwd: options.cwd || process.cwd(), timeout, input: options.input, maxBuffer: options.maxBuffer || 1024 * 1024, // 1MB default encoding: 'utf8' as const, killSignal: options.killSignal || 'SIGTERM', reject: false, // Don't throw on non-zero exit codes }; const subprocess = execa('node', [this.cliPath, ...args], execaOptions); processInfo.pid = subprocess.pid; if (subprocess.pid) { this.activeProcesses.set(processId, subprocess); this.processInfoMap.set(processId, processInfo); } this.emit('processStarted', { ...processInfo }); const result = await subprocess; // Clean up this.activeProcesses.delete(processId); const cliResult: CLIExecutionResult = { stdout: result.stdout || '', stderr: result.stderr || '', exitCode: result.exitCode != null ? result.exitCode : 0, executionTime: Math.round(result.durationMs || 0), timedOut: result.timedOut || false, isTerminated: result.isTerminated || false, signal: result.signal }; // Log full output for failed commands if (cliResult.exitCode !== 0) { console.log(`[E2E] Command failed (exit code ${cliResult.exitCode}): ocp ${args.join(' ')}`); console.log(`[E2E] STDOUT:\n${cliResult.stdout}`); console.log(`[E2E] STDERR:\n${cliResult.stderr}`); } // Update process info processInfo.status = 'completed'; processInfo.endTime = Date.now(); processInfo.duration = Math.round(result.durationMs || 0); this.emit('processCompleted', { processInfo: { ...processInfo }, result: cliResult }); return cliResult; } catch (error: any) { // Clean up this.activeProcesses.delete(processId); const executionTime = error.durationMs ? Math.round(error.durationMs) : Date.now() - startTime; processInfo.status = 'error'; let cliResult: CLIExecutionResult; if (error.stdout !== undefined) { // This is an ExecaError with process results cliResult = { stdout: error.stdout || '', stderr: error.stderr || '', exitCode: error.exitCode != null ? error.exitCode : -1, executionTime, timedOut: error.timedOut || false, isTerminated: error.isTerminated || false, signal: error.signal }; } else { // This is a different kind of error cliResult = { stdout: '', stderr: error.message || '', exitCode: -1, executionTime, timedOut: false, isTerminated: false }; } // Update process info processInfo.endTime = Date.now(); processInfo.duration = executionTime; // Log full output for failed commands console.log(`[E2E] Command failed (exit code ${cliResult.exitCode}): ocp ${args.join(' ')}`); console.log(`[E2E] STDOUT:\n${cliResult.stdout}`); console.log(`[E2E] STDERR:\n${cliResult.stderr}`); const cliError = new CLIExecutionError( `Process execution failed: ${error.message}`, cliResult, processInfo ); this.emit('processError', { processInfo: { ...processInfo }, error: cliError }); // For compatibility, return the result instead of throwing return cliResult; } } async executeCommand(command: string, options: CLIExecutionOptions = {}): Promise { const args = this.parseCommand(command); return this.execute(args, options); } private prepareEnvironment(customEnv?: Record): Record { // Start with current process environment, filtering out undefined values const baseEnv = Object.fromEntries( Object.entries(process.env).filter(([_, value]) => value !== undefined) ) as Record; // Apply test-specific environment variables const testEnv = { NODE_ENV: 'test', OCP_ENV: 'test', // Disable colors for consistent output in tests NO_COLOR: '1', FORCE_COLOR: '0', // Disable interactive prompts CI: '1', // Set consistent locale for predictable output LC_ALL: 'C', LANG: 'C' }; // Merge environments with custom taking precedence const mergedEnv = { ...baseEnv, ...testEnv, ...customEnv }; // Filter out any undefined or null values return Object.fromEntries( Object.entries(mergedEnv).filter(([_, value]) => value !== undefined && value !== null ) ); } private parseCommand(command: string): string[] { // Simple command parsing - handles quoted arguments const args: string[] = []; let current = ''; let inQuotes = false; let quoteChar = ''; for (let i = 0; i < command.length; i++) { const char = command[i]; if ((char === '"' || char === "'") && !inQuotes) { inQuotes = true; quoteChar = char; } else if (char === quoteChar && inQuotes) { inQuotes = false; quoteChar = ''; } else if (char === ' ' && !inQuotes) { if (current.length > 0) { args.push(current); current = ''; } } else { current += char; } } if (current.length > 0) { args.push(current); } return args; } async killAllProcesses(signal: NodeJS.Signals = 'SIGTERM'): Promise { if (this.activeProcesses.size === 0) { return; } const killPromises = Array.from(this.activeProcesses.values()).map((subprocess: any) => subprocess.kill(signal).catch((error: any) => { this.emit('warning', `Failed to kill process: ${error}`); }) ); await Promise.all(killPromises); this.activeProcesses.clear(); // Update all remaining process info to killed status for (const processInfo of this.processInfoMap.values()) { if (processInfo.status === 'running') { processInfo.status = 'killed'; processInfo.endTime = Date.now(); processInfo.duration = processInfo.endTime - processInfo.startTime; } } } getActiveProcessCount(): number { return this.activeProcesses.size; } getProcessInfo(): ProcessInfo[] { return Array.from(this.processInfoMap.values()).filter(info => info.status === 'running' ); } getAllProcessInfo(): ProcessInfo[] { return Array.from(this.processInfoMap.values()); } getCompletedProcessInfo(): ProcessInfo[] { return Array.from(this.processInfoMap.values()).filter(info => info.status !== 'running' ); } isProcessRunning(processId: number): boolean { const processInfo = this.processInfoMap.get(processId); return processInfo?.status === 'running' && this.activeProcesses.has(processId); } async waitForProcessCompletion(processId: number, timeoutMs: number = 30000): Promise { return new Promise((resolve) => { const processInfo = this.processInfoMap.get(processId); if (!processInfo || processInfo.status !== 'running') { resolve(processInfo || null); return; } const timeout = setTimeout(() => { resolve(null); }, timeoutMs); const checkCompletion = () => { const currentInfo = this.processInfoMap.get(processId); if (currentInfo && currentInfo.status !== 'running') { clearTimeout(timeout); resolve(currentInfo); } }; // Check periodically const interval = setInterval(checkCompletion, 100); setTimeout(() => { clearInterval(interval); }, timeoutMs); }); } clearProcessHistory(): void { // Remove completed process info to free memory const completedIds = Array.from(this.processInfoMap.entries()) .filter(([_, info]) => info.status !== 'running') .map(([id]) => id); completedIds.forEach(id => this.processInfoMap.delete(id)); } async destroy(): Promise { // Kill all active processes using the simplified method await this.killAllProcesses('SIGTERM'); // Clear all maps this.activeProcesses.clear(); this.processInfoMap.clear(); // Remove all listeners this.removeAllListeners(); } getMaxConcurrentProcesses(): number { return this.maxConcurrentProcesses; } }