Files
stef-openclaw-skills/tools/ai-cli-dispatch/tests/cli.test.ts
T

1608 lines
50 KiB
TypeScript

import { describe, it } from "node:test";
import assert from "node:assert";
import { main } from "../src/cli.js";
import { ClientNotFoundError } from "../src/types.js";
import type { ClientInfo, ExecResult, ClientName } from "../src/types.js";
function captureOutput() {
const logs: string[] = [];
const errors: string[] = [];
const origLog = console.log;
const origError = console.error;
console.log = (...args: unknown[]) => {
logs.push(args.map(String).join(" "));
};
console.error = (...args: unknown[]) => {
errors.push(args.map(String).join(" "));
};
return {
logs,
errors,
restore() {
console.log = origLog;
console.error = origError;
},
};
}
describe("main", () => {
const mockClients: ClientInfo[] = [
{ name: "codex", found: true, path: "/usr/bin/codex", version: "1.0.0" },
{ name: "claude", found: true, path: "/usr/bin/claude", version: "2.0.0" },
{ name: "opencode", found: false },
];
const mockResult: ExecResult = {
stdout: "output",
stderr: "",
exitCode: 0,
client: "codex",
durationMs: 42,
};
it("returns 0 for --help and prints usage", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "--help"]);
assert.strictEqual(code, 0);
assert.ok(out.logs.some((l) => l.includes("Usage:")));
} finally {
out.restore();
}
});
it("prints usage and returns 1 for bare invocation", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts"]);
assert.strictEqual(code, 1);
assert.ok(out.logs.some((l) => l.includes("Usage:")));
} finally {
out.restore();
}
});
it("returns 1 for unknown command", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "bogus"]);
assert.strictEqual(code, 1);
const err = out.errors[0] ?? out.logs[0];
assert.ok(String(err).includes("Unknown command"));
} finally {
out.restore();
}
});
it("list prints JSON array of clients by default", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "list"], {
detectClients: () => mockClients,
});
assert.strictEqual(code, 0);
assert.strictEqual(out.logs.length, 1);
const parsed = JSON.parse(out.logs[0]);
assert.deepStrictEqual(parsed, mockClients);
} finally {
out.restore();
}
});
it("list prints human-readable output with --text", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "list", "--text"], {
detectClients: () => mockClients,
});
assert.strictEqual(code, 0);
assert.ok(out.logs.some((l) => l.includes("codex:")));
assert.ok(out.logs.some((l) => l.includes("claude:")));
assert.ok(out.logs.some((l) => l.includes("opencode:")));
} finally {
out.restore();
}
});
it("run executes client with prompt and prints JSON result with --sync", async () => {
const out = captureOutput();
try {
const executed: { client: ClientName; prompt: string }[] = [];
const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--sync"],
{
detectClients: () => mockClients,
executePrompt: async (client, prompt) => {
executed.push({ client, prompt });
return mockResult;
},
}
);
assert.strictEqual(code, 0);
assert.strictEqual(executed.length, 1);
assert.strictEqual(executed[0].client, "codex");
assert.strictEqual(executed[0].prompt, "hello");
const parsed = JSON.parse(out.logs[0]);
assert.deepStrictEqual(parsed, mockResult);
} finally {
out.restore();
}
});
it("run prints text output with --text and --sync", async () => {
const out = captureOutput();
const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];
try {
const code = await main(
[
"node",
"cli.ts",
"run",
"--client",
"codex",
"--prompt",
"hello",
"--text",
"--sync",
],
{
detectClients: () => mockClients,
executePrompt: async () => ({
stdout: "hello-out",
stderr: "hello-err",
exitCode: 0,
client: "codex",
durationMs: 42,
}),
stdoutWrite: (chunk) => stdoutChunks.push(chunk),
stderrWrite: (chunk) => stderrChunks.push(chunk),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(stdoutChunks.join(""), "hello-out");
assert.strictEqual(stderrChunks.join(""), "hello-err");
} finally {
out.restore();
}
});
it("run returns 1 and prints error JSON when client is missing", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "run", "--prompt", "hello"], {
detectClients: () => mockClients,
});
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("client"));
} finally {
out.restore();
}
});
it("run returns 1 and prints error text when client is missing in text mode", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "run", "--prompt", "hello", "--text"],
{
detectClients: () => mockClients,
}
);
assert.strictEqual(code, 1);
assert.ok(out.errors[0].includes("client"));
} finally {
out.restore();
}
});
it("run returns 1 and prints error JSON when prompt is missing", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "run", "--client", "codex"], {
detectClients: () => mockClients,
});
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("prompt"));
} finally {
out.restore();
}
});
it("run returns 1 for unknown client", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "run", "--client", "bogus", "--prompt", "hello"],
{
detectClients: () => mockClients,
}
);
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("client"));
} finally {
out.restore();
}
});
it("run returns 1 and prints error JSON when client not found with --sync", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--sync"],
{
detectClients: () => mockClients,
executePrompt: async () => {
throw new ClientNotFoundError("codex");
},
}
);
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("not found"));
} finally {
out.restore();
}
});
it("dispatch routes positional prompt and prints JSON result with --sync", async () => {
const out = captureOutput();
try {
const executed: { client: ClientName; prompt: string }[] = [];
const code = await main(
["node", "cli.ts", "dispatch", "use claude to write tests", "--sync"],
{
detectClients: () => mockClients,
executePrompt: async (client, prompt) => {
executed.push({ client, prompt });
return mockResult;
},
resolveClient: () => "claude",
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(executed.length, 1);
assert.strictEqual(executed[0].client, "claude");
assert.strictEqual(executed[0].prompt, "use claude to write tests");
const parsed = JSON.parse(out.logs[0]);
assert.deepStrictEqual(parsed, mockResult);
} finally {
out.restore();
}
});
it("dispatch routes --prompt flag and prints JSON result with --sync", async () => {
const out = captureOutput();
try {
const executed: { client: ClientName; prompt: string }[] = [];
const code = await main(
["node", "cli.ts", "dispatch", "--prompt", "use codex to refactor", "--sync"],
{
detectClients: () => mockClients,
executePrompt: async (client, prompt) => {
executed.push({ client, prompt });
return mockResult;
},
resolveClient: () => "codex",
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(executed.length, 1);
assert.strictEqual(executed[0].prompt, "use codex to refactor");
} finally {
out.restore();
}
});
it("dispatch respects --client override with --sync", async () => {
const out = captureOutput();
try {
const executed: { client: ClientName; prompt: string }[] = [];
const code = await main(
["node", "cli.ts", "dispatch", "do something", "--client", "opencode", "--sync"],
{
detectClients: () => mockClients,
executePrompt: async (client, prompt) => {
executed.push({ client, prompt });
return mockResult;
},
resolveClient: (_p, cfg) => cfg?.client ?? null,
resolveConfig: () => ({ paths: {}, defaultClient: "claude", timeout: 600_000 }),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(executed[0].client, "opencode");
} finally {
out.restore();
}
});
it("dispatch returns 1 when prompt cannot be resolved", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "dispatch", "do something"],
{
detectClients: () => mockClients,
executePrompt: async () => mockResult,
resolveClient: () => null,
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
}
);
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("resolve"));
} finally {
out.restore();
}
});
it("dispatch returns 1 when resolved client is not found with --sync", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "dispatch", "do something", "--sync"],
{
detectClients: () => mockClients,
executePrompt: async () => {
throw new ClientNotFoundError("claude");
},
resolveClient: () => "claude",
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
}
);
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("not found"));
} finally {
out.restore();
}
});
it("dispatch returns 1 when no prompt is given", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "dispatch"], {
detectClients: () => mockClients,
});
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("prompt"));
} finally {
out.restore();
}
});
it("dispatch prints text output with --text and --sync", async () => {
const out = captureOutput();
const stdoutChunks: string[] = [];
try {
const code = await main(
["node", "cli.ts", "dispatch", "do something", "--text", "--sync"],
{
detectClients: () => mockClients,
executePrompt: async () => ({
stdout: "done",
stderr: "",
exitCode: 0,
client: "codex",
durationMs: 42,
}),
resolveClient: () => "codex",
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
stdoutWrite: (chunk) => stdoutChunks.push(chunk),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(stdoutChunks.join(""), "done");
} finally {
out.restore();
}
});
it("run prints debug diagnostic JSON to stderr with --debug and --sync", async () => {
const out = captureOutput();
const stderrChunks: string[] = [];
try {
const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--debug", "--sync"],
{
detectClients: () => mockClients,
executePrompt: async (_client, _prompt, options?) => {
options?.onDebug?.({
command: "codex",
args: ["exec", "--yolo", "hello"],
pid: 12345,
exitCode: 0,
exitSignal: null,
durationMs: 42,
stderrLength: 0,
stdoutLength: 6,
} as any);
return {
stdout: "output",
stderr: "",
exitCode: 0,
client: "codex",
durationMs: 42,
};
},
stderrWrite: (chunk) => stderrChunks.push(chunk),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(stderrChunks.length, 1);
const diag = JSON.parse(stderrChunks[0]);
assert.strictEqual(diag.command, "codex");
assert.deepStrictEqual(diag.args, ["exec", "--yolo", "hello"]);
assert.strictEqual(diag.pid, 12345);
assert.strictEqual(diag.exitCode, 0);
assert.strictEqual(diag.exitSignal, null);
assert.strictEqual(diag.durationMs, 42);
assert.strictEqual(diag.stderrLength, 0);
} finally {
out.restore();
}
});
it("run ignores non-numeric --timeout and falls back to config timeout with --sync", async () => {
const out = captureOutput();
try {
let receivedTimeout: number | undefined;
const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--timeout", "not-a-number", "--sync"],
{
detectClients: () => mockClients,
executePrompt: async (_client, _prompt, options?) => {
receivedTimeout = options?.timeoutMs;
return mockResult;
},
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(receivedTimeout, 600_000);
} finally {
out.restore();
}
});
it("dispatch ignores non-numeric --timeout and falls back to config timeout with --sync", async () => {
const out = captureOutput();
try {
let receivedTimeout: number | undefined;
const code = await main(
["node", "cli.ts", "dispatch", "do something", "--timeout", "bad", "--sync"],
{
detectClients: () => mockClients,
executePrompt: async (_client, _prompt, options?) => {
receivedTimeout = options?.timeoutMs;
return mockResult;
},
resolveClient: () => "codex",
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(receivedTimeout, 600_000);
} finally {
out.restore();
}
});
it("dispatch prints debug diagnostic JSON to stderr with --debug and --sync", async () => {
const out = captureOutput();
const stderrChunks: string[] = [];
try {
const code = await main(
["node", "cli.ts", "dispatch", "do something", "--debug", "--sync"],
{
detectClients: () => mockClients,
executePrompt: async (_client, _prompt, options?) => {
options?.onDebug?.({
command: "codex",
args: ["exec", "--yolo", "do something"],
pid: 12345,
exitCode: 0,
exitSignal: null,
durationMs: 42,
stderrLength: 0,
stdoutLength: 6,
} as any);
return {
stdout: "output",
stderr: "",
exitCode: 0,
client: "codex",
durationMs: 42,
};
},
resolveClient: () => "codex",
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
stderrWrite: (chunk) => stderrChunks.push(chunk),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(stderrChunks.length, 1);
const diag = JSON.parse(stderrChunks[0]);
assert.strictEqual(diag.command, "codex");
assert.strictEqual(diag.durationMs, 42);
} finally {
out.restore();
}
});
// Async default tests (S-301)
it("run async default starts job and returns job ID JSON immediately", async () => {
const out = captureOutput();
try {
const started: { client: ClientName; prompt: string; options?: any }[] = [];
const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello"],
{
detectClients: () => mockClients,
startJob: async (client, prompt, options) => {
started.push({ client, prompt, options });
return { id: "job-123", client, prompt, status: "running", startedAt: new Date().toISOString() };
},
}
);
assert.strictEqual(code, 0);
assert.strictEqual(started.length, 1);
assert.strictEqual(started[0].client, "codex");
assert.strictEqual(started[0].prompt, "hello");
const parsed = JSON.parse(out.logs[0]);
assert.strictEqual(parsed.jobId, "job-123");
assert.strictEqual(parsed.client, "codex");
assert.strictEqual(parsed.status, "running");
} finally {
out.restore();
}
});
it("dispatch async default starts job and returns job ID JSON immediately", async () => {
const out = captureOutput();
try {
const started: { client: ClientName; prompt: string; options?: any }[] = [];
const code = await main(
["node", "cli.ts", "dispatch", "use claude to write tests"],
{
detectClients: () => mockClients,
startJob: async (client, prompt, options) => {
started.push({ client, prompt, options });
return { id: "job-456", client, prompt, status: "running", startedAt: new Date().toISOString() };
},
resolveClient: () => "claude",
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(started.length, 1);
assert.strictEqual(started[0].client, "claude");
assert.strictEqual(started[0].prompt, "use claude to write tests");
const parsed = JSON.parse(out.logs[0]);
assert.strictEqual(parsed.jobId, "job-456");
assert.strictEqual(parsed.client, "claude");
assert.strictEqual(parsed.status, "running");
} finally {
out.restore();
}
});
it("run async passes --timeout to startJob", async () => {
const out = captureOutput();
try {
let receivedTimeout: number | undefined;
const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--timeout", "30000"],
{
detectClients: () => mockClients,
startJob: async (_client, _prompt, options) => {
receivedTimeout = options?.timeoutMs;
return { id: "job-789", client: "codex", prompt: "hello", status: "running", startedAt: new Date().toISOString() };
},
}
);
assert.strictEqual(code, 0);
assert.strictEqual(receivedTimeout, 30000);
} finally {
out.restore();
}
});
it("run async ignores non-numeric --timeout and falls back to config timeout", async () => {
const out = captureOutput();
try {
let receivedTimeout: number | undefined;
const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--timeout", "not-a-number"],
{
detectClients: () => mockClients,
startJob: async (_client, _prompt, options) => {
receivedTimeout = options?.timeoutMs;
return { id: "job-t1", client: "codex", prompt: "hello", status: "running", startedAt: new Date().toISOString() };
},
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(receivedTimeout, 600_000);
} finally {
out.restore();
}
});
it("dispatch async passes --timeout to startJob", async () => {
const out = captureOutput();
try {
let receivedTimeout: number | undefined;
const code = await main(
["node", "cli.ts", "dispatch", "do something", "--timeout", "45000"],
{
detectClients: () => mockClients,
startJob: async (_client, _prompt, options) => {
receivedTimeout = options?.timeoutMs;
return { id: "job-abc", client: "codex", prompt: "do something", status: "running", startedAt: new Date().toISOString() };
},
resolveClient: () => "codex",
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(receivedTimeout, 45000);
} finally {
out.restore();
}
});
it("dispatch async ignores non-numeric --timeout and falls back to config timeout", async () => {
const out = captureOutput();
try {
let receivedTimeout: number | undefined;
const code = await main(
["node", "cli.ts", "dispatch", "do something", "--timeout", "bad"],
{
detectClients: () => mockClients,
startJob: async (_client, _prompt, options) => {
receivedTimeout = options?.timeoutMs;
return { id: "job-t2", client: "codex", prompt: "do something", status: "running", startedAt: new Date().toISOString() };
},
resolveClient: () => "codex",
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(receivedTimeout, 600_000);
} finally {
out.restore();
}
});
it("run async passes --debug to startJob", async () => {
const out = captureOutput();
const stderrChunks: string[] = [];
try {
const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--debug"],
{
detectClients: () => mockClients,
startJob: async (_client, _prompt, options) => {
options?.onDebug?.({
command: "codex",
args: ["exec", "--yolo", "hello"],
pid: 12345,
exitCode: 0,
exitSignal: null,
durationMs: 42,
stderrLength: 0,
stdoutLength: 6,
} as any);
return { id: "job-def", client: "codex", prompt: "hello", status: "running", startedAt: new Date().toISOString() };
},
stderrWrite: (chunk) => stderrChunks.push(chunk),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(stderrChunks.length, 1);
const diag = JSON.parse(stderrChunks[0]);
assert.strictEqual(diag.command, "codex");
assert.deepStrictEqual(diag.args, ["exec", "--yolo", "hello"]);
assert.strictEqual(diag.pid, 12345);
} finally {
out.restore();
}
});
it("run async returns 1 and prints error JSON when startJob throws", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello"],
{
detectClients: () => mockClients,
startJob: async () => {
throw new Error("spawn ENOENT");
},
}
);
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("ENOENT"));
} finally {
out.restore();
}
});
it("dispatch async returns 1 and prints error JSON when startJob throws", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "dispatch", "do something"],
{
detectClients: () => mockClients,
startJob: async () => {
throw new Error("spawn ENOENT");
},
resolveClient: () => "claude",
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
}
);
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("ENOENT"));
} finally {
out.restore();
}
});
it("run async uses config timeout when --timeout is not provided", async () => {
const out = captureOutput();
try {
let receivedTimeout: number | undefined;
const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello"],
{
detectClients: () => mockClients,
startJob: async (_client, _prompt, options) => {
receivedTimeout = options?.timeoutMs;
return { id: "job-nt1", client: "codex", prompt: "hello", status: "running", startedAt: new Date().toISOString() };
},
resolveConfig: () => ({ paths: {}, timeout: 300_000 }),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(receivedTimeout, 300_000);
} finally {
out.restore();
}
});
it("dispatch async uses config timeout when --timeout is not provided", async () => {
const out = captureOutput();
try {
let receivedTimeout: number | undefined;
const code = await main(
["node", "cli.ts", "dispatch", "do something"],
{
detectClients: () => mockClients,
startJob: async (_client, _prompt, options) => {
receivedTimeout = options?.timeoutMs;
return { id: "job-nt2", client: "codex", prompt: "do something", status: "running", startedAt: new Date().toISOString() };
},
resolveClient: () => "codex",
resolveConfig: () => ({ paths: {}, timeout: 300_000 }),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(receivedTimeout, 300_000);
} finally {
out.restore();
}
});
it("run async without --debug does not pass onDebug to startJob", async () => {
const out = captureOutput();
let receivedOptions: any;
try {
const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello"],
{
detectClients: () => mockClients,
startJob: async (_client, _prompt, options) => {
receivedOptions = options;
return { id: "job-nd1", client: "codex", prompt: "hello", status: "running", startedAt: new Date().toISOString() };
},
}
);
assert.strictEqual(code, 0);
assert.strictEqual(receivedOptions.onDebug, undefined);
} finally {
out.restore();
}
});
it("dispatch async without --debug does not pass onDebug to startJob", async () => {
const out = captureOutput();
let receivedOptions: any;
try {
const code = await main(
["node", "cli.ts", "dispatch", "do something"],
{
detectClients: () => mockClients,
startJob: async (_client, _prompt, options) => {
receivedOptions = options;
return { id: "job-nd2", client: "codex", prompt: "do something", status: "running", startedAt: new Date().toISOString() };
},
resolveClient: () => "codex",
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(receivedOptions.onDebug, undefined);
} finally {
out.restore();
}
});
it("dispatch async passes --debug to startJob", async () => {
const out = captureOutput();
const stderrChunks: string[] = [];
try {
const code = await main(
["node", "cli.ts", "dispatch", "do something", "--debug"],
{
detectClients: () => mockClients,
startJob: async (_client, _prompt, options) => {
options?.onDebug?.({
command: "codex",
args: ["exec", "--yolo", "do something"],
pid: 12345,
exitCode: 0,
exitSignal: null,
durationMs: 42,
stderrLength: 0,
stdoutLength: 6,
} as any);
return { id: "job-ghi", client: "codex", prompt: "do something", status: "running", startedAt: new Date().toISOString() };
},
resolveClient: () => "codex",
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
stderrWrite: (chunk) => stderrChunks.push(chunk),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(stderrChunks.length, 1);
const diag = JSON.parse(stderrChunks[0]);
assert.strictEqual(diag.command, "codex");
assert.strictEqual(diag.durationMs, 42);
} finally {
out.restore();
}
});
// Job lifecycle subcommands (S-302)
it("start starts job and returns job ID JSON", async () => {
const out = captureOutput();
try {
const started: { client: ClientName; prompt: string; options?: any }[] = [];
const code = await main(
["node", "cli.ts", "start", "--client", "codex", "--prompt", "hello"],
{
detectClients: () => mockClients,
startJob: async (client, prompt, options) => {
started.push({ client, prompt, options });
return { id: "job-start-1", client, prompt, status: "running", startedAt: new Date().toISOString() };
},
}
);
assert.strictEqual(code, 0);
assert.strictEqual(started.length, 1);
assert.strictEqual(started[0].client, "codex");
assert.strictEqual(started[0].prompt, "hello");
const parsed = JSON.parse(out.logs[0]);
assert.strictEqual(parsed.jobId, "job-start-1");
assert.strictEqual(parsed.client, "codex");
assert.strictEqual(parsed.status, "running");
} finally {
out.restore();
}
});
it("start returns 1 when client is missing", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "start", "--prompt", "hello"], {
detectClients: () => mockClients,
});
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("client"));
} finally {
out.restore();
}
});
it("start returns 1 when prompt is missing", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "start", "--client", "codex"], {
detectClients: () => mockClients,
});
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("prompt"));
} finally {
out.restore();
}
});
it("status <job-id> returns running job JSON", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "status", "job-123"],
{
detectClients: () => mockClients,
getJob: (jobId) => ({
id: jobId,
client: "codex",
prompt: "hello",
status: "running",
startedAt: "2024-01-01T00:00:00Z",
} as any),
}
);
assert.strictEqual(code, 0);
const parsed = JSON.parse(out.logs[0]);
assert.strictEqual(parsed.id, "job-123");
assert.strictEqual(parsed.status, "running");
assert.strictEqual(parsed.client, "codex");
} finally {
out.restore();
}
});
it("status <job-id> returns completed job JSON", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "status", "job-456"],
{
detectClients: () => mockClients,
getJob: (jobId) => ({
id: jobId,
client: "claude",
prompt: "write tests",
status: "completed",
startedAt: "2024-01-01T00:00:00Z",
completedAt: "2024-01-01T00:01:00Z",
result: { stdout: "ok", stderr: "", exitCode: 0, client: "claude", durationMs: 100 },
} as any),
}
);
assert.strictEqual(code, 0);
const parsed = JSON.parse(out.logs[0]);
assert.strictEqual(parsed.status, "completed");
assert.strictEqual(parsed.result.exitCode, 0);
} finally {
out.restore();
}
});
it("status returns 1 and prints error JSON for nonexistent job", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "status", "missing-job"],
{
detectClients: () => mockClients,
getJob: () => {
throw new Error('Job "missing-job" not found.');
},
}
);
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("not found"));
} finally {
out.restore();
}
});
it("status returns 1 when job-id is missing", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "status"], {
detectClients: () => mockClients,
});
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("job-id"));
} finally {
out.restore();
}
});
it("status prints text output with --text", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "status", "job-123", "--text"],
{
detectClients: () => mockClients,
getJob: (jobId) => ({
id: jobId,
client: "codex",
prompt: "hello",
status: "running",
startedAt: "2024-01-01T00:00:00Z",
} as any),
}
);
assert.strictEqual(code, 0);
assert.ok(out.logs[0].includes("running"));
assert.ok(out.logs[0].includes("job-123"));
} finally {
out.restore();
}
});
it("results <job-id> returns ExecResult JSON for completed job", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "results", "job-789"],
{
detectClients: () => mockClients,
getJobResult: () => ({
stdout: "output",
stderr: "",
exitCode: 0,
client: "codex",
durationMs: 42,
}),
}
);
assert.strictEqual(code, 0);
const parsed = JSON.parse(out.logs[0]);
assert.strictEqual(parsed.stdout, "output");
assert.strictEqual(parsed.exitCode, 0);
} finally {
out.restore();
}
});
it("results returns 1 and prints error JSON for still-running job", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "results", "job-running"],
{
detectClients: () => mockClients,
getJobResult: () => {
throw new Error('Job "job-running" result is not available (status: running).');
},
}
);
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("not available"));
} finally {
out.restore();
}
});
it("results returns 1 and prints error JSON for nonexistent job", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "results", "missing-job"],
{
detectClients: () => mockClients,
getJobResult: () => {
throw new Error('Job "missing-job" not found.');
},
}
);
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("not found"));
} finally {
out.restore();
}
});
it("results returns 1 when job-id is missing", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "results"], {
detectClients: () => mockClients,
});
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("job-id"));
} finally {
out.restore();
}
});
it("results prints text output with --text", async () => {
const out = captureOutput();
const stdoutChunks: string[] = [];
try {
const code = await main(
["node", "cli.ts", "results", "job-789", "--text"],
{
detectClients: () => mockClients,
getJobResult: () => ({
stdout: "hello-out",
stderr: "hello-err",
exitCode: 0,
client: "codex",
durationMs: 42,
}),
stdoutWrite: (chunk) => stdoutChunks.push(chunk),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(stdoutChunks.join(""), "hello-out");
} finally {
out.restore();
}
});
it("cancel <job-id> returns cancelled confirmation for running job", async () => {
const out = captureOutput();
try {
let cancelledJobId: string | undefined;
const code = await main(
["node", "cli.ts", "cancel", "job-abc"],
{
detectClients: () => mockClients,
getJob: (jobId) => ({
id: jobId,
client: "codex",
prompt: "hello",
status: "running",
startedAt: "2024-01-01T00:00:00Z",
} as any),
cancelJob: (jobId) => {
cancelledJobId = jobId;
},
}
);
assert.strictEqual(code, 0);
assert.strictEqual(cancelledJobId, "job-abc");
const parsed = JSON.parse(out.logs[0]);
assert.strictEqual(parsed.jobId, "job-abc");
assert.strictEqual(parsed.cancelled, true);
} finally {
out.restore();
}
});
it("cancel returns 1 and prints error JSON for completed job", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "cancel", "job-done"],
{
detectClients: () => mockClients,
getJob: (jobId) => ({
id: jobId,
client: "codex",
prompt: "hello",
status: "completed",
startedAt: "2024-01-01T00:00:00Z",
completedAt: "2024-01-01T00:01:00Z",
} as any),
}
);
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("running"));
} finally {
out.restore();
}
});
it("cancel returns 1 and prints error JSON for nonexistent job", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "cancel", "missing-job"],
{
detectClients: () => mockClients,
getJob: () => {
throw new Error('Job "missing-job" not found.');
},
}
);
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("not found"));
} finally {
out.restore();
}
});
it("cancel returns 1 when job-id is missing", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "cancel"], {
detectClients: () => mockClients,
});
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("job-id"));
} finally {
out.restore();
}
});
it("cancel prints text output with --text", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "cancel", "job-abc", "--text"],
{
detectClients: () => mockClients,
getJob: (jobId) => ({
id: jobId,
client: "codex",
prompt: "hello",
status: "running",
startedAt: "2024-01-01T00:00:00Z",
} as any),
cancelJob: () => {},
}
);
assert.strictEqual(code, 0);
assert.ok(out.logs[0].includes("cancelled"));
assert.ok(out.logs[0].includes("job-abc"));
} finally {
out.restore();
}
});
it("list-jobs returns array of all jobs", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "list-jobs"],
{
detectClients: () => mockClients,
listJobs: () => [
{ id: "job-1", client: "codex", prompt: "p1", status: "running", startedAt: "2024-01-01T00:00:00Z" },
{ id: "job-2", client: "claude", prompt: "p2", status: "completed", startedAt: "2024-01-01T00:01:00Z" },
] as any[],
}
);
assert.strictEqual(code, 0);
const parsed = JSON.parse(out.logs[0]);
assert.strictEqual(parsed.length, 2);
assert.strictEqual(parsed[0].id, "job-1");
assert.strictEqual(parsed[1].id, "job-2");
} finally {
out.restore();
}
});
it("list-jobs filters by --status", async () => {
const out = captureOutput();
try {
let receivedFilter: string | undefined;
const code = await main(
["node", "cli.ts", "list-jobs", "--status", "running"],
{
detectClients: () => mockClients,
listJobs: (options) => {
receivedFilter = options?.filter;
return [
{ id: "job-1", client: "codex", prompt: "p1", status: "running", startedAt: "2024-01-01T00:00:00Z" },
] as any[];
},
}
);
assert.strictEqual(code, 0);
assert.strictEqual(receivedFilter, "running");
const parsed = JSON.parse(out.logs[0]);
assert.strictEqual(parsed.length, 1);
assert.strictEqual(parsed[0].status, "running");
} finally {
out.restore();
}
});
it("list-jobs prints text output with --text", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "list-jobs", "--text"],
{
detectClients: () => mockClients,
listJobs: () => [
{ id: "job-1", client: "codex", prompt: "p1", status: "running", startedAt: "2024-01-01T00:00:00Z" },
] as any[],
}
);
assert.strictEqual(code, 0);
assert.ok(out.logs[0].includes("job-1"));
assert.ok(out.logs[0].includes("codex"));
assert.ok(out.logs[0].includes("running"));
} finally {
out.restore();
}
});
it("cleanup-jobs returns count of cleaned jobs", async () => {
const out = captureOutput();
try {
let callCount = 0;
const jobs = [
{ id: "job-1", client: "codex", prompt: "p1", status: "completed", startedAt: "2024-01-01T00:00:00Z" },
{ id: "job-2", client: "claude", prompt: "p2", status: "completed", startedAt: "2024-01-01T00:01:00Z" },
];
const code = await main(
["node", "cli.ts", "cleanup-jobs"],
{
detectClients: () => mockClients,
listJobs: () => {
callCount++;
return callCount === 1 ? jobs : [];
},
cleanupJobs: () => {},
}
);
assert.strictEqual(code, 0);
const parsed = JSON.parse(out.logs[0]);
assert.strictEqual(parsed.count, 2);
} finally {
out.restore();
}
});
it("cleanup-jobs with --max-age parses hours", async () => {
const out = captureOutput();
try {
let receivedMaxAgeMs: number | undefined;
const code = await main(
["node", "cli.ts", "cleanup-jobs", "--max-age", "12h"],
{
detectClients: () => mockClients,
listJobs: () => [],
cleanupJobs: (options) => {
receivedMaxAgeMs = options?.maxAgeMs;
},
}
);
assert.strictEqual(code, 0);
assert.strictEqual(receivedMaxAgeMs, 12 * 60 * 60 * 1000);
} finally {
out.restore();
}
});
it("cleanup-jobs returns 1 for invalid --max-age format", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "cleanup-jobs", "--max-age", "abc"],
{
detectClients: () => mockClients,
}
);
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("max-age"));
} finally {
out.restore();
}
});
it("cleanup-jobs prints text output with --text", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "cleanup-jobs", "--text"],
{
detectClients: () => mockClients,
listJobs: () => [],
cleanupJobs: () => {},
}
);
assert.strictEqual(code, 0);
assert.ok(out.logs[0].includes("Cleaned"));
assert.ok(out.logs[0].includes("0"));
} finally {
out.restore();
}
});
// S-303: help output and CLI integration smoke tests
it("--help output documents start subcommand", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "--help"]);
assert.strictEqual(code, 0);
const help = out.logs.join("\n");
assert.ok(help.includes("start"), "help should mention start");
} finally {
out.restore();
}
});
it("--help output documents status subcommand", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "--help"]);
assert.strictEqual(code, 0);
const help = out.logs.join("\n");
assert.ok(help.includes("status"), "help should mention status");
} finally {
out.restore();
}
});
it("--help output documents results subcommand", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "--help"]);
assert.strictEqual(code, 0);
const help = out.logs.join("\n");
assert.ok(help.includes("results"), "help should mention results");
} finally {
out.restore();
}
});
it("--help output documents cancel subcommand", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "--help"]);
assert.strictEqual(code, 0);
const help = out.logs.join("\n");
assert.ok(help.includes("cancel"), "help should mention cancel");
} finally {
out.restore();
}
});
it("--help output documents list-jobs subcommand", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "--help"]);
assert.strictEqual(code, 0);
const help = out.logs.join("\n");
assert.ok(help.includes("list-jobs"), "help should mention list-jobs");
} finally {
out.restore();
}
});
it("--help output documents cleanup-jobs subcommand", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "--help"]);
assert.strictEqual(code, 0);
const help = out.logs.join("\n");
assert.ok(help.includes("cleanup-jobs"), "help should mention cleanup-jobs");
} finally {
out.restore();
}
});
it("--help output documents --sync flag", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "--help"]);
assert.strictEqual(code, 0);
const help = out.logs.join("\n");
assert.ok(help.includes("--sync"), "help should mention --sync");
} finally {
out.restore();
}
});
it("--help output documents --timeout flag", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "--help"]);
assert.strictEqual(code, 0);
const help = out.logs.join("\n");
assert.ok(help.includes("--timeout"), "help should mention --timeout");
} finally {
out.restore();
}
});
it("--help output documents --debug flag", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "--help"]);
assert.strictEqual(code, 0);
const help = out.logs.join("\n");
assert.ok(help.includes("--debug"), "help should mention --debug");
} finally {
out.restore();
}
});
it("--help output documents --max-age flag", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "--help"]);
assert.strictEqual(code, 0);
const help = out.logs.join("\n");
assert.ok(help.includes("--max-age"), "help should mention --max-age");
} finally {
out.restore();
}
});
it("--help output documents --status flag for list-jobs", async () => {
const out = captureOutput();
try {
const code = await main(["node", "cli.ts", "--help"]);
assert.strictEqual(code, 0);
const help = out.logs.join("\n");
assert.ok(help.includes("--status"), "help should mention --status");
} finally {
out.restore();
}
});
it("start returns 1 for unknown client", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "start", "--client", "bogus", "--prompt", "hello"],
{
detectClients: () => mockClients,
}
);
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("client"));
} finally {
out.restore();
}
});
it("start prints text output with --text", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "start", "--client", "codex", "--prompt", "hello", "--text"],
{
detectClients: () => mockClients,
startJob: async (client, prompt) => ({
id: "job-start-txt",
client,
prompt,
status: "running",
startedAt: new Date().toISOString(),
}),
}
);
assert.strictEqual(code, 0);
assert.ok(out.logs[0].includes("job-start-txt"));
assert.ok(out.logs[0].includes("codex"));
assert.ok(out.logs[0].includes("running"));
} finally {
out.restore();
}
});
});