diff --git a/src/cli.test.ts b/src/cli.test.ts index a9c640a..625f68d 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -42,3 +42,37 @@ describe("CLI model validation", () => { assert.equal(isKnownModel("unknown"), false); }); }); + +describe("CLI --no-timeout flag", () => { + function parseTimeout(optsTimeout: string | false): number { + // Mirrors cli.ts logic: --no-timeout sets opts.timeout to false + return optsTimeout === false ? 0 : parseInt(optsTimeout as string, 10); + } + + function validateTimeout(optsTimeout: string | false): string | null { + const timeout = parseTimeout(optsTimeout); + if (optsTimeout !== false && (Number.isNaN(timeout) || timeout < 10 || timeout > 1800)) { + return "Error: --timeout must be a number between 10 and 1800 seconds"; + } + return null; + } + + it("--no-timeout sets timeout to 0", () => { + assert.equal(parseTimeout(false), 0); + }); + + it("--no-timeout passes validation", () => { + assert.equal(validateTimeout(false), null); + }); + + it("normal timeout values still validate", () => { + assert.equal(validateTimeout("300"), null); + assert.equal(parseTimeout("300"), 300); + }); + + it("invalid timeout values are rejected", () => { + assert.notEqual(validateTimeout("5"), null); + assert.notEqual(validateTimeout("abc"), null); + assert.notEqual(validateTimeout("9999"), null); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 11137e1..49337d8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -37,6 +37,7 @@ program String(cfg.testTimeout), ) .option("--timeout ", "Timeout per agent in seconds", String(cfg.timeout)) + .option("--no-timeout", "Disable agent timeout entirely") .option("--model ", "Claude model to use", cfg.model) .option("-r, --runner ", "AI coding tool to use", cfg.runner) .option( @@ -46,7 +47,7 @@ program ) .option("--scoring ", "Scoring method: copeland (default) or weighted", "copeland") .option("--no-color", "Disable colored output") - .option("--output-format ", "Output format: text (default) or json", "text") + .option("--output-format ", "Output format: text (default), json, or diff", "text") .option("--verbose", "Show detailed output from each agent") .option("--whitespace-insensitive", "Ignore whitespace differences in convergence comparison") .option("--retry", "Re-run only failed/timed-out agents from the last run") @@ -57,8 +58,9 @@ program process.exit(1); } - const timeout = parseInt(opts.timeout, 10); - if (Number.isNaN(timeout) || timeout < 10 || timeout > 1800) { + // --no-timeout: commander sets opts.timeout to false + const timeout = opts.timeout === false ? 0 : parseInt(opts.timeout, 10); + if (opts.timeout !== false && (Number.isNaN(timeout) || timeout < 10 || timeout > 1800)) { console.error("Error: --timeout must be a number between 10 and 1800 seconds"); process.exit(1); } @@ -80,7 +82,7 @@ program process.env.NO_COLOR = "1"; } - const validFormats = ["text", "json"]; + const validFormats = ["text", "json", "diff"]; if (!validFormats.includes(opts.outputFormat)) { console.error(`Error: --output-format must be one of: ${validFormats.join(", ")}`); process.exit(1); diff --git a/src/commands/run.ts b/src/commands/run.ts index 2be4566..41a32d8 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -306,6 +306,9 @@ export async function retry(opts: RunOptions): Promise { // Display results if (opts.outputFormat === "json") { console.log(JSON.stringify(result)); + } else if (opts.outputFormat === "diff") { + const recAgent = result.agents.find((a) => a.id === result.recommended); + if (recAgent?.diff) process.stdout.write(recAgent.diff); } else { displayResults(result); displayApplyInstructions(result); @@ -506,6 +509,9 @@ export async function run(opts: RunOptions): Promise { // Display results if (opts.outputFormat === "json") { console.log(JSON.stringify(result)); + } else if (opts.outputFormat === "diff") { + const recAgent = result.agents.find((a) => a.id === result.recommended); + if (recAgent?.diff) process.stdout.write(recAgent.diff); } else { displayResults(result); displayApplyInstructions(result); diff --git a/src/runners/claude-code.test.ts b/src/runners/claude-code.test.ts index 40c50c3..58bcc5a 100644 --- a/src/runners/claude-code.test.ts +++ b/src/runners/claude-code.test.ts @@ -30,4 +30,20 @@ describe("claude-code runner", () => { }); } }); + + describe("timeout=0 skips timer", () => { + it("does not call setTimeout when timeout is 0", () => { + // Mirrors the runner logic: timer is only created when timeout > 0 + const timeout = 0; + const timer = timeout > 0 ? setTimeout(() => {}, timeout * 1000) : null; + assert.equal(timer, null); + }); + + it("creates timer when timeout is positive", () => { + const timeout = 300; + const timer = timeout > 0 ? setTimeout(() => {}, timeout * 1000) : null; + assert.notEqual(timer, null); + if (timer) clearTimeout(timer); + }); + }); }); diff --git a/src/runners/claude-code.ts b/src/runners/claude-code.ts index 91280b2..bc7f491 100644 --- a/src/runners/claude-code.ts +++ b/src/runners/claude-code.ts @@ -82,28 +82,31 @@ export const claudeCodeRunner: Runner = { error += data.toString(); }); - const timer = setTimeout(() => { - if (!settled) { - settled = true; - child.kill("SIGTERM"); - resolve({ - id, - worktree: opts.worktreePath, - status: "timeout", - exitCode: -1, - duration: Date.now() - start, - output, - error: `Timed out after ${opts.timeout}s`, - diff: "", - filesChanged: [], - linesAdded: 0, - linesRemoved: 0, - }); - } - }, opts.timeout * 1000); + const timer = + opts.timeout > 0 + ? setTimeout(() => { + if (!settled) { + settled = true; + child.kill("SIGTERM"); + resolve({ + id, + worktree: opts.worktreePath, + status: "timeout", + exitCode: -1, + duration: Date.now() - start, + output, + error: `Timed out after ${opts.timeout}s`, + diff: "", + filesChanged: [], + linesAdded: 0, + linesRemoved: 0, + }); + } + }, opts.timeout * 1000) + : null; child.on("close", async (code) => { - clearTimeout(timer); + if (timer) clearTimeout(timer); if (settled) return; settled = true; @@ -136,7 +139,7 @@ export const claudeCodeRunner: Runner = { }); child.on("error", (err) => { - clearTimeout(timer); + if (timer) clearTimeout(timer); if (settled) return; settled = true; diff --git a/src/types.ts b/src/types.ts index ceae3ae..43d3df4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,7 +9,7 @@ export interface RunOptions { verbose: boolean; runner?: string; scoring: "weighted" | "copeland"; - outputFormat: "text" | "json"; + outputFormat: "text" | "json" | "diff"; retry?: boolean; whitespaceInsensitive?: boolean; }