feat(S-301): Test-drive and implement src/execute.ts
This commit is contained in:
@@ -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<string, MockScenario>
|
||||
): (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<string>): (p: string) => boolean {
|
||||
return (p: string) => allowedPaths.has(p);
|
||||
}
|
||||
|
||||
describe("executePrompt", () => {
|
||||
it("captures stdout for a successful codex execution", async () => {
|
||||
const scenarios = new Map<string, MockScenario>([
|
||||
['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<string, MockScenario>([
|
||||
["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<string, MockScenario>([
|
||||
["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<string, MockScenario>();
|
||||
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<string, MockScenario>([
|
||||
["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<string, MockScenario>([
|
||||
[
|
||||
'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")
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user