feat(S-301): Test-drive and implement async default for run and dispatch
This commit is contained in:
@@ -107,12 +107,12 @@ describe("main", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("run executes client with prompt and prints JSON result", async () => {
|
||||
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"],
|
||||
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--sync"],
|
||||
{
|
||||
detectClients: () => mockClients,
|
||||
executePrompt: async (client, prompt) => {
|
||||
@@ -132,7 +132,7 @@ describe("main", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("run prints text output with --text", async () => {
|
||||
it("run prints text output with --text and --sync", async () => {
|
||||
const out = captureOutput();
|
||||
const stdoutChunks: string[] = [];
|
||||
const stderrChunks: string[] = [];
|
||||
@@ -147,6 +147,7 @@ describe("main", () => {
|
||||
"--prompt",
|
||||
"hello",
|
||||
"--text",
|
||||
"--sync",
|
||||
],
|
||||
{
|
||||
detectClients: () => mockClients,
|
||||
@@ -230,11 +231,11 @@ describe("main", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("run returns 1 and prints error JSON when client not found", async () => {
|
||||
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"],
|
||||
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--sync"],
|
||||
{
|
||||
detectClients: () => mockClients,
|
||||
executePrompt: async () => {
|
||||
@@ -250,12 +251,12 @@ describe("main", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatch routes positional prompt and prints JSON result", async () => {
|
||||
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"],
|
||||
["node", "cli.ts", "dispatch", "use claude to write tests", "--sync"],
|
||||
{
|
||||
detectClients: () => mockClients,
|
||||
executePrompt: async (client, prompt) => {
|
||||
@@ -277,12 +278,12 @@ describe("main", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatch routes --prompt flag and prints JSON result", async () => {
|
||||
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"],
|
||||
["node", "cli.ts", "dispatch", "--prompt", "use codex to refactor", "--sync"],
|
||||
{
|
||||
detectClients: () => mockClients,
|
||||
executePrompt: async (client, prompt) => {
|
||||
@@ -301,12 +302,12 @@ describe("main", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatch respects --client override", async () => {
|
||||
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"],
|
||||
["node", "cli.ts", "dispatch", "do something", "--client", "opencode", "--sync"],
|
||||
{
|
||||
detectClients: () => mockClients,
|
||||
executePrompt: async (client, prompt) => {
|
||||
@@ -344,11 +345,11 @@ describe("main", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatch returns 1 when resolved client is not found", async () => {
|
||||
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"],
|
||||
["node", "cli.ts", "dispatch", "do something", "--sync"],
|
||||
{
|
||||
detectClients: () => mockClients,
|
||||
executePrompt: async () => {
|
||||
@@ -380,12 +381,12 @@ describe("main", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatch prints text output with --text", async () => {
|
||||
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"],
|
||||
["node", "cli.ts", "dispatch", "do something", "--text", "--sync"],
|
||||
{
|
||||
detectClients: () => mockClients,
|
||||
executePrompt: async () => ({
|
||||
@@ -407,12 +408,12 @@ describe("main", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("run prints debug diagnostic JSON to stderr with --debug", async () => {
|
||||
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"],
|
||||
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--debug", "--sync"],
|
||||
{
|
||||
detectClients: () => mockClients,
|
||||
executePrompt: async (_client, _prompt, options?) => {
|
||||
@@ -452,12 +453,12 @@ describe("main", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("run ignores non-numeric --timeout and falls back to config timeout", async () => {
|
||||
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"],
|
||||
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--timeout", "not-a-number", "--sync"],
|
||||
{
|
||||
detectClients: () => mockClients,
|
||||
executePrompt: async (_client, _prompt, options?) => {
|
||||
@@ -474,12 +475,12 @@ describe("main", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatch ignores non-numeric --timeout and falls back to config timeout", async () => {
|
||||
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"],
|
||||
["node", "cli.ts", "dispatch", "do something", "--timeout", "bad", "--sync"],
|
||||
{
|
||||
detectClients: () => mockClients,
|
||||
executePrompt: async (_client, _prompt, options?) => {
|
||||
@@ -497,12 +498,12 @@ describe("main", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatch prints debug diagnostic JSON to stderr with --debug", async () => {
|
||||
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"],
|
||||
["node", "cli.ts", "dispatch", "do something", "--debug", "--sync"],
|
||||
{
|
||||
detectClients: () => mockClients,
|
||||
executePrompt: async (_client, _prompt, options?) => {
|
||||
@@ -538,4 +539,353 @@ describe("main", () => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user