#!/usr/bin/env bun import { describe, test, expect } from "bun:test"; import { prepareRunConfig, parseClaudeArgs, extractStructuredOutput, type ClaudeOptions, } from "../src/run-claude"; describe("prepareRunConfig", () => { test("should prepare config with basic arguments", () => { const options: ClaudeOptions = {}; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.claudeArgs.slice(0, 4)).toEqual([ "-p", "--verbose", "--output-format", "stream-json", ]); }); test("should include promptPath", () => { const options: ClaudeOptions = {}; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.promptPath).toBe("/tmp/test-prompt.txt"); }); test("should include allowed tools in command arguments", () => { const options: ClaudeOptions = { allowedTools: "Bash,Read", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.claudeArgs).toContain("--allowedTools"); expect(prepared.claudeArgs).toContain("Bash,Read"); }); test("should include disallowed tools in command arguments", () => { const options: ClaudeOptions = { disallowedTools: "Bash,Read", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.claudeArgs).toContain("--disallowedTools"); expect(prepared.claudeArgs).toContain("Bash,Read"); }); test("should include max turns in command arguments", () => { const options: ClaudeOptions = { maxTurns: "5", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.claudeArgs).toContain("--max-turns"); expect(prepared.claudeArgs).toContain("5"); }); test("should include mcp config in command arguments", () => { const options: ClaudeOptions = { mcpConfig: "/path/to/mcp-config.json", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.claudeArgs).toContain("--mcp-config"); expect(prepared.claudeArgs).toContain("/path/to/mcp-config.json"); }); test("should include system prompt in command arguments", () => { const options: ClaudeOptions = { systemPrompt: "You are a senior backend engineer.", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.claudeArgs).toContain("--system-prompt"); expect(prepared.claudeArgs).toContain("You are a senior backend engineer."); }); test("should include append system prompt in command arguments", () => { const options: ClaudeOptions = { appendSystemPrompt: "After writing code, be sure to code review yourself.", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.claudeArgs).toContain("--append-system-prompt"); expect(prepared.claudeArgs).toContain( "After writing code, be sure to code review yourself.", ); }); test("should include fallback model in command arguments", () => { const options: ClaudeOptions = { fallbackModel: "claude-sonnet-4-20250514", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.claudeArgs).toContain("--fallback-model"); expect(prepared.claudeArgs).toContain("claude-sonnet-4-20250514"); }); test("should use provided prompt path", () => { const options: ClaudeOptions = {}; const prepared = prepareRunConfig("/custom/prompt/path.txt", options); expect(prepared.promptPath).toBe("/custom/prompt/path.txt"); }); test("should not include optional arguments when not set", () => { const options: ClaudeOptions = {}; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.claudeArgs).not.toContain("--allowedTools"); expect(prepared.claudeArgs).not.toContain("--disallowedTools"); expect(prepared.claudeArgs).not.toContain("--max-turns"); expect(prepared.claudeArgs).not.toContain("--mcp-config"); expect(prepared.claudeArgs).not.toContain("--system-prompt"); expect(prepared.claudeArgs).not.toContain("--append-system-prompt"); expect(prepared.claudeArgs).not.toContain("--fallback-model"); }); test("should preserve order of claude arguments", () => { const options: ClaudeOptions = { allowedTools: "Bash,Read", maxTurns: "3", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.claudeArgs).toEqual([ "-p", "--verbose", "--output-format", "stream-json", "--allowedTools", "Bash,Read", "--max-turns", "3", ]); }); test("should preserve order with all options including fallback model", () => { const options: ClaudeOptions = { allowedTools: "Bash,Read", disallowedTools: "Write", maxTurns: "3", mcpConfig: "/path/to/config.json", systemPrompt: "You are a helpful assistant", appendSystemPrompt: "Be concise", fallbackModel: "claude-sonnet-4-20250514", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.claudeArgs).toEqual([ "-p", "--verbose", "--output-format", "stream-json", "--allowedTools", "Bash,Read", "--disallowedTools", "Write", "--max-turns", "3", "--mcp-config", "/path/to/config.json", "--system-prompt", "You are a helpful assistant", "--append-system-prompt", "Be concise", "--fallback-model", "claude-sonnet-4-20250514", ]); }); describe("maxTurns validation", () => { test("should accept valid maxTurns value", () => { const options: ClaudeOptions = { maxTurns: "5" }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.claudeArgs).toContain("--max-turns"); expect(prepared.claudeArgs).toContain("5"); }); test("should throw error for non-numeric maxTurns", () => { const options: ClaudeOptions = { maxTurns: "abc" }; expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow( "maxTurns must be a positive number, got: abc", ); }); test("should throw error for negative maxTurns", () => { const options: ClaudeOptions = { maxTurns: "-1" }; expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow( "maxTurns must be a positive number, got: -1", ); }); test("should throw error for zero maxTurns", () => { const options: ClaudeOptions = { maxTurns: "0" }; expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow( "maxTurns must be a positive number, got: 0", ); }); }); describe("timeoutMinutes validation", () => { test("should accept valid timeoutMinutes value", () => { const options: ClaudeOptions = { timeoutMinutes: "15" }; expect(() => prepareRunConfig("/tmp/test-prompt.txt", options), ).not.toThrow(); }); test("should throw error for non-numeric timeoutMinutes", () => { const options: ClaudeOptions = { timeoutMinutes: "abc" }; expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow( "timeoutMinutes must be a positive number, got: abc", ); }); test("should throw error for negative timeoutMinutes", () => { const options: ClaudeOptions = { timeoutMinutes: "-5" }; expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow( "timeoutMinutes must be a positive number, got: -5", ); }); test("should throw error for zero timeoutMinutes", () => { const options: ClaudeOptions = { timeoutMinutes: "0" }; expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow( "timeoutMinutes must be a positive number, got: 0", ); }); }); describe("custom environment variables", () => { test("should parse empty claudeEnv correctly", () => { const options: ClaudeOptions = { claudeEnv: "" }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.env).toEqual({}); }); test("should parse single environment variable", () => { const options: ClaudeOptions = { claudeEnv: "API_KEY: secret123" }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.env).toEqual({ API_KEY: "secret123" }); }); test("should parse multiple environment variables", () => { const options: ClaudeOptions = { claudeEnv: "API_KEY: secret123\nDEBUG: true\nUSER: testuser", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.env).toEqual({ API_KEY: "secret123", DEBUG: "true", USER: "testuser", }); }); test("should handle environment variables with spaces around values", () => { const options: ClaudeOptions = { claudeEnv: "API_KEY: secret123 \n DEBUG : true ", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.env).toEqual({ API_KEY: "secret123", DEBUG: "true", }); }); test("should skip empty lines and comments", () => { const options: ClaudeOptions = { claudeEnv: "API_KEY: secret123\n\n# This is a comment\nDEBUG: true\n# Another comment", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.env).toEqual({ API_KEY: "secret123", DEBUG: "true", }); }); test("should skip lines without colons", () => { const options: ClaudeOptions = { claudeEnv: "API_KEY: secret123\nINVALID_LINE\nDEBUG: true", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.env).toEqual({ API_KEY: "secret123", DEBUG: "true", }); }); test("should handle undefined claudeEnv", () => { const options: ClaudeOptions = {}; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.env).toEqual({}); }); }); describe("claude_args passthrough", () => { test("empty/undefined claude_args leaves the arg list byte-identical", () => { const baseline = prepareRunConfig("/tmp/test-prompt.txt", { allowedTools: "Bash,Read", maxTurns: "3", }); const withEmpty = prepareRunConfig("/tmp/test-prompt.txt", { allowedTools: "Bash,Read", maxTurns: "3", claudeArgs: "", }); const withWhitespace = prepareRunConfig("/tmp/test-prompt.txt", { allowedTools: "Bash,Read", maxTurns: "3", claudeArgs: " \n ", }); const expected = [ "-p", "--verbose", "--output-format", "stream-json", "--allowedTools", "Bash,Read", "--max-turns", "3", ]; expect(baseline.claudeArgs).toEqual(expected); expect(withEmpty.claudeArgs).toEqual(expected); expect(withWhitespace.claudeArgs).toEqual(expected); expect(baseline.hasJsonSchema).toBe(false); }); test("claude_args tokens are appended after the managed arguments", () => { const prepared = prepareRunConfig("/tmp/test-prompt.txt", { maxTurns: "3", claudeArgs: "--output-format json --json-schema /tmp/schema.json", }); expect(prepared.claudeArgs).toEqual([ "-p", "--verbose", "--output-format", "stream-json", "--max-turns", "3", "--output-format", "json", "--json-schema", "/tmp/schema.json", ]); expect(prepared.hasJsonSchema).toBe(true); }); test("a single-quoted inline JSON schema survives as one token (incl. $ref)", () => { const schema = '{"$ref":"#/$defs/Verdict","type":"object"}'; const tokens = parseClaudeArgs(`--json-schema '${schema}'`); expect(tokens).toEqual(["--json-schema", schema]); }); test("comment lines are stripped before tokenizing", () => { const tokens = parseClaudeArgs( "# leading comment\n--max-turns 5\n # indented comment\n--verbose", ); expect(tokens).toEqual(["--max-turns", "5", "--verbose"]); }); test("hasJsonSchema is detected for both space and equals forms", () => { expect( prepareRunConfig("/tmp/test-prompt.txt", { claudeArgs: "--json-schema /tmp/s.json", }).hasJsonSchema, ).toBe(true); expect( prepareRunConfig("/tmp/test-prompt.txt", { claudeArgs: "--json-schema=/tmp/s.json", }).hasJsonSchema, ).toBe(true); expect( prepareRunConfig("/tmp/test-prompt.txt", { claudeArgs: "--max-turns 3", }).hasJsonSchema, ).toBe(false); }); }); describe("extractStructuredOutput", () => { test("returns structured_output from the stream-json result event", () => { const verdict = { decision: "approve", score: 9 }; const output = [ JSON.stringify({ type: "system", subtype: "init" }), JSON.stringify({ type: "assistant", message: { content: [] } }), JSON.stringify({ type: "result", subtype: "success", is_error: false, structured_output: verdict, }), ].join("\n"); expect(extractStructuredOutput(output)).toEqual(verdict); }); test("returns undefined when no structured_output is present", () => { const output = [ JSON.stringify({ type: "system", subtype: "init" }), JSON.stringify({ type: "result", subtype: "success", is_error: false }), ].join("\n"); expect(extractStructuredOutput(output)).toBeUndefined(); }); test("ignores non-JSON lines and trailing whitespace", () => { const verdict = { ok: true }; const output = `Running Claude...\n${JSON.stringify({ type: "result", structured_output: verdict, })}\n\n`; expect(extractStructuredOutput(output)).toEqual(verdict); }); }); });