inspector/clients/cli/__tests__/helpers/cli-runner.ts
cliffhall a5d8d80ffc feat(cli): exit-code map + structured JSON error envelopes (closes #1564)
Give the CLI a stable machine-readable failure surface:

- EXIT_CODES map (0 ok / 1 usage / 2 no-app / 3 auth-required /
  4 unreachable / 5 tool-error)
- ErrorEnvelope interface + CliExitCodeError for pre-classified exits
- pure classifyError() (status/cause/pattern heuristics) and
  formatErrorOutput()/handleError() that emit one JSON line on stderr
- in-process cli-runner mirrors the binary via formatErrorOutput so tests
  observe the real exit code and envelope
- document the exit-code map + envelope shape in clients/cli/README.md

Wave 1 foundation of the PR #1510 decomposition; shapes kept clean for
reuse by --app-info (exit 2), stored-auth (exit 3), and --format json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
2026-07-01 18:08:56 -04:00

148 lines
5.7 KiB
TypeScript

import { runCli as invokeCli } from "../../src/cli.js";
import { formatErrorOutput } from "../../src/error-handler.js";
export interface CliResult {
exitCode: number | null;
stdout: string;
stderr: string;
output: string; // Combined stdout + stderr
}
export interface CliOptions {
timeout?: number;
/**
* Accepted for source-compatibility with the previous out-of-process runner.
* The in-process runner shares the test worker's working directory, so it is
* ignored — no current test sets it. The out-of-process E2E layer
* (`e2e.test.ts`) still honors a real cwd.
*/
cwd?: string;
env?: Record<string, string>;
signal?: AbortSignal;
}
type WriteArgs = [
chunk: unknown,
encoding?: unknown,
callback?: (() => void) | undefined,
];
/**
* Build a `process.std{out,err}.write` replacement that appends every chunk to
* a buffer and still honors the optional write callback. `awaitableLog` (the
* CLI's stdout writer) passes its resolve handler as that callback, so it MUST
* be invoked or `runCli` would hang forever waiting for the flush.
*/
function captureWrite(append: (text: string) => void) {
return (...args: WriteArgs): boolean => {
const [chunk, encoding, callback] = args;
append(typeof chunk === "string" ? chunk : String(chunk));
const cb = typeof encoding === "function" ? encoding : callback;
if (typeof cb === "function") cb();
return true;
};
}
/**
* Run the CLI **in-process** by importing and invoking `runCli` directly, so
* its source (`clients/cli/src/**`) is measured under vitest's coverage
* instrumentation. The previous implementation spawned `build/index.js` as a
* subprocess, which left CLI source invisible to coverage (#1484).
*
* The returned shape matches the old out-of-process runner exactly
* (`exitCode` / `stdout` / `stderr` / `output`) so every existing test and the
* shared assertion helpers keep working unchanged:
* - stdout/stderr are captured by temporarily patching `process.std*.write`
* (which also captures `console.error`/`console.warn`, since those route
* through `process.stderr.write`).
* - A thrown error (bad args, failed connect, unsupported method) maps to
* `exitCode: 1` with the message appended to stderr — mirroring how the real
* binary (`index.ts`) routes errors through `handleError` → `process.exit(1)`.
* - `options.env` is applied to `process.env` for the duration of the call and
* fully restored afterward.
*
* The real binary, its shebang, and actual `process.exit` codes are covered by
* the thin out-of-process layer in `e2e.test.ts` and `scripts/smoke-cli.mjs`.
*
* DO NOT drive `--help` / `--version` through this runner. `cli.ts` sets
* `program.exitOverride((err) => { if (err.exitCode !== 0) throw err })`, so for
* those (exitCode 0) commander does NOT throw — it falls through to its normal
* `process.exit(0)`, which run in-process would terminate the vitest worker.
* Cover `--help` / `--version` in the out-of-process `e2e.test.ts` layer instead.
*/
export async function runCli(
args: string[],
options: CliOptions = {},
): Promise<CliResult> {
let stdout = "";
let stderr = "";
const originalStdoutWrite = process.stdout.write;
const originalStderrWrite = process.stderr.write;
// Snapshot and apply env overrides; `undefined` records keys that did not
// exist so they can be deleted (not set to the string "undefined") on restore.
const envBackup: Record<string, string | undefined> = {};
if (options.env) {
for (const [key, value] of Object.entries(options.env)) {
envBackup[key] = process.env[key];
process.env[key] = value;
}
}
process.stdout.write = captureWrite((text) => {
stdout += text;
}) as typeof process.stdout.write;
// `console.error` / `console.warn` route through `process.stderr.write`, so
// patching the underlying stream captures them too — no separate console
// override is needed (the previous explicit overrides were redundant).
process.stderr.write = captureWrite((text) => {
stderr += text;
}) as typeof process.stderr.write;
// `runCli` reads `process.argv.slice(2)`, so prepend two placeholder entries
// ([node, script]) to mirror how the binary is launched.
const argv = ["node", "inspector-cli", ...args];
// Safety net for a genuinely hung CLI. Note: when the timeout wins the race
// below, `invokeCli` is NOT cancellable in-process — it keeps running (its
// stdio MCP subprocess stays alive) and any later writes land on the
// *restored* real stdout, since `finally` has already unpatched it. The old
// out-of-process runner could SIGTERM the child group; this one structurally
// cannot. Acceptable because it only fires on the failure path (a hang),
// which no passing test should reach.
const timeoutMs = options.timeout ?? 10000;
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`CLI command timed out after ${timeoutMs}ms`)),
timeoutMs,
);
});
let exitCode = 0;
try {
await Promise.race([invokeCli(argv), timeout]);
} catch (error) {
// Mirror the binary's `handleError` exactly so tests observe the same exit
// code and stderr (one JSON envelope line) the real CLI would emit.
const out = formatErrorOutput(error);
exitCode = out.exitCode;
stderr += out.stderr;
} finally {
if (timer) clearTimeout(timer);
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
for (const [key, value] of Object.entries(envBackup)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
return {
exitCode,
stdout,
stderr,
output: stdout + stderr,
};
}