Skip to content

Conversation

@akramcodez
Copy link
Contributor

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.spawn to test exit codes, timeout detection, signal handling, and piped stdin scenarios.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Core Implementation (source/test-utils/cli-test-harness.ts - 331 lines)

  • CLITestHarness class: Full-featured test harness with process lifecycle management
  • Process control methods: run(), sendSignal(), writeToStdin(), closeStdin(), kill()
  • Real-time monitoring: EventEmitter-based architecture for stdout/stderr/exit events
  • Timeout handling: Configurable timeouts with automatic SIGKILL
  • Helper functions: getCLIPath(), runCLI(), runNonInteractive() for quick testing
  • 6 assertion helpers: assertExitCode(), assertSignal(), assertTimedOut(), assertStdoutContains(), assertStderrContains(), assertCompletedWithin()

Test Suite (source/cli-harness.spec.ts - 316 lines, 38 tests)

Comprehensive test coverage including:

  • Utility function validation
  • Initial state verification
  • All assertion helper tests (success and failure cases)
  • CLI argument parsing
  • Exit code mapping
  • Signal handling validation
  • Timeout detection
  • EventEmitter functionality
  • Process control edge cases

Clean Exports (source/test-utils/index.ts)

  • Centralized export interface for all test utilities
  • Re-exports from both cli-test-harness.ts and existing render-with-theme.tsx

Key Features

  • ESM-compatible: Uses import.meta.url for proper module resolution
  • Automatic TypeScript support: Detects when to use tsx vs node
  • Consistent test environment: Sets NODE_ENV=test, FORCE_COLOR=0, NO_COLOR=1
  • Pattern matching: waitForOutput() for async output validation
  • Signal testing: Built-in support for delayed signal sending

Automated Tests

  • New features include passing tests in .spec.ts/tsx files (38 tests in cli-harness.spec.ts)
  • All existing tests pass (pnpm test:all completes successfully)
  • Tests cover both success and error scenarios (assertion helper pass/fail cases)

Manual Testing

  • Tested with local CLI execution
  • Verified TypeScript source file detection
  • Validated compiled JavaScript execution
  • Confirmed timeout and signal handling behavior

Usage Example

import {runNonInteractive, assertExitCode, createCLITestHarness} from './test-utils';

// Quick non-interactive test
const result = await runNonInteractive('create a hello world file', {
  timeout: 30000,
});
assertExitCode(result, 0);

// Advanced signal handling test
const harness = createCLITestHarness();
const result = await harness.run({
  args: ['run', 'long-task'],
  sendSignal: {signal: 'SIGINT', delayMs: 1000},
});
assertSignal(result, 'SIGINT');

Checklist

  • Code follows project style guidelines
  • Self-review completed
  • Documentation updated (inline JSDoc comments throughout)
  • No breaking changes
  • Appropriate logging added (test output via EventEmitter events)

Closes #130

Copilot AI review requested due to automatic review settings December 20, 2025 06:55
Copy link
Contributor

Copilot AI left a 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.

Comment on lines 140 to 152
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());
});
}
Copy link

Copilot AI Dec 20, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines 89 to 93
...(opts.inheritEnv ? process.env : {}),
...opts.env,
NODE_ENV: 'test',
FORCE_COLOR: '0',
NO_COLOR: '1',
Copy link

Copilot AI Dec 20, 2025

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.

Suggested change
...(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,

Copilot uses AI. Check for mistakes.
Comment on lines 73 to 166
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);
});
});
}
Copy link

Copilot AI Dec 20, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines 236 to 249
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');
}
}
});
Copy link

Copilot AI Dec 20, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines 1 to 366
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());
});
Copy link

Copilot AI Dec 20, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines 1 to 391
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.`,
);
}
}
Copy link

Copilot AI Dec 20, 2025

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).

Copilot uses AI. Check for mistakes.
Comment on lines 359 to 362
throw new Error(
`Expected stdout to contain ${pattern}, but it was:\n${result.stdout}`,
);
}
Copy link

Copilot AI Dec 20, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines 375 to 378
throw new Error(
`Expected stderr to contain ${pattern}, but it was:\n${result.stderr}`,
);
}
Copy link

Copilot AI Dec 20, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines 115 to 120
this.timeoutId = setTimeout(() => {
if (this.process && !this.process.killed) {
this.process.kill('SIGKILL');
this.result = this.buildResult(null, null, true);
}
}, opts.timeout);
Copy link

Copilot AI Dec 20, 2025

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.

Copilot uses AI. Check for mistakes.
@Avtrkrb
Copy link
Member

Avtrkrb commented Dec 20, 2025

@namar0x0309 Could you review this please ?

@Avtrkrb Avtrkrb added enhancement New feature or request Feature labels Dec 21, 2025
@namar0x0309
Copy link
Collaborator

namar0x0309 commented Dec 24, 2025

@akramcodez please address copilot feedback and below:

  • Reentrancy / concurrency risk: CLITestHarness.run() can be called while a process is already running and will overwrite this.process, startTime, and chunk arrays — add an isRunning() guard and throw or refuse concurrent run() calls.
  • Timeout handling bug: timeout handler builds/stores a result then kills the process; the exit handler overwrites it with timedOut=false. Instead, set an internal timedOut flag and let the exit handler build the final result.
  • Event-listener leaks: stdout/stderr listeners are attached each run but not removed in cleanup() — remove listeners in cleanup() (or use once/listener references and call off()).
  • Env override order: the current spread order forces test env values over user-provided opts.env. Reverse to: {NODE_ENV:'test', FORCE_COLOR:'0', NO_COLOR:'1', ...(opts.inheritEnv ? process.env : {}), ...opts.env} so caller can override.
  • No integration tests: current tests exercise helpers/assertions but do not actually spawn the CLI end-to-end. Add at least one integration test that uses run() or runNonInteractive() against a tiny deterministic test script or the built CLI (e.g., a short JS script that prints and exits) to validate timeouts/signals/output.
  • getCurrentStdout/stderr inefficiency: repeated Buffer.concat(this.stdoutChunks).toString() is fine functionally but re-concatenates on each call. Consider caching concatenation on process exit or return early for empty/one-chunk cases (copilot suggestion is fine).
  • cleanup() missing listener removals and clearing process reference: ensure cleanup() clears this.process after killing and removes registered on handlers.
  • Tests: potential duplicates: there appear to be duplicated getCLIPath tests — remove duplicate

@will-lamerton
Copy link
Member

Hey @akramcodez - did you see @namar0x0309's feedback? Let me know if you want to keep this PR open

@namar0x0309
Copy link
Collaborator

@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 :)

@akramcodez
Copy link
Contributor Author

akramcodez commented Jan 11, 2026

@namar0x0309 sorry for huge delay I am trying to implement changes within today

@namar0x0309
Copy link
Collaborator

@namar0x0309 sorry for huge delay I am trying to implement changes within today

Awesome! Looking forward to the next rev.

@akramcodez
Copy link
Contributor Author

PTAL @namar0x0309

will-lamerton
will-lamerton previously approved these changes Jan 11, 2026
@will-lamerton
Copy link
Member

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 :)

@will-lamerton will-lamerton merged commit ed233db into Nano-Collective:main Jan 13, 2026
4 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request Feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] A CLI test harness to better test nonInteractiveMode

4 participants