From a99041f9101e5106638718ba6872050a419efd38 Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Mon, 18 May 2026 18:01:51 -0500 Subject: [PATCH] feat(S-301): Test-drive and implement src/execute.ts --- tools/ai-cli-dispatch/src/execute.ts | 101 ++++++++++ tools/ai-cli-dispatch/tests/execute.test.ts | 194 ++++++++++++++++++++ 2 files changed, 295 insertions(+) create mode 100644 tools/ai-cli-dispatch/src/execute.ts create mode 100644 tools/ai-cli-dispatch/tests/execute.test.ts diff --git a/tools/ai-cli-dispatch/src/execute.ts b/tools/ai-cli-dispatch/src/execute.ts new file mode 100644 index 0000000..b0f9359 --- /dev/null +++ b/tools/ai-cli-dispatch/src/execute.ts @@ -0,0 +1,101 @@ +import { spawn as defaultSpawn } from "node:child_process"; +import { existsSync as defaultExistsSync } from "node:fs"; +import type { ClientName, ExecResult } from "./types.js"; +import { ClientNotFoundError, ExecError } from "./types.js"; + +const CLIENT_ARGS: Record string[]> = { + codex: (p) => ["exec", p], + claude: (p) => ["-p", p], + opencode: (p) => [p], +}; + +export interface ExecuteOptions { + clientPath?: string; + timeoutMs?: number; + spawn?: typeof defaultSpawn; + existsSync?: typeof defaultExistsSync; +} + +export async function executePrompt( + client: ClientName, + prompt: string, + options: ExecuteOptions = {} +): Promise { + if (prompt.trim() === "") { + throw new ExecError("Prompt cannot be empty", { + stdout: "", + stderr: "", + exitCode: -1, + }); + } + + const spawnImpl = options.spawn ?? defaultSpawn; + const existsSyncImpl = options.existsSync ?? defaultExistsSync; + const timeoutMs = options.timeoutMs ?? 300_000; + + const command = options.clientPath ?? client; + if (options.clientPath && !existsSyncImpl(options.clientPath)) { + throw new ClientNotFoundError(client); + } + + const args = CLIENT_ARGS[client](prompt); + + return new Promise((resolve, reject) => { + let settled = false; + let timedOut = false; + let stdout = ""; + let stderr = ""; + + const child = spawnImpl(command, args, { + shell: false, + }); + + child.stdout?.on("data", (chunk: Buffer | string) => { + stdout += chunk.toString(); + }); + + child.stderr?.on("data", (chunk: Buffer | string) => { + stderr += chunk.toString(); + }); + + const timeout = setTimeout(() => { + timedOut = true; + child.kill(); + }, timeoutMs); + + function settle( + err?: Error | undefined, + result?: ExecResult | undefined + ): void { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (err) reject(err); + else resolve(result!); + } + + child.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "ENOENT") { + settle(new ClientNotFoundError(client)); + } else { + settle( + new ExecError(err.message, { stdout, stderr, exitCode: -1 }) + ); + } + }); + + child.on("close", (code: number | null) => { + if (timedOut) { + settle( + new ExecError(`Execution timed out after ${timeoutMs}ms`, { + stdout, + stderr, + exitCode: -1, + }) + ); + } else { + settle(undefined, { stdout, stderr, exitCode: code ?? -1 }); + } + }); + }); +} diff --git a/tools/ai-cli-dispatch/tests/execute.test.ts b/tools/ai-cli-dispatch/tests/execute.test.ts new file mode 100644 index 0000000..6f3a6d1 --- /dev/null +++ b/tools/ai-cli-dispatch/tests/execute.test.ts @@ -0,0 +1,194 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { EventEmitter } from "node:events"; +import { Readable } from "node:stream"; +import { executePrompt } from "../src/execute.js"; +import { ClientNotFoundError, ExecError } from "../src/types.js"; + +interface MockScenario { + stdout?: string; + stderr?: string; + exitCode?: number; + error?: NodeJS.ErrnoException; + hang?: boolean; +} + +function createMockChildProcess(scenario: MockScenario): any { + const child = new EventEmitter(); + child.stdout = Readable.from( + scenario.stdout !== undefined ? [scenario.stdout] : [] + ); + child.stderr = Readable.from( + scenario.stderr !== undefined ? [scenario.stderr] : [] + ); + child.killed = false; + child.kill = () => { + child.killed = true; + process.nextTick(() => { + child.emit("exit", null, "SIGTERM"); + child.emit("close", null, "SIGTERM"); + }); + return true; + }; + + let stdoutEnded = scenario.stdout === undefined; + let stderrEnded = scenario.stderr === undefined; + + child.stdout.on("end", () => { + stdoutEnded = true; + maybeClose(); + }); + child.stderr.on("end", () => { + stderrEnded = true; + maybeClose(); + }); + + function maybeClose() { + if (stdoutEnded && stderrEnded && !scenario.hang) { + child.emit("exit", scenario.exitCode ?? 0, null); + child.emit("close", scenario.exitCode ?? 0, null); + } + } + + process.nextTick(() => { + if (scenario.error) { + child.emit("error", scenario.error); + } + }); + + return child; +} + +function mockSpawn( + scenarios: Map +): (cmd: string, args: string[], opts: any) => any { + return (cmd: string, args: string[], _opts: any) => { + const key = [cmd, ...args].join(" "); + const scenario = scenarios.get(key); + if (!scenario) { + return createMockChildProcess({ + error: Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }), + }); + } + return createMockChildProcess(scenario); + }; +} + +function mockExistsSync(allowedPaths: Set): (p: string) => boolean { + return (p: string) => allowedPaths.has(p); +} + +describe("executePrompt", () => { + it("captures stdout for a successful codex execution", async () => { + const scenarios = new Map([ + ['codex exec "hello world"', { stdout: "result\n", exitCode: 0 }], + ]); + const result = await executePrompt("codex", '"hello world"', { + spawn: mockSpawn(scenarios), + existsSync: () => true, + }); + assert.strictEqual(result.stdout, "result\n"); + assert.strictEqual(result.stderr, ""); + assert.strictEqual(result.exitCode, 0); + }); + + it("captures stderr for a successful claude execution", async () => { + const scenarios = new Map([ + ["claude -p hello", { stdout: "", stderr: "warning\n", exitCode: 0 }], + ]); + const result = await executePrompt("claude", "hello", { + spawn: mockSpawn(scenarios), + existsSync: () => true, + }); + assert.strictEqual(result.stdout, ""); + assert.strictEqual(result.stderr, "warning\n"); + assert.strictEqual(result.exitCode, 0); + }); + + it("returns non-zero exit code without throwing", async () => { + const scenarios = new Map([ + ["opencode fail", { stdout: "", stderr: "error\n", exitCode: 1 }], + ]); + const result = await executePrompt("opencode", "fail", { + spawn: mockSpawn(scenarios), + existsSync: () => true, + }); + assert.strictEqual(result.exitCode, 1); + assert.strictEqual(result.stderr, "error\n"); + }); + + it("throws ClientNotFoundError when binary emits ENOENT", async () => { + const scenarios = new Map(); + await assert.rejects( + executePrompt("codex", "hello", { + spawn: mockSpawn(scenarios), + existsSync: () => true, + }), + (err: unknown) => err instanceof ClientNotFoundError + ); + }); + + it("throws ClientNotFoundError when explicit clientPath does not exist", async () => { + await assert.rejects( + executePrompt("claude", "hello", { + clientPath: "/nonexistent/claude", + existsSync: mockExistsSync(new Set()), + }), + (err: unknown) => err instanceof ClientNotFoundError + ); + }); + + it("rejects with ExecError when timeout is exceeded", async () => { + const scenarios = new Map([ + ["codex exec slow", { hang: true }], + ]); + await assert.rejects( + executePrompt("codex", "slow", { + spawn: mockSpawn(scenarios), + existsSync: () => true, + timeoutMs: 10, + }), + (err: unknown) => + err instanceof ExecError && + err.message.includes("timed out") && + err.result.exitCode === -1 + ); + }); + + it("passes prompts with special characters unchanged", async () => { + const scenarios = new Map([ + [ + 'codex exec "quotes\nnewlines"', + { stdout: "ok", exitCode: 0 }, + ], + ]); + const result = await executePrompt("codex", '"quotes\nnewlines"', { + spawn: mockSpawn(scenarios), + existsSync: () => true, + }); + assert.strictEqual(result.stdout, "ok"); + assert.strictEqual(result.exitCode, 0); + }); + + it("rejects empty prompt", async () => { + await assert.rejects( + executePrompt("codex", "", { + spawn: mockSpawn(new Map()), + existsSync: () => true, + }), + (err: unknown) => + err instanceof ExecError && err.message.includes("empty") + ); + }); + + it("rejects whitespace-only prompt", async () => { + await assert.rejects( + executePrompt("claude", " ", { + spawn: mockSpawn(new Map()), + existsSync: () => true, + }), + (err: unknown) => + err instanceof ExecError && err.message.includes("empty") + ); + }); +});