@nanocollective/nanocoder
Version:
A local-first CLI coding agent that brings the power of agentic coding tools like Claude Code and Gemini CLI to local models or controlled APIs like OpenRouter
413 lines • 14.9 kB
JavaScript
import { 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);
const DEFAULT_OPTIONS = {
args: [],
env: {},
timeout: 30000,
cwd: process.cwd(),
inheritEnv: true,
nodeArgs: [],
};
/**
* Gets the path to the CLI entry point.
* Prefers the compiled dist/cli.js, falls back to source/cli.tsx.
* @returns Absolute path to the CLI entry point
* @throws Error if neither path exists
*/
export function getCLIPath() {
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`.');
}
/**
* Checks if the CLI path requires tsx to execute (TypeScript source files).
* @param cliPath - Path to the CLI file
* @returns true if the file is .ts or .tsx
*/
export function needsTsx(cliPath) {
return cliPath.endsWith('.tsx') || cliPath.endsWith('.ts');
}
/**
* A test harness for spawning and controlling CLI processes.
* Extends EventEmitter and emits 'stdout', 'stderr', 'exit', and 'signal-sent' events.
*
* @example
* ```typescript
* const harness = createCLITestHarness();
* const result = await harness.run({ args: ['run', 'hello world'] });
* assertExitCode(result, 0);
* ```
*/
export class CLITestHarness extends EventEmitter {
process = null;
startTime = 0;
result = null;
stdoutChunks = [];
stderrChunks = [];
timeoutId = null;
signalTimeoutId = null;
_timedOut = false;
stdoutListener = null;
stderrListener = null;
/**
* Spawns the CLI process with the given options and waits for it to exit.
* @param options - Configuration options for the CLI execution
* @returns Promise that resolves with the test result
* @throws Error if called while a process is already running
*/
async run(options = {}) {
if (this.isRunning()) {
throw new Error('CLITestHarness: Cannot call run() while a process is already running. ' +
'Create a new harness instance or wait for the current process to complete.');
}
const opts = { ...DEFAULT_OPTIONS, ...options };
const cliPath = getCLIPath();
let command;
let args;
if (needsTsx(cliPath)) {
command = 'npx';
args = ['tsx', ...opts.nodeArgs, cliPath, ...opts.args];
}
else {
command = 'node';
args = [...opts.nodeArgs, cliPath, ...opts.args];
}
const env = {
NODE_ENV: 'test',
FORCE_COLOR: '0',
NO_COLOR: '1',
...(opts.inheritEnv ? process.env : {}),
...opts.env,
};
const spawnOptions = {
cwd: opts.cwd,
env,
stdio: ['pipe', 'pipe', 'pipe'],
};
return new Promise((resolve, reject) => {
this.startTime = Date.now();
this.stdoutChunks = [];
this.stderrChunks = [];
this._timedOut = false;
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._timedOut = true;
this.process.kill('SIGKILL');
}
}, 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.stdoutListener = (chunk) => {
this.stdoutChunks.push(chunk);
this.emit('stdout', chunk.toString());
};
this.process.stdout.on('data', this.stdoutListener);
}
if (this.process.stderr) {
this.stderrListener = (chunk) => {
this.stderrChunks.push(chunk);
this.emit('stderr', chunk.toString());
};
this.process.stderr.on('data', this.stderrListener);
}
this.process.on('exit', (code, signal) => {
this.cleanup();
this.result = this.buildResult(code, signal, this._timedOut);
this.emit('exit', this.result);
resolve(this.result);
});
this.process.on('error', error => {
this.cleanup();
reject(error);
});
});
}
/**
* Sends a signal to the running process.
* @param signal - The signal to send (e.g., 'SIGINT', 'SIGTERM')
* @returns true if the signal was sent, false if no process is running
*/
sendSignal(signal) {
if (this.process && !this.process.killed) {
return this.process.kill(signal);
}
return false;
}
/**
* Writes data to the process's stdin.
* @param data - The string data to write
* @returns true if data was written, false if no process or stdin is unavailable
*/
writeToStdin(data) {
if (this.process?.stdin && !this.process.stdin.destroyed) {
this.process.stdin.write(data);
return true;
}
return false;
}
/**
* Closes the process's stdin stream.
* @returns true if stdin was closed, false if no process or stdin is unavailable
*/
closeStdin() {
if (this.process?.stdin && !this.process.stdin.destroyed) {
this.process.stdin.end();
return true;
}
return false;
}
/**
* Kills the running process with the specified signal.
* @param signal - The signal to use (default: 'SIGTERM')
* @returns true if the process was killed, false if no process is running
*/
kill(signal = 'SIGTERM') {
if (this.process && !this.process.killed) {
return this.process.kill(signal);
}
return false;
}
/**
* Checks if a process is currently running.
* @returns true if a process is running and has not exited
*/
isRunning() {
return (this.process !== null &&
!this.process.killed &&
this.process.exitCode === null);
}
/**
* Gets the current accumulated stdout output.
* @returns The stdout output collected so far
*/
getCurrentStdout() {
const length = this.stdoutChunks.length;
if (length === 0) {
return '';
}
if (length === 1) {
return this.stdoutChunks[0].toString();
}
return Buffer.concat(this.stdoutChunks).toString();
}
getCurrentStderr() {
const length = this.stderrChunks.length;
if (length === 0) {
return '';
}
if (length === 1) {
return this.stderrChunks[0].toString();
}
if (this.stderrChunks.length === 0)
return '';
if (this.stderrChunks.length === 1)
return this.stderrChunks[0].toString();
return Buffer.concat(this.stderrChunks).toString();
}
/**
* Waits for output matching a pattern to appear in the process output.
* @param pattern - String or RegExp to match against output
* @param options - Options for timeout and which stream(s) to check
* @returns Promise that resolves with the matched string
* @throws Error if timeout is reached before pattern is found
*/
async waitForOutput(pattern, options = {}) {
const { timeout = 10000, stream = 'both' } = options;
// Pattern is provided by test code, not user input - ReDoS is not a concern here
const regex = typeof pattern === 'string'
? new RegExp(pattern) /* nosemgrep */
: 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);
});
}
cleanup() {
if (this.process?.stdout && this.stdoutListener) {
this.process.stdout.off('data', this.stdoutListener);
this.stdoutListener = null;
}
if (this.process?.stderr && this.stderrListener) {
this.process.stderr.off('data', this.stderrListener);
this.stderrListener = null;
}
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
if (this.signalTimeoutId) {
clearTimeout(this.signalTimeoutId);
this.signalTimeoutId = null;
}
this.process = null;
}
buildResult(exitCode, signal, timedOut) {
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,
};
}
}
/**
* Creates a new CLITestHarness instance.
* @returns A new CLITestHarness ready to run tests
*/
export function createCLITestHarness() {
return new CLITestHarness();
}
/**
* Asserts that the process exited with the expected exit code.
* @param result - The CLI test result to check
* @param expectedCode - The expected exit code
* @throws Error if the exit code doesn't match
*/
export function assertExitCode(result, expectedCode) {
if (result.exitCode !== expectedCode) {
throw new Error(`Expected exit code ${expectedCode}, but got ${result.exitCode}.\n` +
`stdout: ${result.stdout}\n` +
`stderr: ${result.stderr}`);
}
}
/**
* Asserts that the process was terminated by the expected signal.
* @param result - The CLI test result to check
* @param expectedSignal - The expected termination signal
* @throws Error if the signal doesn't match
*/
export function assertSignal(result, expectedSignal) {
if (result.signal !== expectedSignal) {
throw new Error(`Expected signal ${expectedSignal}, but got ${result.signal}.\n` +
`stdout: ${result.stdout}\n` +
`stderr: ${result.stderr}`);
}
}
/**
* Asserts that the process timed out.
* @param result - The CLI test result to check
* @throws Error if the process did not time out
*/
export function assertTimedOut(result) {
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}`);
}
}
/**
* Asserts that stdout contains the expected pattern.
* @param result - The CLI test result to check
* @param pattern - String or RegExp to match against stdout
* @throws Error if the pattern is not found in stdout
*/
export function assertStdoutContains(result, pattern) {
const matches = typeof pattern === 'string'
? result.stdout.includes(pattern)
: pattern.test(result.stdout);
if (!matches) {
const patternStr = typeof pattern === 'string' ? `"${pattern}"` : pattern.toString();
throw new Error(`Expected stdout to contain ${patternStr}, but it was:\n${result.stdout}`);
}
}
/**
* Asserts that stderr contains the expected pattern.
* @param result - The CLI test result to check
* @param pattern - String or RegExp to match against stderr
* @throws Error if the pattern is not found in stderr
*/
export function assertStderrContains(result, pattern) {
const matches = typeof pattern === 'string'
? result.stderr.includes(pattern)
: pattern.test(result.stderr);
if (!matches) {
const patternStr = typeof pattern === 'string' ? `"${pattern}"` : pattern.toString();
throw new Error(`Expected stderr to contain ${patternStr}, but it was:\n${result.stderr}`);
}
}
/**
* Asserts that the process completed within the specified time.
* @param result - The CLI test result to check
* @param maxDurationMs - Maximum allowed duration in milliseconds
* @throws Error if the process took longer than the specified time
*/
export function assertCompletedWithin(result, maxDurationMs) {
if (result.duration > maxDurationMs) {
throw new Error(`Expected process to complete within ${maxDurationMs}ms, ` +
`but it took ${result.duration}ms.`);
}
}
//# sourceMappingURL=cli-test-harness.js.map