feat(S-102): Test-drive and implement --timeout flag, config layering, and default in

This commit is contained in:
2026-05-19 19:39:46 -05:00
parent 5375c83c77
commit dc3fe8d6eb
6 changed files with 182 additions and 15 deletions
+12 -6
View File
@@ -39,6 +39,8 @@ describe("main", () => {
stdout: "output",
stderr: "",
exitCode: 0,
client: "codex",
durationMs: 42,
};
it("returns 0 for --help and prints usage", async () => {
@@ -152,6 +154,8 @@ describe("main", () => {
stdout: "hello-out",
stderr: "hello-err",
exitCode: 0,
client: "codex",
durationMs: 42,
}),
stdoutWrite: (chunk) => stdoutChunks.push(chunk),
stderrWrite: (chunk) => stderrChunks.push(chunk),
@@ -259,7 +263,7 @@ describe("main", () => {
return mockResult;
},
resolveClient: () => "claude",
resolveConfig: () => ({ paths: {} }),
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
}
);
assert.strictEqual(code, 0);
@@ -286,7 +290,7 @@ describe("main", () => {
return mockResult;
},
resolveClient: () => "codex",
resolveConfig: () => ({ paths: {} }),
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
}
);
assert.strictEqual(code, 0);
@@ -310,7 +314,7 @@ describe("main", () => {
return mockResult;
},
resolveClient: (_p, cfg) => cfg?.client ?? null,
resolveConfig: () => ({ paths: {}, defaultClient: "claude" }),
resolveConfig: () => ({ paths: {}, defaultClient: "claude", timeout: 600_000 }),
}
);
assert.strictEqual(code, 0);
@@ -329,7 +333,7 @@ describe("main", () => {
detectClients: () => mockClients,
executePrompt: async () => mockResult,
resolveClient: () => null,
resolveConfig: () => ({ paths: {} }),
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
}
);
assert.strictEqual(code, 1);
@@ -351,7 +355,7 @@ describe("main", () => {
throw new ClientNotFoundError("claude");
},
resolveClient: () => "claude",
resolveConfig: () => ({ paths: {} }),
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
}
);
assert.strictEqual(code, 1);
@@ -388,9 +392,11 @@ describe("main", () => {
stdout: "done",
stderr: "",
exitCode: 0,
client: "codex",
durationMs: 42,
}),
resolveClient: () => "codex",
resolveConfig: () => ({ paths: {} }),
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
stdoutWrite: (chunk) => stdoutChunks.push(chunk),
}
);
@@ -135,4 +135,71 @@ describe("resolveConfig", () => {
});
assert.strictEqual(config.defaultClient, undefined);
});
it("returns default timeout of 600000 when no sources are present", () => {
const config = resolveConfig({
existsSync: () => false,
whichSync: () => undefined,
});
assert.strictEqual(config.timeout, 600_000);
});
it("loads timeout from file config", () => {
const config = resolveConfig({
existsSync: () => true,
readFileSync: () => JSON.stringify({ timeout: 120_000 }),
whichSync: () => undefined,
});
assert.strictEqual(config.timeout, 120_000);
});
it("overrides file timeout with env var", () => {
const config = resolveConfig({
env: { AI_CLI_TIMEOUT: "240000" },
existsSync: () => true,
readFileSync: () => JSON.stringify({ timeout: 120_000 }),
whichSync: () => undefined,
});
assert.strictEqual(config.timeout, 240_000);
});
it("overrides env timeout with CLI flag", () => {
const config = resolveConfig({
flags: { timeout: "480000" },
env: { AI_CLI_TIMEOUT: "240000" },
existsSync: () => true,
readFileSync: () => JSON.stringify({ timeout: 120_000 }),
whichSync: () => undefined,
});
assert.strictEqual(config.timeout, 480_000);
});
it("respects full priority ordering for timeout: flag > env > file > default", () => {
const config = resolveConfig({
flags: { timeout: "480000" },
env: { AI_CLI_TIMEOUT: "240000" },
existsSync: () => true,
readFileSync: () => JSON.stringify({ timeout: 120_000 }),
whichSync: () => undefined,
});
assert.strictEqual(config.timeout, 480_000);
});
it("ignores invalid timeout from env var and falls back to default", () => {
const config = resolveConfig({
env: { AI_CLI_TIMEOUT: "not-a-number" },
existsSync: () => false,
whichSync: () => undefined,
});
assert.strictEqual(config.timeout, 600_000);
});
it("ignores invalid timeout from file and falls back to default", () => {
const config = resolveConfig({
existsSync: () => true,
readFileSync: () => JSON.stringify({ timeout: "not-a-number" }),
whichSync: () => undefined,
});
assert.strictEqual(config.timeout, 600_000);
});
});
@@ -203,4 +203,58 @@ describe("executePrompt", () => {
err instanceof ExecError && err.message.includes("Unknown client")
);
});
it("includes client and durationMs in result", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo hello", { stdout: "ok", exitCode: 0 }],
]);
const result = await executePrompt("codex", "hello", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
});
assert.strictEqual(result.client, "codex");
assert.strictEqual(typeof result.durationMs, "number");
assert.ok(result.durationMs >= 0);
});
it("rejects with ExecError containing custom timeout value", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo slow", { hang: true }],
]);
await assert.rejects(
executePrompt("codex", "slow", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
timeoutMs: 50,
}),
(err: unknown) =>
err instanceof ExecError &&
err.message === "Execution timed out after 50ms" &&
err.result.exitCode === -1 &&
err.result.client === "codex" &&
typeof err.result.durationMs === "number"
);
});
it("uses default timeout of 600000 when timeoutMs is not provided", async () => {
const delays: number[] = [];
const origSetTimeout = global.setTimeout;
(global as any).setTimeout = function(callback: any, delay: number) {
delays.push(delay);
return origSetTimeout(callback, delay);
};
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo hello", { stdout: "ok", exitCode: 0 }],
]);
try {
await executePrompt("codex", "hello", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
});
assert.strictEqual(delays[0], 600_000);
} finally {
global.setTimeout = origSetTimeout;
}
});
});