-
Notifications
You must be signed in to change notification settings - Fork 120
feat: add CLI test harness for non-interactive mode #154
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: add CLI test harness for non-interactive mode #154
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR implements a comprehensive CLI test harness to enable robust testing of non-interactive mode. The implementation provides process lifecycle management, real-time output monitoring, timeout handling, and signal support using Node.js's child_process.spawn.
Key Changes:
- New CLI test harness infrastructure with EventEmitter-based architecture for process control
- Six assertion helpers for validating exit codes, signals, timeouts, and output
- 38 unit tests covering helper functions and assertion validation
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 11 comments.
| File | Description |
|---|---|
source/test-utils/index.ts |
Exports centralized test utility interface, including new CLI harness exports and existing render utilities |
source/test-utils/cli-test-harness.ts |
Core implementation of CLITestHarness class with process control, event monitoring, helper functions, and assertion utilities |
source/cli-harness.spec.ts |
Test suite validating harness infrastructure, assertion helpers, and CLI argument parsing logic |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (this.process.stdout) { | ||
| this.process.stdout.on('data', (chunk: Buffer) => { | ||
| this.stdoutChunks.push(chunk); | ||
| this.emit('stdout', chunk.toString()); | ||
| }); | ||
| } | ||
|
|
||
| if (this.process.stderr) { | ||
| this.process.stderr.on('data', (chunk: Buffer) => { | ||
| this.stderrChunks.push(chunk); | ||
| this.emit('stderr', chunk.toString()); | ||
| }); | ||
| } |
Copilot
AI
Dec 20, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The event listeners attached to stdout and stderr are never cleaned up after the promise resolves or rejects. This creates a memory leak if the harness is reused multiple times. Consider removing these listeners in the cleanup method or when the process exits.
| ...(opts.inheritEnv ? process.env : {}), | ||
| ...opts.env, | ||
| NODE_ENV: 'test', | ||
| FORCE_COLOR: '0', | ||
| NO_COLOR: '1', |
Copilot
AI
Dec 20, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The environment variables are always overridden with hard-coded test values (NODE_ENV, FORCE_COLOR, NO_COLOR) even if the user explicitly passes different values in opts.env. The order should be reversed to allow user-provided environment variables to override the defaults. Consider changing the spread order to: {NODE_ENV: 'test', FORCE_COLOR: '0', NO_COLOR: '1', ...(opts.inheritEnv ? process.env : {}), ...opts.env} to ensure user-specified values take precedence.
| ...(opts.inheritEnv ? process.env : {}), | |
| ...opts.env, | |
| NODE_ENV: 'test', | |
| FORCE_COLOR: '0', | |
| NO_COLOR: '1', | |
| NODE_ENV: 'test', | |
| FORCE_COLOR: '0', | |
| NO_COLOR: '1', | |
| ...(opts.inheritEnv ? process.env : {}), | |
| ...opts.env, |
| async run(options: CLITestOptions = {}): Promise<CLITestResult> { | ||
| const opts = {...DEFAULT_OPTIONS, ...options}; | ||
| const cliPath = getCLIPath(); | ||
|
|
||
| let command: string; | ||
| let args: string[]; | ||
|
|
||
| if (needsTsx(cliPath)) { | ||
| command = 'npx'; | ||
| args = ['tsx', ...opts.nodeArgs, cliPath, ...opts.args]; | ||
| } else { | ||
| command = 'node'; | ||
| args = [...opts.nodeArgs, cliPath, ...opts.args]; | ||
| } | ||
|
|
||
| const env: NodeJS.ProcessEnv = { | ||
| ...(opts.inheritEnv ? process.env : {}), | ||
| ...opts.env, | ||
| NODE_ENV: 'test', | ||
| FORCE_COLOR: '0', | ||
| NO_COLOR: '1', | ||
| }; | ||
|
|
||
| const spawnOptions: SpawnOptions = { | ||
| cwd: opts.cwd, | ||
| env, | ||
| stdio: ['pipe', 'pipe', 'pipe'], | ||
| }; | ||
|
|
||
| return new Promise((resolve, reject) => { | ||
| this.startTime = Date.now(); | ||
| this.stdoutChunks = []; | ||
| this.stderrChunks = []; | ||
|
|
||
| try { | ||
| this.process = spawn(command, args, spawnOptions); | ||
| } catch (error) { | ||
| reject(new Error(`Failed to spawn process: ${error}`)); | ||
| return; | ||
| } | ||
|
|
||
| if (opts.timeout && opts.timeout > 0) { | ||
| this.timeoutId = setTimeout(() => { | ||
| if (this.process && !this.process.killed) { | ||
| this.process.kill('SIGKILL'); | ||
| this.result = this.buildResult(null, null, true); | ||
| } | ||
| }, opts.timeout); | ||
| } | ||
|
|
||
| if (opts.sendSignal) { | ||
| const {signal, delayMs} = opts.sendSignal; | ||
| this.signalTimeoutId = setTimeout(() => { | ||
| if (this.process && !this.process.killed) { | ||
| this.process.kill(signal); | ||
| this.emit('signal-sent', signal); | ||
| } | ||
| }, delayMs); | ||
| } | ||
|
|
||
| if (opts.stdin !== undefined && this.process.stdin) { | ||
| this.process.stdin.write(opts.stdin); | ||
| this.process.stdin.end(); | ||
| } else if (this.process.stdin) { | ||
| this.process.stdin.end(); | ||
| } | ||
|
|
||
| if (this.process.stdout) { | ||
| this.process.stdout.on('data', (chunk: Buffer) => { | ||
| this.stdoutChunks.push(chunk); | ||
| this.emit('stdout', chunk.toString()); | ||
| }); | ||
| } | ||
|
|
||
| if (this.process.stderr) { | ||
| this.process.stderr.on('data', (chunk: Buffer) => { | ||
| this.stderrChunks.push(chunk); | ||
| this.emit('stderr', chunk.toString()); | ||
| }); | ||
| } | ||
|
|
||
| this.process.on('exit', (code, signal) => { | ||
| this.cleanup(); | ||
| this.result = this.buildResult(code, signal, false); | ||
| this.emit('exit', this.result); | ||
| resolve(this.result); | ||
| }); | ||
|
|
||
| this.process.on('error', error => { | ||
| this.cleanup(); | ||
| reject(error); | ||
| }); | ||
| }); | ||
| } |
Copilot
AI
Dec 20, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The CLITestHarness class does not prevent concurrent calls to run(). If run() is called while a process is already running, it will overwrite the process, startTime, and chunk arrays without cleaning up the previous process. This could lead to resource leaks and unpredictable behavior. Consider either throwing an error if run() is called while a process is running, or adding a method to check if the harness can be reused.
source/cli-harness.spec.ts
Outdated
| test('getCLIPath finds a valid CLI path', t => { | ||
| try { | ||
| const cliPath = getCLIPath(); | ||
| t.is(typeof cliPath, 'string'); | ||
| t.true(cliPath.length > 0); | ||
| t.true(cliPath.endsWith('.js') || cliPath.endsWith('.tsx')); | ||
| } catch (error) { | ||
| if (error instanceof Error) { | ||
| t.true(error.message.includes('CLI entry point not found')); | ||
| } else { | ||
| t.fail('Unexpected error type'); | ||
| } | ||
| } | ||
| }); |
Copilot
AI
Dec 20, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test is duplicated - there's already an identical test for getCLIPath at line 17. The test names are different ('getCLIPath returns a valid path' vs 'getCLIPath finds a valid CLI path') but they test the exact same functionality. Consider removing this duplicate test.
| import test from 'ava'; | ||
| import { | ||
| CLITestHarness, | ||
| createCLITestHarness, | ||
| getCLIPath, | ||
| needsTsx, | ||
| type CLITestResult, | ||
| assertExitCode, | ||
| assertTimedOut, | ||
| assertStdoutContains, | ||
| assertStderrContains, | ||
| assertCompletedWithin, | ||
| assertSignal, | ||
| } from './test-utils/cli-test-harness'; | ||
| import * as fs from 'node:fs'; | ||
|
|
||
| test('getCLIPath returns a valid path', t => { | ||
| try { | ||
| const cliPath = getCLIPath(); | ||
| t.true(fs.existsSync(cliPath), `CLI path should exist: ${cliPath}`); | ||
| } catch { | ||
| t.pass('CLI path not available (build may not have run)'); | ||
| } | ||
| }); | ||
|
|
||
| test('needsTsx correctly identifies TypeScript files', t => { | ||
| t.true(needsTsx('/path/to/file.ts')); | ||
| t.true(needsTsx('/path/to/file.tsx')); | ||
| t.false(needsTsx('/path/to/file.js')); | ||
| t.false(needsTsx('/path/to/file.mjs')); | ||
| }); | ||
|
|
||
| test('createCLITestHarness returns a CLITestHarness instance', t => { | ||
| const harness = createCLITestHarness(); | ||
| t.true(harness instanceof CLITestHarness); | ||
| }); | ||
|
|
||
| test('isRunning returns false before run', t => { | ||
| const harness = createCLITestHarness(); | ||
| t.false(harness.isRunning()); | ||
| }); | ||
|
|
||
| test('getCurrentStdout returns empty string before run', t => { | ||
| const harness = createCLITestHarness(); | ||
| t.is(harness.getCurrentStdout(), ''); | ||
| }); | ||
|
|
||
| test('getCurrentStderr returns empty string before run', t => { | ||
| const harness = createCLITestHarness(); | ||
| t.is(harness.getCurrentStderr(), ''); | ||
| }); | ||
|
|
||
| test('assertExitCode passes when exit code matches', t => { | ||
| const result: CLITestResult = { | ||
| exitCode: 0, | ||
| signal: null, | ||
| stdout: '', | ||
| stderr: '', | ||
| timedOut: false, | ||
| duration: 100, | ||
| killed: false, | ||
| }; | ||
| t.notThrows(() => assertExitCode(result, 0)); | ||
| }); | ||
|
|
||
| test('assertExitCode throws when exit code does not match', t => { | ||
| const result: CLITestResult = { | ||
| exitCode: 1, | ||
| signal: null, | ||
| stdout: 'stdout output', | ||
| stderr: 'stderr output', | ||
| timedOut: false, | ||
| duration: 100, | ||
| killed: false, | ||
| }; | ||
| const error = t.throws(() => assertExitCode(result, 0)); | ||
| t.true(error?.message.includes('Expected exit code 0')); | ||
| t.true(error?.message.includes('but got 1')); | ||
| }); | ||
|
|
||
| test('assertSignal passes when signal matches', t => { | ||
| const result: CLITestResult = { | ||
| exitCode: null, | ||
| signal: 'SIGTERM', | ||
| stdout: '', | ||
| stderr: '', | ||
| timedOut: false, | ||
| duration: 100, | ||
| killed: true, | ||
| }; | ||
| t.notThrows(() => assertSignal(result, 'SIGTERM')); | ||
| }); | ||
|
|
||
| test('assertSignal throws when signal does not match', t => { | ||
| const result: CLITestResult = { | ||
| exitCode: null, | ||
| signal: 'SIGKILL', | ||
| stdout: '', | ||
| stderr: '', | ||
| timedOut: false, | ||
| duration: 100, | ||
| killed: true, | ||
| }; | ||
| const error = t.throws(() => assertSignal(result, 'SIGTERM')); | ||
| t.true(error?.message.includes('Expected signal SIGTERM')); | ||
| t.true(error?.message.includes('but got SIGKILL')); | ||
| }); | ||
|
|
||
| test('assertTimedOut passes when process timed out', t => { | ||
| const result: CLITestResult = { | ||
| exitCode: null, | ||
| signal: 'SIGKILL', | ||
| stdout: '', | ||
| stderr: '', | ||
| timedOut: true, | ||
| duration: 5000, | ||
| killed: true, | ||
| }; | ||
| t.notThrows(() => assertTimedOut(result)); | ||
| }); | ||
|
|
||
| test('assertTimedOut throws when process did not time out', t => { | ||
| const result: CLITestResult = { | ||
| exitCode: 0, | ||
| signal: null, | ||
| stdout: '', | ||
| stderr: '', | ||
| timedOut: false, | ||
| duration: 100, | ||
| killed: false, | ||
| }; | ||
| const error = t.throws(() => assertTimedOut(result)); | ||
| t.true(error?.message.includes('Expected process to time out')); | ||
| }); | ||
|
|
||
| test('assertStdoutContains passes with matching string', t => { | ||
| const result: CLITestResult = { | ||
| exitCode: 0, | ||
| signal: null, | ||
| stdout: 'Hello, World!', | ||
| stderr: '', | ||
| timedOut: false, | ||
| duration: 100, | ||
| killed: false, | ||
| }; | ||
| t.notThrows(() => assertStdoutContains(result, 'Hello')); | ||
| }); | ||
|
|
||
| test('assertStdoutContains passes with matching regex', t => { | ||
| const result: CLITestResult = { | ||
| exitCode: 0, | ||
| signal: null, | ||
| stdout: 'Hello, World!', | ||
| stderr: '', | ||
| timedOut: false, | ||
| duration: 100, | ||
| killed: false, | ||
| }; | ||
| t.notThrows(() => assertStdoutContains(result, /World/)); | ||
| }); | ||
|
|
||
| test('assertStdoutContains throws when pattern not found', t => { | ||
| const result: CLITestResult = { | ||
| exitCode: 0, | ||
| signal: null, | ||
| stdout: 'Hello, World!', | ||
| stderr: '', | ||
| timedOut: false, | ||
| duration: 100, | ||
| killed: false, | ||
| }; | ||
| const error = t.throws(() => assertStdoutContains(result, 'Goodbye')); | ||
| t.true(error?.message.includes('Expected stdout to contain')); | ||
| }); | ||
|
|
||
| test('assertStderrContains passes with matching string', t => { | ||
| const result: CLITestResult = { | ||
| exitCode: 1, | ||
| signal: null, | ||
| stdout: '', | ||
| stderr: 'Error: Something went wrong', | ||
| timedOut: false, | ||
| duration: 100, | ||
| killed: false, | ||
| }; | ||
| t.notThrows(() => assertStderrContains(result, 'Error')); | ||
| }); | ||
|
|
||
| test('assertStderrContains throws when pattern not found', t => { | ||
| const result: CLITestResult = { | ||
| exitCode: 1, | ||
| signal: null, | ||
| stdout: '', | ||
| stderr: 'Error: Something went wrong', | ||
| timedOut: false, | ||
| duration: 100, | ||
| killed: false, | ||
| }; | ||
| const error = t.throws(() => assertStderrContains(result, 'Warning')); | ||
| t.true(error?.message.includes('Expected stderr to contain')); | ||
| }); | ||
|
|
||
| test('assertCompletedWithin passes when duration is within limit', t => { | ||
| const result: CLITestResult = { | ||
| exitCode: 0, | ||
| signal: null, | ||
| stdout: '', | ||
| stderr: '', | ||
| timedOut: false, | ||
| duration: 100, | ||
| killed: false, | ||
| }; | ||
| t.notThrows(() => assertCompletedWithin(result, 500)); | ||
| }); | ||
|
|
||
| test('assertCompletedWithin throws when duration exceeds limit', t => { | ||
| const result: CLITestResult = { | ||
| exitCode: 0, | ||
| signal: null, | ||
| stdout: '', | ||
| stderr: '', | ||
| timedOut: false, | ||
| duration: 1000, | ||
| killed: false, | ||
| }; | ||
| const error = t.throws(() => assertCompletedWithin(result, 500)); | ||
| t.true(error?.message.includes('Expected process to complete within 500ms')); | ||
| t.true(error?.message.includes('took 1000ms')); | ||
| }); | ||
|
|
||
| test('run method exists and is callable', t => { | ||
| const harness = createCLITestHarness(); | ||
| t.is(typeof harness.run, 'function'); | ||
| }); | ||
|
|
||
| test('getCLIPath finds a valid CLI path', t => { | ||
| try { | ||
| const cliPath = getCLIPath(); | ||
| t.is(typeof cliPath, 'string'); | ||
| t.true(cliPath.length > 0); | ||
| t.true(cliPath.endsWith('.js') || cliPath.endsWith('.tsx')); | ||
| } catch (error) { | ||
| if (error instanceof Error) { | ||
| t.true(error.message.includes('CLI entry point not found')); | ||
| } else { | ||
| t.fail('Unexpected error type'); | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| test('CLITestResult has correct shape', t => { | ||
| const result: CLITestResult = { | ||
| exitCode: 0, | ||
| signal: null, | ||
| stdout: 'output', | ||
| stderr: 'error', | ||
| timedOut: false, | ||
| duration: 100, | ||
| killed: false, | ||
| }; | ||
| t.is(result.exitCode, 0); | ||
| t.is(result.signal, null); | ||
| t.is(result.stdout, 'output'); | ||
| t.is(result.stderr, 'error'); | ||
| t.is(result.timedOut, false); | ||
| t.is(result.duration, 100); | ||
| t.is(result.killed, false); | ||
| }); | ||
|
|
||
| test('CLI args parsing: run command is detected', t => { | ||
| const args = ['run', 'test prompt']; | ||
| const runCommandIndex = args.findIndex(arg => arg === 'run'); | ||
| t.is(runCommandIndex, 0); | ||
| t.truthy(args[runCommandIndex + 1]); | ||
| }); | ||
|
|
||
| test('CLI args parsing: nonInteractiveMode flag is set correctly', t => { | ||
| const args = ['run', 'test prompt']; | ||
| const runCommandIndex = args.findIndex(arg => arg === 'run'); | ||
| const nonInteractiveMode = runCommandIndex !== -1; | ||
| t.true(nonInteractiveMode); | ||
| }); | ||
|
|
||
| test('CLI args parsing: nonInteractiveMode is false without run command', t => { | ||
| const args = ['--vscode']; | ||
| const runCommandIndex = args.findIndex(arg => arg === 'run'); | ||
| const nonInteractiveMode = runCommandIndex !== -1; | ||
| t.false(nonInteractiveMode); | ||
| }); | ||
|
|
||
| type ExitReason = 'complete' | 'timeout' | 'error' | 'tool-approval' | null; | ||
|
|
||
| function getExitCodeForReason(reason: ExitReason): number { | ||
| return reason === 'error' || reason === 'tool-approval' ? 1 : 0; | ||
| } | ||
|
|
||
| test('Exit code mapping: complete reason uses exit code 0', t => { | ||
| const reason: ExitReason = 'complete'; | ||
| t.is(getExitCodeForReason(reason), 0); | ||
| }); | ||
|
|
||
| test('Exit code mapping: error reason uses exit code 1', t => { | ||
| const reason: ExitReason = 'error'; | ||
| t.is(getExitCodeForReason(reason), 1); | ||
| }); | ||
|
|
||
| test('Exit code mapping: tool-approval reason uses exit code 1', t => { | ||
| const reason: ExitReason = 'tool-approval'; | ||
| t.is(getExitCodeForReason(reason), 1); | ||
| }); | ||
|
|
||
| test('Exit code mapping: timeout reason uses exit code 0', t => { | ||
| const reason: ExitReason = 'timeout'; | ||
| t.is(getExitCodeForReason(reason), 0); | ||
| }); | ||
|
|
||
| test('Signal handling: SIGINT is a valid NodeJS signal', t => { | ||
| const validSignals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGQUIT', 'SIGHUP']; | ||
| t.true(validSignals.includes('SIGINT')); | ||
| }); | ||
|
|
||
| test('Signal handling: SIGTERM is a valid NodeJS signal', t => { | ||
| const validSignals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGQUIT', 'SIGHUP']; | ||
| t.true(validSignals.includes('SIGTERM')); | ||
| }); | ||
|
|
||
| test('Timeout detection: identifies when duration exceeds max', t => { | ||
| const startTime = Date.now() - 400000; | ||
| const maxExecutionTimeMs = 300000; | ||
| const hasTimedOut = Date.now() - startTime > maxExecutionTimeMs; | ||
| t.true(hasTimedOut); | ||
| }); | ||
|
|
||
| test('Timeout detection: does not trigger when within time limit', t => { | ||
| const startTime = Date.now() - 60000; | ||
| const maxExecutionTimeMs = 300000; | ||
| const hasTimedOut = Date.now() - startTime > maxExecutionTimeMs; | ||
| t.false(hasTimedOut); | ||
| }); | ||
|
|
||
| test('CLITestHarness extends EventEmitter', t => { | ||
| const harness = createCLITestHarness(); | ||
| t.is(typeof harness.on, 'function'); | ||
| t.is(typeof harness.emit, 'function'); | ||
| t.is(typeof harness.off, 'function'); | ||
| }); | ||
|
|
||
| test('sendSignal returns false when no process running', t => { | ||
| const harness = createCLITestHarness(); | ||
| t.false(harness.sendSignal('SIGTERM')); | ||
| }); | ||
|
|
||
| test('writeToStdin returns false when no process running', t => { | ||
| const harness = createCLITestHarness(); | ||
| t.false(harness.writeToStdin('test')); | ||
| }); | ||
|
|
||
| test('closeStdin returns false when no process running', t => { | ||
| const harness = createCLITestHarness(); | ||
| t.false(harness.closeStdin()); | ||
| }); | ||
|
|
||
| test('kill returns false when no process running', t => { | ||
| const harness = createCLITestHarness(); | ||
| t.false(harness.kill()); | ||
| }); |
Copilot
AI
Dec 20, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The tests in this file primarily test helper functions and assertions but do not include integration tests that actually run the CLI using the harness. While the unit tests are valuable, there are no tests that verify the core functionality of spawning a process, capturing output, handling timeouts, or signal sending. Consider adding at least a few integration tests that use harness.run() with a simple CLI command to verify the end-to-end behavior works correctly.
| import {type ChildProcess, type SpawnOptions, spawn} from 'node:child_process'; | ||
| import {EventEmitter} from 'node:events'; | ||
| import * as fs from 'node:fs'; | ||
| import * as path from 'node:path'; | ||
| import {fileURLToPath} from 'node:url'; | ||
|
|
||
| const __filename = fileURLToPath(import.meta.url); | ||
| const __dirname = path.dirname(__filename); | ||
|
|
||
| export interface CLITestResult { | ||
| exitCode: number | null; | ||
| signal: NodeJS.Signals | null; | ||
| stdout: string; | ||
| stderr: string; | ||
| timedOut: boolean; | ||
| duration: number; | ||
| killed: boolean; | ||
| } | ||
|
|
||
| export interface CLITestOptions { | ||
| args?: string[]; | ||
| env?: Record<string, string>; | ||
| timeout?: number; | ||
| cwd?: string; | ||
| stdin?: string; | ||
| inheritEnv?: boolean; | ||
| sendSignal?: { | ||
| signal: NodeJS.Signals; | ||
| delayMs: number; | ||
| }; | ||
| nodeArgs?: string[]; | ||
| } | ||
|
|
||
| const DEFAULT_OPTIONS: Required<Omit<CLITestOptions, 'stdin' | 'sendSignal'>> = | ||
| { | ||
| args: [], | ||
| env: {}, | ||
| timeout: 30000, | ||
| cwd: process.cwd(), | ||
| inheritEnv: true, | ||
| nodeArgs: [], | ||
| }; | ||
|
|
||
| export function getCLIPath(): string { | ||
| const distPath = path.resolve(__dirname, '../../dist/cli.js'); | ||
| if (fs.existsSync(distPath)) { | ||
| return distPath; | ||
| } | ||
|
|
||
| const sourcePath = path.resolve(__dirname, '../cli.tsx'); | ||
| if (fs.existsSync(sourcePath)) { | ||
| return sourcePath; | ||
| } | ||
|
|
||
| throw new Error( | ||
| 'CLI entry point not found. Please build the project first with `pnpm build`.', | ||
| ); | ||
| } | ||
|
|
||
| export function needsTsx(cliPath: string): boolean { | ||
| return cliPath.endsWith('.tsx') || cliPath.endsWith('.ts'); | ||
| } | ||
|
|
||
| export class CLITestHarness extends EventEmitter { | ||
| private process: ChildProcess | null = null; | ||
| private startTime: number = 0; | ||
| private result: CLITestResult | null = null; | ||
| private stdoutChunks: Buffer[] = []; | ||
| private stderrChunks: Buffer[] = []; | ||
| private timeoutId: NodeJS.Timeout | null = null; | ||
| private signalTimeoutId: NodeJS.Timeout | null = null; | ||
|
|
||
| async run(options: CLITestOptions = {}): Promise<CLITestResult> { | ||
| const opts = {...DEFAULT_OPTIONS, ...options}; | ||
| const cliPath = getCLIPath(); | ||
|
|
||
| let command: string; | ||
| let args: string[]; | ||
|
|
||
| if (needsTsx(cliPath)) { | ||
| command = 'npx'; | ||
| args = ['tsx', ...opts.nodeArgs, cliPath, ...opts.args]; | ||
| } else { | ||
| command = 'node'; | ||
| args = [...opts.nodeArgs, cliPath, ...opts.args]; | ||
| } | ||
|
|
||
| const env: NodeJS.ProcessEnv = { | ||
| ...(opts.inheritEnv ? process.env : {}), | ||
| ...opts.env, | ||
| NODE_ENV: 'test', | ||
| FORCE_COLOR: '0', | ||
| NO_COLOR: '1', | ||
| }; | ||
|
|
||
| const spawnOptions: SpawnOptions = { | ||
| cwd: opts.cwd, | ||
| env, | ||
| stdio: ['pipe', 'pipe', 'pipe'], | ||
| }; | ||
|
|
||
| return new Promise((resolve, reject) => { | ||
| this.startTime = Date.now(); | ||
| this.stdoutChunks = []; | ||
| this.stderrChunks = []; | ||
|
|
||
| try { | ||
| this.process = spawn(command, args, spawnOptions); | ||
| } catch (error) { | ||
| reject(new Error(`Failed to spawn process: ${error}`)); | ||
| return; | ||
| } | ||
|
|
||
| if (opts.timeout && opts.timeout > 0) { | ||
| this.timeoutId = setTimeout(() => { | ||
| if (this.process && !this.process.killed) { | ||
| this.process.kill('SIGKILL'); | ||
| this.result = this.buildResult(null, null, true); | ||
| } | ||
| }, opts.timeout); | ||
| } | ||
|
|
||
| if (opts.sendSignal) { | ||
| const {signal, delayMs} = opts.sendSignal; | ||
| this.signalTimeoutId = setTimeout(() => { | ||
| if (this.process && !this.process.killed) { | ||
| this.process.kill(signal); | ||
| this.emit('signal-sent', signal); | ||
| } | ||
| }, delayMs); | ||
| } | ||
|
|
||
| if (opts.stdin !== undefined && this.process.stdin) { | ||
| this.process.stdin.write(opts.stdin); | ||
| this.process.stdin.end(); | ||
| } else if (this.process.stdin) { | ||
| this.process.stdin.end(); | ||
| } | ||
|
|
||
| if (this.process.stdout) { | ||
| this.process.stdout.on('data', (chunk: Buffer) => { | ||
| this.stdoutChunks.push(chunk); | ||
| this.emit('stdout', chunk.toString()); | ||
| }); | ||
| } | ||
|
|
||
| if (this.process.stderr) { | ||
| this.process.stderr.on('data', (chunk: Buffer) => { | ||
| this.stderrChunks.push(chunk); | ||
| this.emit('stderr', chunk.toString()); | ||
| }); | ||
| } | ||
|
|
||
| this.process.on('exit', (code, signal) => { | ||
| this.cleanup(); | ||
| this.result = this.buildResult(code, signal, false); | ||
| this.emit('exit', this.result); | ||
| resolve(this.result); | ||
| }); | ||
|
|
||
| this.process.on('error', error => { | ||
| this.cleanup(); | ||
| reject(error); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| sendSignal(signal: NodeJS.Signals): boolean { | ||
| if (this.process && !this.process.killed) { | ||
| return this.process.kill(signal); | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| writeToStdin(data: string): boolean { | ||
| if (this.process?.stdin && !this.process.stdin.destroyed) { | ||
| this.process.stdin.write(data); | ||
| return true; | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| closeStdin(): boolean { | ||
| if (this.process?.stdin && !this.process.stdin.destroyed) { | ||
| this.process.stdin.end(); | ||
| return true; | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| kill(signal: NodeJS.Signals = 'SIGTERM'): boolean { | ||
| if (this.process && !this.process.killed) { | ||
| return this.process.kill(signal); | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| isRunning(): boolean { | ||
| return ( | ||
| this.process !== null && | ||
| !this.process.killed && | ||
| this.process.exitCode === null | ||
| ); | ||
| } | ||
|
|
||
| getCurrentStdout(): string { | ||
| return Buffer.concat(this.stdoutChunks).toString(); | ||
| } | ||
|
|
||
| getCurrentStderr(): string { | ||
| return Buffer.concat(this.stderrChunks).toString(); | ||
| } | ||
|
|
||
| async waitForOutput( | ||
| pattern: RegExp | string, | ||
| options: {timeout?: number; stream?: 'stdout' | 'stderr' | 'both'} = {}, | ||
| ): Promise<string> { | ||
| const {timeout = 10000, stream = 'both'} = options; | ||
| const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern; | ||
|
|
||
| return new Promise((resolve, reject) => { | ||
| const timeoutId = setTimeout(() => { | ||
| reject(new Error(`Timed out waiting for output matching: ${pattern}`)); | ||
| }, timeout); | ||
|
|
||
| const checkOutput = () => { | ||
| const stdoutText = this.getCurrentStdout(); | ||
| const stderrText = this.getCurrentStderr(); | ||
|
|
||
| let textToCheck = ''; | ||
| if (stream === 'stdout') { | ||
| textToCheck = stdoutText; | ||
| } else if (stream === 'stderr') { | ||
| textToCheck = stderrText; | ||
| } else { | ||
| textToCheck = stdoutText + stderrText; | ||
| } | ||
|
|
||
| const match = regex.exec(textToCheck); | ||
| if (match) { | ||
| clearTimeout(timeoutId); | ||
| resolve(match[0]); | ||
| } | ||
| }; | ||
|
|
||
| checkOutput(); | ||
|
|
||
| const onStdout = () => { | ||
| if (stream === 'stdout' || stream === 'both') checkOutput(); | ||
| }; | ||
| const onStderr = () => { | ||
| if (stream === 'stderr' || stream === 'both') checkOutput(); | ||
| }; | ||
|
|
||
| this.on('stdout', onStdout); | ||
| this.on('stderr', onStderr); | ||
|
|
||
| setTimeout(() => { | ||
| this.off('stdout', onStdout); | ||
| this.off('stderr', onStderr); | ||
| }, timeout + 100); | ||
| }); | ||
| } | ||
|
|
||
| private cleanup(): void { | ||
| if (this.timeoutId) { | ||
| clearTimeout(this.timeoutId); | ||
| this.timeoutId = null; | ||
| } | ||
| if (this.signalTimeoutId) { | ||
| clearTimeout(this.signalTimeoutId); | ||
| this.signalTimeoutId = null; | ||
| } | ||
| } | ||
|
|
||
| private buildResult( | ||
| exitCode: number | null, | ||
| signal: NodeJS.Signals | null, | ||
| timedOut: boolean, | ||
| ): CLITestResult { | ||
| return { | ||
| exitCode, | ||
| signal, | ||
| stdout: Buffer.concat(this.stdoutChunks).toString(), | ||
| stderr: Buffer.concat(this.stderrChunks).toString(), | ||
| timedOut, | ||
| duration: Date.now() - this.startTime, | ||
| killed: this.process?.killed ?? false, | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| export function createCLITestHarness(): CLITestHarness { | ||
| return new CLITestHarness(); | ||
| } | ||
|
|
||
| export async function runCLI( | ||
| args: string[], | ||
| options: Omit<CLITestOptions, 'args'> = {}, | ||
| ): Promise<CLITestResult> { | ||
| const harness = createCLITestHarness(); | ||
| return harness.run({...options, args}); | ||
| } | ||
|
|
||
| export async function runNonInteractive( | ||
| prompt: string, | ||
| options: Omit<CLITestOptions, 'args'> = {}, | ||
| ): Promise<CLITestResult> { | ||
| const harness = createCLITestHarness(); | ||
| return harness.run({...options, args: ['run', prompt]}); | ||
| } | ||
|
|
||
| export function assertExitCode( | ||
| result: CLITestResult, | ||
| expectedCode: number, | ||
| ): void { | ||
| if (result.exitCode !== expectedCode) { | ||
| throw new Error( | ||
| `Expected exit code ${expectedCode}, but got ${result.exitCode}.\n` + | ||
| `stdout: ${result.stdout}\n` + | ||
| `stderr: ${result.stderr}`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| export function assertSignal( | ||
| result: CLITestResult, | ||
| expectedSignal: NodeJS.Signals, | ||
| ): void { | ||
| if (result.signal !== expectedSignal) { | ||
| throw new Error( | ||
| `Expected signal ${expectedSignal}, but got ${result.signal}.\n` + | ||
| `stdout: ${result.stdout}\n` + | ||
| `stderr: ${result.stderr}`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| export function assertTimedOut(result: CLITestResult): void { | ||
| if (!result.timedOut) { | ||
| throw new Error( | ||
| `Expected process to time out, but it exited with code ${result.exitCode}.\n` + | ||
| `stdout: ${result.stdout}\n` + | ||
| `stderr: ${result.stderr}`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| export function assertStdoutContains( | ||
| result: CLITestResult, | ||
| pattern: string | RegExp, | ||
| ): void { | ||
| const matches = | ||
| typeof pattern === 'string' | ||
| ? result.stdout.includes(pattern) | ||
| : pattern.test(result.stdout); | ||
|
|
||
| if (!matches) { | ||
| throw new Error( | ||
| `Expected stdout to contain ${pattern}, but it was:\n${result.stdout}`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| export function assertStderrContains( | ||
| result: CLITestResult, | ||
| pattern: string | RegExp, | ||
| ): void { | ||
| const matches = | ||
| typeof pattern === 'string' | ||
| ? result.stderr.includes(pattern) | ||
| : pattern.test(result.stderr); | ||
|
|
||
| if (!matches) { | ||
| throw new Error( | ||
| `Expected stderr to contain ${pattern}, but it was:\n${result.stderr}`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| export function assertCompletedWithin( | ||
| result: CLITestResult, | ||
| maxDurationMs: number, | ||
| ): void { | ||
| if (result.duration > maxDurationMs) { | ||
| throw new Error( | ||
| `Expected process to complete within ${maxDurationMs}ms, ` + | ||
| `but it took ${result.duration}ms.`, | ||
| ); | ||
| } | ||
| } |
Copilot
AI
Dec 20, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The public API lacks JSDoc documentation. The PR description states "Documentation updated (inline JSDoc comments throughout)" but there are no JSDoc comments for the exported functions, classes, interfaces, or methods. Consider adding JSDoc comments to document the purpose, parameters, return values, and usage examples for at least the public API (getCLIPath, needsTsx, CLITestHarness class and its public methods, helper functions like runCLI, runNonInteractive, and all assertion functions).
| throw new Error( | ||
| `Expected stdout to contain ${pattern}, but it was:\n${result.stdout}`, | ||
| ); | ||
| } |
Copilot
AI
Dec 20, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When displaying RegExp patterns in error messages, the pattern is converted to string which may not be helpful for debugging. For regex patterns, the error message will show something like "/pattern/flags" which is better than the object, but for complex regexes this could be improved by showing what was actually searched for. Consider using pattern.toString() for RegExp or adding more context about what was being searched for.
| throw new Error( | ||
| `Expected stderr to contain ${pattern}, but it was:\n${result.stderr}`, | ||
| ); | ||
| } |
Copilot
AI
Dec 20, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When displaying RegExp patterns in error messages, the pattern is converted to string which may not be helpful for debugging. For regex patterns, the error message will show something like "/pattern/flags" which is better than the object, but for complex regexes this could be improved by showing what was actually searched for. Consider using pattern.toString() for RegExp or adding more context about what was being searched for.
| this.timeoutId = setTimeout(() => { | ||
| if (this.process && !this.process.killed) { | ||
| this.process.kill('SIGKILL'); | ||
| this.result = this.buildResult(null, null, true); | ||
| } | ||
| }, opts.timeout); |
Copilot
AI
Dec 20, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the timeout fires and kills the process with SIGKILL, the result is built and stored but never resolved to the caller. The promise will only resolve when the 'exit' event fires (line 158), but the result stored here on line 118 will be overwritten by line 156 with timedOut set to false. This means the timedOut flag will incorrectly be false when a timeout occurs.
The timeout handler should not build and store the result. Instead, it should just kill the process and let the exit handler build the result with the timedOut flag. However, the exit handler needs to know that a timeout occurred. Consider adding a instance variable to track timeout state.
|
@namar0x0309 Could you review this please ? |
|
@akramcodez please address copilot feedback and below:
|
|
Hey @akramcodez - did you see @namar0x0309's feedback? Let me know if you want to keep this PR open |
|
@akramcodez hey! I'll be taking over this as the feature really needs the test harness. Thanks for the effort! Hopefully you can jump on other features/bugs whenever you come back :) |
|
@namar0x0309 sorry for huge delay I am trying to implement changes within today |
Awesome! Looking forward to the next rev. |
|
PTAL @namar0x0309 |
|
Hey @namar0x0309, @Avtrkrb - if you have time could you guys review this PR? Would love to include it in the next release that's pending :) |
Co-authored-by: Copilot <[email protected]>
Description
Implements a comprehensive CLI test harness to enable robust testing of non-interactive mode. This PR adds a complete testing infrastructure using Node.js's
child_process.spawnto test exit codes, timeout detection, signal handling, and piped stdin scenarios.Type of Change
Core Implementation (
source/test-utils/cli-test-harness.ts- 331 lines)run(),sendSignal(),writeToStdin(),closeStdin(),kill()getCLIPath(),runCLI(),runNonInteractive()for quick testingassertExitCode(),assertSignal(),assertTimedOut(),assertStdoutContains(),assertStderrContains(),assertCompletedWithin()Test Suite (
source/cli-harness.spec.ts- 316 lines, 38 tests)Comprehensive test coverage including:
Clean Exports (
source/test-utils/index.ts)cli-test-harness.tsand existingrender-with-theme.tsxKey Features
import.meta.urlfor proper module resolutiontsxvsnodeNODE_ENV=test,FORCE_COLOR=0,NO_COLOR=1waitForOutput()for async output validationAutomated Tests
.spec.ts/tsxfiles (38 tests incli-harness.spec.ts)pnpm test:allcompletes successfully)Manual Testing
Usage Example
Checklist
Closes #130