d71f4db3d7
The docs recommend the file-path form (`--json-schema /path/to/schema.json`) as primary, but the default-pinned Claude CLI 2.1.160 only accepts an inline JSON string. Passing a path makes the CLI exit 0 with empty output, so the action's fail-loud branch trips on every run. Resolve the value before spawning the CLI: a file-path schema is read in-process and passed inline (validated + compacted), while an already-inline schema is left untouched. Because the value is handed to spawn() as an argv element (never through a shell), a schema with `$ref`/`$defs` round-trips intact. Unreadable files and invalid JSON now fail with a clear error instead of the CLI's silent empty output. Docs (README, example workflow, action.yml) are corrected to explain that the file-path form is auto-inlined and works on the default CLI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
540 lines
18 KiB
TypeScript
540 lines
18 KiB
TypeScript
#!/usr/bin/env bun
|
|
|
|
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
import { mkdtempSync, writeFileSync, rmSync } from "fs";
|
|
import { tmpdir } from "os";
|
|
import { join } from "path";
|
|
import {
|
|
prepareRunConfig,
|
|
parseClaudeArgs,
|
|
inlineJsonSchemaArgs,
|
|
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 '{"type":"object"}'`,
|
|
});
|
|
|
|
expect(prepared.claudeArgs).toEqual([
|
|
"-p",
|
|
"--verbose",
|
|
"--output-format",
|
|
"stream-json",
|
|
"--max-turns",
|
|
"3",
|
|
"--output-format",
|
|
"json",
|
|
"--json-schema",
|
|
'{"type":"object"}',
|
|
]);
|
|
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 '{"type":"object"}'`,
|
|
}).hasJsonSchema,
|
|
).toBe(true);
|
|
expect(
|
|
prepareRunConfig("/tmp/test-prompt.txt", {
|
|
claudeArgs: `--json-schema='{"type":"object"}'`,
|
|
}).hasJsonSchema,
|
|
).toBe(true);
|
|
expect(
|
|
prepareRunConfig("/tmp/test-prompt.txt", {
|
|
claudeArgs: "--max-turns 3",
|
|
}).hasJsonSchema,
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("inlineJsonSchemaArgs (issue #3 — file-path schemas)", () => {
|
|
let dir: string;
|
|
|
|
beforeAll(() => {
|
|
dir = mkdtempSync(join(tmpdir(), "json-schema-test-"));
|
|
});
|
|
|
|
afterAll(() => {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("passes through args that don't touch --json-schema", () => {
|
|
const args = ["-p", "--max-turns", "3", "--verbose"];
|
|
expect(inlineJsonSchemaArgs(args)).toEqual(args);
|
|
});
|
|
|
|
test("leaves an already-inline JSON schema untouched (space form)", () => {
|
|
const inline = '{"type":"object","properties":{"x":{"type":"string"}}}';
|
|
expect(inlineJsonSchemaArgs(["--json-schema", inline])).toEqual([
|
|
"--json-schema",
|
|
inline,
|
|
]);
|
|
});
|
|
|
|
test("leaves an already-inline JSON schema untouched (equals form)", () => {
|
|
const inline = '{"type":"object"}';
|
|
expect(inlineJsonSchemaArgs([`--json-schema=${inline}`])).toEqual([
|
|
`--json-schema=${inline}`,
|
|
]);
|
|
});
|
|
|
|
test("inlines a file-path schema (space form)", () => {
|
|
const schema = { type: "object", required: ["decision"] };
|
|
const file = join(dir, "space.json");
|
|
writeFileSync(file, JSON.stringify(schema, null, 2));
|
|
|
|
expect(inlineJsonSchemaArgs(["--json-schema", file])).toEqual([
|
|
"--json-schema",
|
|
JSON.stringify(schema),
|
|
]);
|
|
});
|
|
|
|
test("inlines a file-path schema (equals form)", () => {
|
|
const schema = { type: "object" };
|
|
const file = join(dir, "equals.json");
|
|
writeFileSync(file, JSON.stringify(schema));
|
|
|
|
expect(inlineJsonSchemaArgs([`--json-schema=${file}`])).toEqual([
|
|
`--json-schema=${JSON.stringify(schema)}`,
|
|
]);
|
|
});
|
|
|
|
// The reason the file-path form exists: a schema with $ref/$defs must
|
|
// round-trip without the shell expanding `$`. Inlining in-process and
|
|
// handing the result to spawn() as an argv element keeps it intact.
|
|
test("inlines a file-path schema containing $ref/$defs intact", () => {
|
|
const schema = {
|
|
$ref: "#/$defs/Verdict",
|
|
$defs: {
|
|
Verdict: {
|
|
type: "object",
|
|
additionalProperties: false,
|
|
required: ["decision"],
|
|
properties: {
|
|
decision: { type: "string", enum: ["approve", "reject"] },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const file = join(dir, "ref-schema.json");
|
|
writeFileSync(file, JSON.stringify(schema, null, 2));
|
|
|
|
const [flag, value] = inlineJsonSchemaArgs(["--json-schema", file]);
|
|
expect(flag).toBe("--json-schema");
|
|
expect(value).toContain("$ref");
|
|
expect(value).toContain("$defs");
|
|
expect(JSON.parse(value as string)).toEqual(schema);
|
|
});
|
|
|
|
test("throws a clear error when the schema file is missing", () => {
|
|
const missing = join(dir, "does-not-exist.json");
|
|
expect(() => inlineJsonSchemaArgs(["--json-schema", missing])).toThrow(
|
|
/Failed to read --json-schema file/,
|
|
);
|
|
});
|
|
|
|
test("throws a clear error when the schema file is not valid JSON", () => {
|
|
const file = join(dir, "invalid.json");
|
|
writeFileSync(file, "{ this is not json ");
|
|
expect(() => inlineJsonSchemaArgs(["--json-schema", file])).toThrow(
|
|
/is not valid JSON/,
|
|
);
|
|
});
|
|
|
|
test("prepareRunConfig inlines a file-path schema end-to-end", () => {
|
|
const schema = { $ref: "#/$defs/V", $defs: { V: { type: "object" } } };
|
|
const file = join(dir, "prepare.json");
|
|
writeFileSync(file, JSON.stringify(schema, null, 2));
|
|
|
|
const prepared = prepareRunConfig("/tmp/test-prompt.txt", {
|
|
claudeArgs: `--json-schema ${file}`,
|
|
});
|
|
|
|
expect(prepared.hasJsonSchema).toBe(true);
|
|
const idx = prepared.claudeArgs.indexOf("--json-schema");
|
|
expect(idx).toBeGreaterThanOrEqual(0);
|
|
expect(prepared.claudeArgs[idx + 1]).toBe(JSON.stringify(schema));
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|