Files
Captain e0805839eb fix(action): honor an explicit llm_protocol instead of forcing openai or anthropic (#1192)
The Configure OCR step derived llm.protocol from llm_use_anthropic alone,
so it always wrote openai or anthropic. Anyone on the Responses API was
sent to /v1/chat/completions no matter what OCR_LLM_PROTOCOL they set.

Add an optional llm_protocol input (anthropic, openai, openai-responses).
When it is set, or when OCR_LLM_PROTOCOL is inherited from the job
environment, that value is written and llm.use_anthropic is mirrored
from it, the same way `ocr config set llm.protocol` mirrors the boolean.
An unknown value fails the step with the accepted list. The explicit
protocol joins the checkpoint fingerprint, appended only when set so
existing checkpoints stay valid.

Fixes #1134
2026-09-21 17:42:02 +08:00

2032 lines
83 KiB
JavaScript

#!/usr/bin/env node
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
"use strict";
// Contract tests for action.yml's timeout/configuration boundary.
//
// Run via: node scripts/github-actions/action-contract.test.js
//
// The action is a composite action, so these tests use a small YAML extractor
// and execute the real shell blocks with fake `ocr` and `npm` binaries. No
// YAML or test-runner dependency is required.
const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { spawnSync } = require("child_process");
const ROOT = path.resolve(__dirname, "../..");
const ACTION_PATH = path.join(ROOT, "action.yml");
const ACTION_TEXT = fs.readFileSync(ACTION_PATH, "utf8");
const CONTRACT_WORKFLOW_PATH = path.join(ROOT, ".github/workflows/action-contract.yml");
const CONTRACT_WORKFLOW_TEXT = fs.readFileSync(CONTRACT_WORKFLOW_PATH, "utf8");
const EXAMPLE_README_PATH = path.join(ROOT, "examples/github_actions/README.md");
const EXAMPLE_README_TEXT = fs.readFileSync(EXAMPLE_README_PATH, "utf8");
function parseScalar(raw) {
const value = raw.trim();
if (value.length >= 2 && value[0] === "'" && value[value.length - 1] === "'") {
return value.slice(1, -1).replace(/''/g, "'");
}
if (value.length >= 2 && value[0] === '"' && value[value.length - 1] === '"') {
try {
return JSON.parse(value);
} catch (_err) {
return value.slice(1, -1);
}
}
return value;
}
function parseInputs(text) {
const lines = text.split(/\r?\n/);
const start = lines.findIndex((line) => /^inputs:\s*$/.test(line));
assert.ok(start >= 0, "action.yml must define inputs");
const end = lines.findIndex((line, index) => index > start && /^(outputs|runs):\s*$/.test(line));
const inputs = {};
const limit = end >= 0 ? end : lines.length;
for (let index = start + 1; index < limit; index += 1) {
const match = /^ ([A-Za-z0-9_]+):\s*$/.exec(lines[index]);
if (!match) continue;
const name = match[1];
let defaultValue;
for (let cursor = index + 1; cursor < limit; cursor += 1) {
if (/^ [A-Za-z0-9_]+:\s*$/.test(lines[cursor])) break;
const defaultMatch = /^ default:\s*(.*)$/.exec(lines[cursor]);
if (defaultMatch) defaultValue = parseScalar(defaultMatch[1]);
}
inputs[name] = { default: defaultValue };
}
return inputs;
}
function parseSteps(text) {
const lines = text.split(/\r?\n/);
const start = lines.findIndex((line) => /^ steps:\s*$/.test(line));
assert.ok(start >= 0, "action.yml must define composite steps");
const steps = [];
let current;
function finish() {
if (!current) return;
const rawLines = current.lines;
const runDeclarations = rawLines
.map((line, index) => (/^\s*run:\s*/.test(line) ? { line, index } : undefined))
.filter(Boolean);
assert.ok(runDeclarations.length <= 1, `step ${current.name} must not define multiple run blocks`);
let run;
if (runDeclarations.length === 1) {
const { line, index: runMarker } = runDeclarations[0];
assert.match(line, /^ run:\s*\|\s*$/, `step ${current.name} uses an unsupported run scalar`);
const body = [];
for (let index = runMarker + 1; index < rawLines.length; index += 1) {
const line = rawLines[index];
if (line.trim() === "") {
body.push("");
} else if (/^ /.test(line)) {
body.push(line.slice(8));
} else {
break;
}
}
run = body.join("\n");
}
const env = {};
const envDeclarations = rawLines
.map((line, index) => (/^\s*env:\s*$/.test(line) ? { line, index } : undefined))
.filter(Boolean);
assert.ok(envDeclarations.length <= 1, `step ${current.name} must not define multiple env blocks`);
const envMarker = envDeclarations.length === 1 ? envDeclarations[0].index : -1;
if (envMarker >= 0) {
assert.match(
envDeclarations[0].line,
/^ env:\s*$/,
`step ${current.name} uses unsupported env indentation`
);
for (let index = envMarker + 1; index < rawLines.length; index += 1) {
const line = rawLines[index];
if (line.trim() === "") continue;
// A `#` comment is legal YAML inside a mapping block, and the env
// blocks use them to record why a value is wired the way it is.
if (/^\s*#/.test(line)) continue;
if (/^ [A-Za-z_][A-Za-z0-9_-]*:\s*/.test(line)) break;
const match = /^ ([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/.exec(line);
assert.ok(match, `step ${current.name} contains an unsupported env value or indentation`);
assert.doesNotMatch(
match[2],
/^(?:\||>|\|-|>-|\|\+|>\+)$/,
`step ${current.name} contains an unsupported multiline env value`
);
env[match[1]] = parseScalar(match[2]);
}
}
steps.push({ name: current.name, run, env, index: current.index });
current = undefined;
}
for (let index = start + 1; index < lines.length; index += 1) {
if (/^ -(?:\s|$)/.test(lines[index]) && !/^ - name:\s*/.test(lines[index])) {
assert.fail(`unsupported nameless composite step at line ${index + 1}`);
}
if (/^\s*-\s+name:\s*/.test(lines[index]) && !/^ - name:\s*/.test(lines[index])) {
assert.fail(`unsupported composite step indentation at line ${index + 1}`);
}
const match = /^ - name:\s*(.*)\s*$/.exec(lines[index]);
if (match) {
finish();
current = { name: parseScalar(match[1]), lines: [lines[index]], index };
} else if (current) {
current.lines.push(lines[index]);
}
}
finish();
const names = steps.map((step) => step.name);
assert.strictEqual(new Set(names).size, names.length, "composite step names must be unique");
return steps;
}
const INPUTS = parseInputs(ACTION_TEXT);
const STEPS = parseSteps(ACTION_TEXT);
function inputValues(overrides = {}) {
const values = {};
for (const [name, definition] of Object.entries(INPUTS)) {
if (definition.default !== undefined) values[name] = definition.default;
}
return Object.assign(values, overrides);
}
function resolveInputExpressions(value, values, stepOutputs = {}) {
const resolved = String(value)
.replace(/\$\{\{\s*inputs\.([A-Za-z0-9_]+)\s*\}\}/g, (_match, name) => {
return values[name] === undefined ? "" : String(values[name]);
})
// A step output reads as "" when the step was skipped or never set it,
// which is the case these contracts exercise; `stepOutputs` overrides it
// where a test needs the range actually resolved. Other contexts
// (github.*, env.*) stay unresolved and still fail closed below.
.replace(/\$\{\{\s*steps\.([A-Za-z0-9_-]+)\.outputs\.([A-Za-z0-9_-]+)\s*\}\}/g, (_match, id, name) => {
const value = (stepOutputs[id] || {})[name];
return value === undefined ? "" : String(value);
});
assert.doesNotMatch(resolved, /\$\{\{[^}]+\}\}/, "unsupported or unresolved action expression");
return resolved;
}
function renderedEnv(step, values, stepOutputs = {}) {
const env = {};
for (const [name, value] of Object.entries(step.env || {})) {
env[name] = resolveInputExpressions(value, values, stepOutputs);
}
return env;
}
function renderedRun(step, values) {
assert.ok(step && typeof step.run === "string", `step ${step ? step.name : "<missing>"} must have a bash run block`);
return resolveInputExpressions(step.run, values);
}
function stepNamed(name) {
return STEPS.find((step) => step.name === name);
}
function validationStep() {
return STEPS.find((step) => {
const haystack = `${step.name}\n${step.run || ""}`;
return /review(?:[ _-]?task)?[ _-]?timeout/i.test(haystack);
});
}
function installStep() {
return STEPS.find((step) => /install\s+opencode|install\s+open.?code.?review/i.test(step.name));
}
function makeFixture() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "open-code-review-action-contract-"));
const bin = path.join(dir, "bin");
fs.mkdirSync(bin);
const callsPath = path.join(dir, "calls.jsonl");
const npmCallsPath = path.join(dir, "npm-calls.jsonl");
const configPath = path.join(dir, "config.jsonl");
const resultPath = path.join(dir, "ocr-result.json");
const stderrPath = path.join(dir, "ocr-stderr.log");
const ocrScript = `#!/usr/bin/env node
const fs = require("fs");
const args = process.argv.slice(2);
const env = {};
for (const name of ["OCR_LLM_TIMEOUT", "OCR_LLM_EXTRA_HEADERS", "REVIEW_TASK_TIMEOUT", "OCR_TIMEOUT"]) {
if (process.env[name] !== undefined) env[name] = process.env[name];
}
const record = { args, env };
fs.appendFileSync(process.env.OCR_CALLS, JSON.stringify(record) + "\\n");
if (args[0] === "config" && args[1] === "set") {
fs.appendFileSync(process.env.OCR_CONFIG, JSON.stringify(args) + "\\n");
process.stdout.write("ocr config set " + args.slice(2).join(" ") + "\\n");
} else if (args[0] === "config" && args[1] === "unset") {
fs.appendFileSync(process.env.OCR_CONFIG, JSON.stringify(args) + "\\n");
process.stdout.write("ocr config unset " + args.slice(2).join(" ") + "\\n");
} else if (args[0] === "version") {
const output = Object.prototype.hasOwnProperty.call(process.env, "OCR_FAKE_VERSION_OUTPUT")
? process.env.OCR_FAKE_VERSION_OUTPUT
: "open-code-review 1.9.10 contract-test/fixture";
process.stdout.write(output);
if (output && !output.endsWith("\\n")) process.stdout.write("\\n");
process.exit(Number(process.env.OCR_FAKE_VERSION_STATUS || 0));
} else if (args[0] === "review") {
process.stdout.write(JSON.stringify({ comments: [], warnings: [] }));
} else {
process.stdout.write("ocr " + args.join(" ") + "\\n");
}
`;
const npmScript = `#!/usr/bin/env node
const fs = require("fs");
const args = process.argv.slice(2);
fs.appendFileSync(process.env.OCR_NPM_CALLS, JSON.stringify(args) + "\\n");
process.stdout.write("npm " + args.join(" ") + "\\n");
`;
for (const [name, body] of [["ocr", ocrScript], ["npm", npmScript]]) {
const file = path.join(bin, name);
fs.writeFileSync(file, body, { mode: 0o755 });
}
return { dir, bin, callsPath, npmCallsPath, configPath, resultPath, stderrPath };
}
function removeFixture(fixture) {
fs.rmSync(fixture.dir, { recursive: true, force: true });
}
function runShell(script, env, fixture) {
const result = spawnSync("/bin/bash", ["--noprofile", "--norc", "-eo", "pipefail", "-c", script], {
cwd: ROOT,
env: Object.assign({}, process.env, {
PATH: `${fixture.bin}:${process.env.PATH || ""}`,
OCR_CALLS: fixture.callsPath,
OCR_NPM_CALLS: fixture.npmCallsPath,
OCR_CONFIG: fixture.configPath,
GITHUB_OUTPUT: path.join(fixture.dir, "github-output"),
GITHUB_ENV: path.join(fixture.dir, "github-env"),
}, env),
encoding: "utf8",
});
return result;
}
function runStep(step, values, fixture, extraEnv = {}, options = {}) {
let script = renderedRun(step, values);
if (options.replaceResultPaths) {
script = script
.replaceAll("/tmp/ocr-result.json", fixture.resultPath)
.replaceAll("/tmp/ocr-stderr.log", fixture.stderrPath);
}
return runShell(script, Object.assign({}, renderedEnv(step, values), extraEnv), fixture);
}
function resultDescription(result) {
return `status=${result.status}; stdout=${JSON.stringify(result.stdout)}; stderr=${JSON.stringify(result.stderr)}`;
}
function readJsonLines(file) {
if (!fs.existsSync(file)) return [];
return fs
.readFileSync(file, "utf8")
.split(/\r?\n/)
.filter(Boolean)
.map((line) => JSON.parse(line));
}
function readEnvAssignments(file) {
const values = {};
if (!fs.existsSync(file)) return values;
for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
if (!line) continue;
const separator = line.indexOf("=");
if (separator < 1) continue;
values[line.slice(0, separator)] = line.slice(separator + 1);
}
return values;
}
function configValues(calls) {
const values = {};
for (const args of calls) {
if (!Array.isArray(args) || args.length < 2) continue;
if (args[0] !== "config" || args[1] !== "set") continue;
const key = args[2];
const value = args[3];
if (key) values[key] = value;
}
return values;
}
function configOperations(fixture) {
return readJsonLines(fixture.configPath);
}
function allFixtureFiles(root) {
const files = [];
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
const fullPath = path.join(root, entry.name);
if (entry.isDirectory()) files.push(...allFixtureFiles(fullPath));
else files.push(fullPath);
}
return files;
}
function escapedRegExp(value) {
return new RegExp(value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
}
function assertValidation(value, expectedValid) {
const step = validationStep();
assert.ok(step, "action.yml must add a validation step that references review_task_timeout");
const fixture = makeFixture();
try {
const result = runStep(step, inputValues({ review_task_timeout: value }), fixture);
if (expectedValid) {
assert.strictEqual(result.status, 0, `review_task_timeout=${JSON.stringify(value)} should be accepted; ${resultDescription(result)}`);
} else {
assert.notStrictEqual(result.status, 0, `review_task_timeout=${JSON.stringify(value)} should be rejected; ${resultDescription(result)}`);
}
} finally {
removeFixture(fixture);
}
}
function prRefsStep() {
const step = stepNamed("Resolve PR refs");
assert.ok(step, "action.yml must retain the Resolve PR refs step");
return step;
}
// The step reads its candidate PR numbers from `github.*` expressions, which the
// harness deliberately refuses to resolve. So the sources are supplied here the
// way the runner would have rendered them for a given event, and the precedence
// between them — the part that is actually under test — runs as written.
function runPrRefs(sources) {
const fixture = makeFixture();
try {
const env = Object.assign(
{
INPUT_BASE_REF: "",
INPUT_HEAD_SHA: "",
INPUT_PR_NUMBER: "",
EVENT_BASE_REF: "main",
EVENT_HEAD_SHA: "a".repeat(40),
EVENT_PR_NUMBER: "",
WORKFLOW_RUN_PR_NUMBER: "",
GITHUB_EVENT_NAME: "workflow_run",
},
sources
);
const result = runShell(renderedRun(prRefsStep(), inputValues()), env, fixture);
return { result, exported: readEnvAssignments(path.join(fixture.dir, "github-env")) };
} finally {
removeFixture(fixture);
}
}
function testPrNumberInputIsOptionalAndDocumented() {
assert.ok(INPUTS.pr_number, "action.yml must define the pr_number input");
assert.strictEqual(
INPUTS.pr_number.default,
"",
"pr_number must default to empty so every current trigger keeps today's resolution"
);
const inputBlock = ACTION_TEXT.match(
/^ pr_number:\s*$([\s\S]*?)(?=^ [A-Za-z0-9_]+:\s*$|^outputs:|^runs:)/m
);
assert.ok(inputBlock, "action.yml must expose pr_number metadata");
assert.match(inputBlock[1], /required:\s*false/, "pr_number must stay optional");
assert.match(
inputBlock[1],
/workflow_run/,
"pr_number must document the trigger it exists for"
);
}
function testPrNumberResolutionOrder() {
const cases = [
[
"the explicit input outranks every payload field",
{ INPUT_PR_NUMBER: "4242", EVENT_PR_NUMBER: "7", WORKFLOW_RUN_PR_NUMBER: "9" },
"4242",
],
[
"pull_request / issue_comment payloads are used when no input is given",
{ EVENT_PR_NUMBER: "7", WORKFLOW_RUN_PR_NUMBER: "9" },
"7",
],
[
"workflow_run.pull_requests[0] is the last resort",
{ WORKFLOW_RUN_PR_NUMBER: "9" },
"9",
],
];
for (const [label, sources, expected] of cases) {
const { result, exported } = runPrRefs(sources);
assert.strictEqual(result.status, 0, `${label}; ${resultDescription(result)}`);
assert.strictEqual(exported.PR_NUMBER, expected, label);
assert.strictEqual(exported.BASE_REF, "main", `${label}; the ref resolution is unchanged`);
assert.strictEqual(exported.HEAD_SHA, "a".repeat(40), `${label}; the head resolution is unchanged`);
}
}
function testPrNumberFailsBeforeAnyReviewWork() {
const { result, exported } = runPrRefs({});
assert.notStrictEqual(
result.status,
0,
`an unresolvable PR number must stop the run; ${resultDescription(result)}`
);
const output = `${result.stdout}${result.stderr}`;
assert.match(output, /::error::/, "the failure must be annotated for the run summary");
assert.match(output, /pr_number/, "…and must name the input that fixes it");
assert.match(output, /workflow_run\.pull_requests\[0\]\.number/, "…and where that payload keeps it");
assert.strictEqual(exported.PR_NUMBER, undefined, "nothing is exported when no number is known");
// The point of failing here is that it costs nothing: no install, no LLM
// quota, no half-finished review whose findings have nowhere to go.
const refs = prRefsStep();
for (const later of ["Install OpenCodeReview", "Run OpenCodeReview", "Post review comments"]) {
const step = stepNamed(later);
assert.ok(step, `action.yml must retain the ${later} step`);
assert.ok(refs.index < step.index, `PR number resolution must precede ${later}`);
}
const fixture = makeFixture();
try {
const values = inputValues({ ocr_version: "contract-test" });
const install = installStep();
const script = `${renderedRun(refs, values)}\n${renderedRun(install, values)}`;
const env = Object.assign(
{
INPUT_BASE_REF: "",
INPUT_HEAD_SHA: "",
INPUT_PR_NUMBER: "",
EVENT_BASE_REF: "main",
EVENT_HEAD_SHA: "a".repeat(40),
EVENT_PR_NUMBER: "",
WORKFLOW_RUN_PR_NUMBER: "",
GITHUB_EVENT_NAME: "workflow_run",
},
renderedEnv(install, values)
);
const combined = runShell(script, env, fixture);
assert.notStrictEqual(combined.status, 0, `the run must stop; ${resultDescription(combined)}`);
assert.deepStrictEqual(
readJsonLines(fixture.npmCallsPath),
[],
"NPM must not run after PR-number resolution fails"
);
} finally {
removeFixture(fixture);
}
}
function testPrNumberRejectsValuesThatAreNotAPrNumber() {
for (const value of ["#123", "123abc", "0", "-1", "12.0", " ", "pull/123"]) {
const { result, exported } = runPrRefs({ INPUT_PR_NUMBER: value });
assert.notStrictEqual(
result.status,
0,
`pr_number=${JSON.stringify(value)} must be rejected; ${resultDescription(result)}`
);
assert.strictEqual(exported.PR_NUMBER, undefined, "a rejected value is never exported");
}
for (const value of ["1", "4242"]) {
const { result, exported } = runPrRefs({ INPUT_PR_NUMBER: value });
assert.strictEqual(
result.status,
0,
`pr_number=${JSON.stringify(value)} must be accepted; ${resultDescription(result)}`
);
assert.strictEqual(exported.PR_NUMBER, value);
}
}
function testResolvedPrNumberReachesTheHeadFetch() {
const step = stepNamed("Fetch PR head (fork-safe)");
assert.ok(step, "action.yml must retain the fork-safe head fetch");
assert.strictEqual(
step.env.PR_NUM,
"${{ env.PR_NUMBER }}",
"the fetch must use the resolved number rather than re-reading the payload"
);
const fixture = makeFixture();
try {
const gitCalls = path.join(fixture.dir, "git-calls.jsonl");
fs.writeFileSync(
path.join(fixture.bin, "git"),
`#!/usr/bin/env node
require("fs").appendFileSync(process.env.OCR_GIT_CALLS, JSON.stringify(process.argv.slice(2)) + "\\n");
`,
{ mode: 0o755 }
);
const result = runShell(
renderedRun(step, inputValues()),
{ PR_NUM: "4242", OCR_GIT_CALLS: gitCalls },
fixture
);
assert.strictEqual(result.status, 0, resultDescription(result));
assert.deepStrictEqual(
readJsonLines(gitCalls),
[["fetch", "origin", "pull/4242/head"]],
"the head fetch must name the resolved PR"
);
} finally {
removeFixture(fixture);
}
}
function testPrNumberWiredIntoBothGithubScriptSteps() {
for (const name of ["Resolve review range", "Post review comments"]) {
const step = stepNamed(name);
assert.ok(step, `action.yml must retain the ${name} step`);
assert.strictEqual(
step.env.OCR_PR_NUMBER,
"${{ env.PR_NUMBER }}",
`${name} must read the resolved PR number`
);
}
assert.doesNotMatch(
ACTION_TEXT,
/prNumber:\s*context\.issue\.number/,
"no step may fall back to context.issue.number, which resolves nothing on workflow_run"
);
assert.strictEqual(
(ACTION_TEXT.match(/prNumber: Number\(process\.env\.OCR_PR_NUMBER\)/g) || []).length,
2,
"both github-script steps must pass the resolved number"
);
}
function testExampleReadmeDocumentsPrNumberResolution() {
assert.match(
EXAMPLE_README_TEXT,
/\| `pr_number` input \|/,
"GitHub Actions README must document pr_number as the first resolution source"
);
assert.match(
EXAMPLE_README_TEXT,
/pr_number: \$\{\{ github\.event\.workflow_run\.pull_requests\[0\]\.number \}\}/,
"GitHub Actions README must show the workflow_run wiring it exists for"
);
}
function testReviewTaskTimeoutInputNameAndScope() {
assert.ok(INPUTS.review_task_timeout, "action.yml must define the review_task_timeout input");
assert.ok(!INPUTS.review_timeout, "the not-yet-released review_timeout input must be renamed");
assert.strictEqual(
INPUTS.review_task_timeout.default,
"15",
"review_task_timeout default must remain 15 minutes"
);
const inputBlock = ACTION_TEXT.match(
/^ review_task_timeout:\s*$([\s\S]*?)(?=^ [A-Za-z0-9_]+:\s*$|^outputs:|^runs:)/m
);
assert.ok(inputBlock, "action.yml must expose review_task_timeout metadata");
assert.match(
inputBlock[1],
/per-(?:file|task)|concurrent task/i,
"review_task_timeout must describe its per-file/per-task scope"
);
assert.doesNotMatch(
inputBlock[1],
/whole[- ]review|wall[- ]clock cap/i,
"review_task_timeout must not promise a whole-review wall-clock cap"
);
}
function testLlmTimeoutInputDefault() {
assert.ok(INPUTS.llm_timeout, "action.yml must define the llm_timeout input");
assert.strictEqual(INPUTS.llm_timeout.default, "300", "llm_timeout default must match the CLI's 5-minute timeout");
}
function testReviewTimeoutValidationAcceptsBoundaries() {
for (const value of ["1", "10", "120"]) assertValidation(value, true);
}
function testReviewTimeoutValidationRejectsMalformedValues() {
for (const value of ["", "1.5", "+10", "0", "-1", "-10", "121"]) {
assertValidation(value, false);
}
}
function testValidationPrecedesNpmInstall() {
const validation = validationStep();
const install = installStep();
assert.ok(validation, "action.yml must add review_task_timeout validation before installation");
assert.ok(install, "action.yml must retain an OpenCodeReview installation step");
assert.ok(validation.index < install.index, "review_task_timeout validation must occur before NPM install");
const fixture = makeFixture();
try {
const values = inputValues({ review_task_timeout: "121", ocr_version: "contract-test" });
const script = `${renderedRun(validation, values)}\n${renderedRun(install, values)}`;
const env = Object.assign({}, renderedEnv(validation, values), renderedEnv(install, values));
const result = runShell(script, env, fixture);
assert.notStrictEqual(result.status, 0, `invalid review_task_timeout must stop the action; ${resultDescription(result)}`);
assert.deepStrictEqual(readJsonLines(fixture.npmCallsPath), [], "NPM must not run after timeout validation fails");
} finally {
removeFixture(fixture);
}
}
function testReviewTimeoutForwardedSeparatelyFromLlmTimeout() {
const run = stepNamed("Run OpenCodeReview");
assert.ok(run, "action.yml must retain the Run OpenCodeReview step");
const fixture = makeFixture();
try {
const values = inputValues({
review_task_timeout: "10",
llm_timeout: "900",
llm_url: "https://llm.example.invalid/v1",
llm_auth_token: "unused-token",
llm_model: "contract-model",
llm_use_anthropic: "false",
});
const result = runStep(
run,
values,
fixture,
{ MERGE_BASE: "base-sha", HEAD_SHA: "head-sha", REVIEW_TASK_TIMEOUT: values.review_task_timeout },
{ replaceResultPaths: true }
);
assert.strictEqual(result.status, 0, `Run OpenCodeReview shell block failed; ${resultDescription(result)}`);
const reviewCall = readJsonLines(fixture.callsPath).find((call) => call.args[0] === "review");
assert.ok(reviewCall, "Run OpenCodeReview must invoke `ocr review`");
const timeoutIndices = reviewCall.args
.map((arg, index) => (arg === "--timeout" ? index : -1))
.filter((index) => index >= 0);
assert.strictEqual(
timeoutIndices.length,
1,
`review timeout must be forwarded exactly once; args=${JSON.stringify(reviewCall.args)}`
);
assert.strictEqual(reviewCall.args[timeoutIndices[0] + 1], "10", "review_task_timeout must be passed as --timeout");
assert.strictEqual(reviewCall.env.OCR_LLM_TIMEOUT, "900", "llm_timeout must remain the LLM request timeout");
assert.notStrictEqual(
reviewCall.args[timeoutIndices[0] + 1],
reviewCall.env.OCR_LLM_TIMEOUT,
"review_task_timeout and llm_timeout must remain distinct settings"
);
} finally {
removeFixture(fixture);
}
}
function testDefaultLlmTimeoutExportedSeparatelyFromReviewTimeout() {
const run = stepNamed("Run OpenCodeReview");
assert.ok(run, "action.yml must retain the Run OpenCodeReview step");
const fixture = makeFixture();
try {
const values = inputValues({
review_task_timeout: "10",
llm_url: "https://llm.example.invalid/v1",
llm_auth_token: "unused-token",
llm_model: "contract-model",
llm_use_anthropic: "false",
});
const result = runStep(
run,
values,
fixture,
{ MERGE_BASE: "base-sha", HEAD_SHA: "head-sha", REVIEW_TASK_TIMEOUT: values.review_task_timeout },
{ replaceResultPaths: true }
);
assert.strictEqual(result.status, 0, `Run OpenCodeReview shell block failed; ${resultDescription(result)}`);
const reviewCall = readJsonLines(fixture.callsPath).find((call) => call.args[0] === "review");
assert.ok(reviewCall, "Run OpenCodeReview must invoke `ocr review`");
const timeoutIndex = reviewCall.args.indexOf("--timeout");
assert.ok(timeoutIndex >= 0, "Run OpenCodeReview must forward review_task_timeout");
assert.strictEqual(reviewCall.args[timeoutIndex + 1], "10");
assert.strictEqual(reviewCall.env.OCR_LLM_TIMEOUT, "300");
assert.notStrictEqual(reviewCall.args[timeoutIndex + 1], reviewCall.env.OCR_LLM_TIMEOUT);
} finally {
removeFixture(fixture);
}
}
function testEmptyLlmTimeoutNormalizesBeforeReviewInvocation() {
const run = stepNamed("Run OpenCodeReview");
assert.ok(run, "action.yml must retain the Run OpenCodeReview step");
const fixture = makeFixture();
try {
const values = inputValues({
review_task_timeout: "10",
llm_timeout: "",
llm_url: "https://llm.example.invalid/v1",
llm_auth_token: "unused-token",
llm_model: "contract-model",
llm_use_anthropic: "false",
});
const result = runStep(
run,
values,
fixture,
{ MERGE_BASE: "base-sha", HEAD_SHA: "head-sha", REVIEW_TASK_TIMEOUT: values.review_task_timeout },
{ replaceResultPaths: true }
);
assert.strictEqual(result.status, 0, `Run OpenCodeReview shell block failed; ${resultDescription(result)}`);
const reviewCall = readJsonLines(fixture.callsPath).find((call) => call.args[0] === "review");
assert.ok(reviewCall, "Run OpenCodeReview must invoke `ocr review`");
const timeoutIndex = reviewCall.args.indexOf("--timeout");
assert.strictEqual(reviewCall.env.OCR_LLM_TIMEOUT, "300");
assert.strictEqual(reviewCall.args[timeoutIndex + 1], "10");
assert.notStrictEqual(reviewCall.args[timeoutIndex + 1], reviewCall.env.OCR_LLM_TIMEOUT);
} finally {
removeFixture(fixture);
}
}
function testReviewTimeoutLeadingZeroIsNormalizedAcrossSteps() {
const validation = validationStep();
const run = stepNamed("Run OpenCodeReview");
assert.ok(validation, "action.yml must retain review_task_timeout validation");
assert.ok(run, "action.yml must retain the Run OpenCodeReview step");
const fixture = makeFixture();
try {
const values = inputValues({
review_task_timeout: "010",
llm_url: "https://llm.example.invalid/v1",
llm_auth_token: "unused-token",
llm_model: "contract-model",
llm_use_anthropic: "false",
});
const validationResult = runStep(validation, values, fixture);
assert.strictEqual(
validationResult.status,
0,
`review_task_timeout=010 validation failed; ${resultDescription(validationResult)}`
);
const exported = readEnvAssignments(path.join(fixture.dir, "github-env"));
assert.strictEqual(exported.REVIEW_TASK_TIMEOUT, "10", "validation must export normalized decimal review_task_timeout");
assert.ok(
!Object.prototype.hasOwnProperty.call(run.env, "REVIEW_TASK_TIMEOUT"),
"Run OpenCodeReview must consume the normalized GITHUB_ENV value, not rebind the raw input"
);
const result = runStep(
run,
values,
fixture,
{ MERGE_BASE: "base-sha", HEAD_SHA: "head-sha", REVIEW_TASK_TIMEOUT: exported.REVIEW_TASK_TIMEOUT },
{ replaceResultPaths: true }
);
assert.strictEqual(result.status, 0, `Run OpenCodeReview shell block failed; ${resultDescription(result)}`);
const reviewCall = readJsonLines(fixture.callsPath).find((call) => call.args[0] === "review");
assert.ok(reviewCall, "Run OpenCodeReview must invoke `ocr review`");
const timeoutIndex = reviewCall.args.indexOf("--timeout");
assert.strictEqual(reviewCall.args[timeoutIndex + 1], "10");
} finally {
removeFixture(fixture);
}
}
function testValidateInputsRejectsInvalidEffortAndBudget() {
const validation = validationStep();
assert.ok(validation, "action.yml must retain input validation");
const cases = [
[{ effort: "extreme" }, /effort must be one of/],
[{ effort: "max" }, /effort must be one of/],
[{ max_tokens_budget: "-5" }, /max_tokens_budget must be/],
[{ max_tokens_budget: "10.5" }, /max_tokens_budget must be/],
[{ max_tokens_budget: "abc" }, /max_tokens_budget must be/],
];
for (const [overrides, pattern] of cases) {
const fixture = makeFixture();
try {
const result = runStep(validation, inputValues(overrides), fixture);
assert.notStrictEqual(
result.status,
0,
`inputs ${JSON.stringify(overrides)} should be rejected; ${resultDescription(result)}`
);
assert.match(
`${result.stdout}\n${result.stderr}`,
pattern,
`rejection for ${JSON.stringify(overrides)} must name the offending input; ${resultDescription(result)}`
);
} finally {
removeFixture(fixture);
}
}
}
function testEffortAndBudgetNormalizeAndForwardAcrossSteps() {
const validation = validationStep();
const run = stepNamed("Run OpenCodeReview");
assert.ok(validation, "action.yml must retain input validation");
assert.ok(run, "action.yml must retain the Run OpenCodeReview step");
const fixture = makeFixture();
try {
const values = inputValues({
effort: "HIGH",
max_tokens_budget: "010000",
llm_url: "https://llm.example.invalid/v1",
llm_auth_token: "unused-token",
llm_model: "contract-model",
llm_use_anthropic: "false",
});
const validationResult = runStep(validation, values, fixture);
assert.strictEqual(validationResult.status, 0, `validation failed; ${resultDescription(validationResult)}`);
const exported = readEnvAssignments(path.join(fixture.dir, "github-env"));
assert.strictEqual(exported.EFFORT, "high", "validation must export lowercase effort");
assert.strictEqual(
exported.MAX_TOKENS_BUDGET,
"10000",
"validation must export normalized decimal max_tokens_budget"
);
const result = runStep(
run,
values,
fixture,
{
MERGE_BASE: "base-sha",
HEAD_SHA: "head-sha",
REVIEW_TASK_TIMEOUT: exported.REVIEW_TASK_TIMEOUT,
EFFORT: exported.EFFORT,
MAX_TOKENS_BUDGET: exported.MAX_TOKENS_BUDGET,
},
{ replaceResultPaths: true }
);
assert.strictEqual(result.status, 0, `Run OpenCodeReview shell block failed; ${resultDescription(result)}`);
const reviewCall = readJsonLines(fixture.callsPath).find((call) => call.args[0] === "review");
assert.ok(reviewCall, "Run OpenCodeReview must invoke `ocr review`");
const effortIndex = reviewCall.args.indexOf("--effort");
assert.ok(effortIndex >= 0, "Run OpenCodeReview must forward --effort");
assert.strictEqual(reviewCall.args[effortIndex + 1], "high");
const budgetIndex = reviewCall.args.indexOf("--max-tokens-budget");
assert.ok(budgetIndex >= 0, "Run OpenCodeReview must forward --max-tokens-budget");
assert.strictEqual(reviewCall.args[budgetIndex + 1], "10000");
} finally {
removeFixture(fixture);
}
}
function testZeroMaxTokensBudgetNormalizesToUnlimited() {
const validation = validationStep();
assert.ok(validation, "action.yml must retain input validation");
const fixture = makeFixture();
try {
const result = runStep(validation, inputValues({ max_tokens_budget: "0" }), fixture);
assert.strictEqual(result.status, 0, `validation failed; ${resultDescription(result)}`);
const exported = readEnvAssignments(path.join(fixture.dir, "github-env"));
assert.strictEqual(exported.MAX_TOKENS_BUDGET, "", "max_tokens_budget=0 must normalize to unlimited (empty)");
} finally {
removeFixture(fixture);
}
}
function testEmptyEffortAndBudgetOmitTheFlags() {
const run = stepNamed("Run OpenCodeReview");
assert.ok(run, "action.yml must retain the Run OpenCodeReview step");
const fixture = makeFixture();
try {
const values = inputValues({
llm_url: "https://llm.example.invalid/v1",
llm_auth_token: "unused-token",
llm_model: "contract-model",
llm_use_anthropic: "false",
});
const result = runStep(
run,
values,
fixture,
{
MERGE_BASE: "base-sha",
HEAD_SHA: "head-sha",
REVIEW_TASK_TIMEOUT: "15",
EFFORT: "",
MAX_TOKENS_BUDGET: "",
},
{ replaceResultPaths: true }
);
assert.strictEqual(result.status, 0, `Run OpenCodeReview shell block failed; ${resultDescription(result)}`);
const reviewCall = readJsonLines(fixture.callsPath).find((call) => call.args[0] === "review");
assert.ok(reviewCall, "Run OpenCodeReview must invoke `ocr review`");
assert.ok(!reviewCall.args.includes("--effort"), "empty effort must omit --effort so the CLI default applies");
assert.ok(
!reviewCall.args.includes("--max-tokens-budget"),
"empty max_tokens_budget must omit --max-tokens-budget so the CLI default applies"
);
} finally {
removeFixture(fixture);
}
}
function testValidateInputsRejectsInvalidReasoningEffort() {
const validation = validationStep();
assert.ok(validation, "action.yml must retain input validation");
for (const value of ["extreme", "1", "reasoning"]) {
const fixture = makeFixture();
try {
const result = runStep(validation, inputValues({ llm_reasoning_effort: value }), fixture);
assert.notStrictEqual(
result.status,
0,
`llm_reasoning_effort=${JSON.stringify(value)} should be rejected; ${resultDescription(result)}`
);
assert.match(
`${result.stdout}\n${result.stderr}`,
/llm_reasoning_effort must be one of/,
`rejection for llm_reasoning_effort=${JSON.stringify(value)} must name the input; ${resultDescription(result)}`
);
} finally {
removeFixture(fixture);
}
}
const fixture = makeFixture();
try {
const result = runStep(validation, inputValues({ llm_reasoning_effort: "MAX" }), fixture);
assert.strictEqual(result.status, 0, `llm_reasoning_effort=MAX should be accepted; ${resultDescription(result)}`);
const exported = readEnvAssignments(path.join(fixture.dir, "github-env"));
assert.strictEqual(exported.LLM_REASONING_EFFORT, "max", "validation must export lowercase llm_reasoning_effort");
} finally {
removeFixture(fixture);
}
}
function testStreamProgressInputDefaultsToFalse() {
assert.ok(INPUTS.stream_progress, "action.yml must define the stream_progress input");
assert.strictEqual(
INPUTS.stream_progress.default,
"false",
"stream_progress must default to 'false' so live progress stays opt-in"
);
const inputBlock = ACTION_TEXT.match(
/^ stream_progress:\s*$([\s\S]*?)(?=^ [A-Za-z0-9_]+:\s*$|^outputs:|^runs:)/m
);
assert.ok(inputBlock, "action.yml must expose stream_progress metadata");
assert.match(inputBlock[1], /\[ocr\].*progress|progress.*\[ocr\]/i, "stream_progress must describe the [ocr] progress lines");
}
function testValidateInputsValidatesStreamProgress() {
const validation = validationStep();
assert.ok(validation, "action.yml must retain input validation");
for (const value of ["yes", "1", "on"]) {
const fixture = makeFixture();
try {
const result = runStep(validation, inputValues({ stream_progress: value }), fixture);
assert.notStrictEqual(
result.status,
0,
`stream_progress=${JSON.stringify(value)} should be rejected; ${resultDescription(result)}`
);
assert.match(
`${result.stdout}\n${result.stderr}`,
/stream_progress must be one of/,
`rejection for stream_progress=${JSON.stringify(value)} must name the input; ${resultDescription(result)}`
);
} finally {
removeFixture(fixture);
}
}
const acceptance = [
["TRUE", "true"],
["", "false"],
];
for (const [value, expected] of acceptance) {
const fixture = makeFixture();
try {
const result = runStep(validation, inputValues({ stream_progress: value }), fixture);
assert.strictEqual(result.status, 0, `stream_progress=${JSON.stringify(value)} should be accepted; ${resultDescription(result)}`);
const exported = readEnvAssignments(path.join(fixture.dir, "github-env"));
assert.strictEqual(
exported.STREAM_PROGRESS,
expected,
`validation must export ${JSON.stringify(expected)} for stream_progress=${JSON.stringify(value)}`
);
} finally {
removeFixture(fixture);
}
}
}
function testValidateInputsValidatesResolveOutdated() {
const validation = validationStep();
assert.ok(validation, "action.yml must retain input validation");
for (const value of ["yes", "1", "on", "resolve"]) {
const fixture = makeFixture();
try {
const result = runStep(validation, inputValues({ resolve_outdated: value }), fixture);
assert.notStrictEqual(
result.status,
0,
`resolve_outdated=${JSON.stringify(value)} should be rejected; ${resultDescription(result)}`
);
assert.match(
`${result.stdout}\n${result.stderr}`,
/resolve_outdated must be one of/,
`rejection for resolve_outdated=${JSON.stringify(value)} must name the input; ${resultDescription(result)}`
);
} finally {
removeFixture(fixture);
}
}
const acceptance = [
["TRUE", "true"],
["Report", "report"],
["", "false"],
["false", "false"],
];
for (const [value, expected] of acceptance) {
const fixture = makeFixture();
try {
const result = runStep(validation, inputValues({ resolve_outdated: value }), fixture);
assert.strictEqual(result.status, 0, `resolve_outdated=${JSON.stringify(value)} should be accepted; ${resultDescription(result)}`);
const exported = readEnvAssignments(path.join(fixture.dir, "github-env"));
assert.strictEqual(
exported.RESOLVE_OUTDATED,
expected,
`validation must export ${JSON.stringify(expected)} for resolve_outdated=${JSON.stringify(value)}`
);
} finally {
removeFixture(fixture);
}
}
}
function testRunKeepsAgentAudienceAndLogFileByDefault() {
const run = stepNamed("Run OpenCodeReview");
assert.ok(run, "action.yml must retain the Run OpenCodeReview step");
assert.match(
run.run,
/STREAM_PROGRESS:-false\}" = "true" \]; then\s+OCR_STDERR_FIFO="\$\(mktemp -u\)"\s+mkfifo "\$OCR_STDERR_FIFO" \|\| exit 1\s+tee \/tmp\/ocr-stderr\.log < "\$OCR_STDERR_FIFO" >&2 &\s+TEE_PID=\$!\s+ocr review "\$\{ARGS\[@\]\}" > \/tmp\/ocr-result\.json 2> "\$OCR_STDERR_FIFO"\s+OCR_EXIT_CODE=\$\?\s+wait "\$TEE_PID"\s+rm -f "\$OCR_STDERR_FIFO"\s+else\s+ocr review "\$\{ARGS\[@\]\}" > \/tmp\/ocr-result\.json 2>\/tmp\/ocr-stderr\.log/,
"the live-tee path must be gated on stream_progress, flush the FIFO-fed tee via wait, and the default path must redirect stderr to the log file"
);
const fixture = makeFixture();
try {
const values = inputValues({
llm_url: "https://llm.example.invalid/v1",
llm_auth_token: "unused-token",
llm_model: "contract-model",
llm_use_anthropic: "false",
});
const result = runStep(
run,
values,
fixture,
{ MERGE_BASE: "base-sha", HEAD_SHA: "head-sha", REVIEW_TASK_TIMEOUT: "15" },
{ replaceResultPaths: true }
);
assert.strictEqual(result.status, 0, `Run OpenCodeReview shell block failed; ${resultDescription(result)}`);
const reviewCall = readJsonLines(fixture.callsPath).find((call) => call.args[0] === "review");
assert.ok(reviewCall, "Run OpenCodeReview must invoke `ocr review`");
const audienceIndex = reviewCall.args.indexOf("--audience");
assert.ok(audienceIndex >= 0, "the default path must keep --audience agent");
assert.strictEqual(reviewCall.args[audienceIndex + 1], "agent");
assert.ok(fs.existsSync(fixture.stderrPath), "the default path must still capture stderr to the log file");
} finally {
removeFixture(fixture);
}
}
function testRunStreamsProgressWhenOptedIn() {
const run = stepNamed("Run OpenCodeReview");
assert.ok(run, "action.yml must retain the Run OpenCodeReview step");
const fixture = makeFixture();
try {
const values = inputValues({
llm_url: "https://llm.example.invalid/v1",
llm_auth_token: "unused-token",
llm_model: "contract-model",
llm_use_anthropic: "false",
});
const result = runStep(
run,
values,
fixture,
{ MERGE_BASE: "base-sha", HEAD_SHA: "head-sha", REVIEW_TASK_TIMEOUT: "15", STREAM_PROGRESS: "true" },
{ replaceResultPaths: true }
);
assert.strictEqual(result.status, 0, `Run OpenCodeReview shell block failed; ${resultDescription(result)}`);
const reviewCall = readJsonLines(fixture.callsPath).find((call) => call.args[0] === "review");
assert.ok(reviewCall, "Run OpenCodeReview must invoke `ocr review`");
assert.ok(!reviewCall.args.includes("--audience"), "stream_progress=true must drop --audience agent");
assert.ok(fs.existsSync(fixture.stderrPath), "the streaming path must still capture stderr to the log file");
} finally {
removeFixture(fixture);
}
}
function testLlmExtraBodyDefaultDisablesThinking() {
assert.ok(INPUTS.llm_extra_body, "action.yml must define the llm_extra_body input");
assert.strictEqual(
INPUTS.llm_extra_body.default,
'{"thinking": {"type": "disabled"}}',
"llm_extra_body default must disable thinking mode; enabling it must be an explicit opt-in"
);
}
function testConfigureMergesReasoningEffortIntoExtraBody() {
const configure = stepNamed("Configure OCR");
assert.ok(configure, "action.yml must retain the Configure OCR step");
const baseValues = {
llm_url: "https://llm.example.invalid/v1",
llm_model: "contract-model",
llm_use_anthropic: "false",
llm_auth_token: "unused-token",
};
const cases = [
[
"the default body disables thinking when nothing is set",
{},
{},
{ thinking: { type: "disabled" } },
],
[
"the effort merges into the default body",
{},
{ LLM_REASONING_EFFORT: "low" },
{ thinking: { type: "disabled" }, reasoning_effort: "low" },
],
[
"an explicitly empty extra_body still receives the effort",
{ llm_extra_body: "" },
{ LLM_REASONING_EFFORT: "low" },
{ reasoning_effort: "low" },
],
[
"an explicit extra_body key wins over the input",
{ llm_extra_body: '{"reasoning_effort":"high"}' },
{ LLM_REASONING_EFFORT: "low" },
{ reasoning_effort: "high" },
],
[
"merges alongside existing extra_body keys",
{ llm_extra_body: '{"thinking":{"type":"enabled"}}' },
{ LLM_REASONING_EFFORT: "max" },
{ thinking: { type: "enabled" }, reasoning_effort: "max" },
],
];
for (const [label, overrides, extraEnv, expected] of cases) {
const fixture = makeFixture();
try {
const values = inputValues(Object.assign({}, baseValues, overrides));
const result = runStep(configure, values, fixture, extraEnv);
assert.strictEqual(result.status, 0, `Configure OCR failed for "${label}"; ${resultDescription(result)}`);
const configured = configValues(readJsonLines(fixture.configPath));
const body =
typeof configured["llm.extra_body"] === "string"
? JSON.parse(configured["llm.extra_body"])
: configured["llm.extra_body"];
assert.deepStrictEqual(body, expected, `extra_body mismatch for "${label}"`);
} finally {
removeFixture(fixture);
}
}
}
function testConfigureRejectsReasoningEffortOnAnthropic() {
const configure = stepNamed("Configure OCR");
assert.ok(configure, "action.yml must retain the Configure OCR step");
const fixture = makeFixture();
try {
const values = inputValues({
llm_url: "https://llm.example.invalid/v1",
llm_model: "contract-model",
llm_use_anthropic: "true",
llm_auth_token: "unused-token",
});
const result = runStep(configure, values, fixture, { LLM_REASONING_EFFORT: "low" });
assert.notStrictEqual(result.status, 0, "Configure OCR must reject llm_reasoning_effort on the anthropic protocol");
assert.match(
`${result.stdout}\n${result.stderr}`,
/::error::llm_reasoning_effort is supported only with OpenAI-compatible protocols/,
`the failure must name the llm_reasoning_effort input; ${resultDescription(result)}`
);
} finally {
removeFixture(fixture);
}
}
function testConfigureRejectsMalformedExtraBodyWithActionableError() {
const configure = stepNamed("Configure OCR");
assert.ok(configure, "action.yml must retain the Configure OCR step");
const fixture = makeFixture();
try {
const values = inputValues({
llm_url: "https://llm.example.invalid/v1",
llm_model: "contract-model",
llm_use_anthropic: "false",
llm_auth_token: "unused-token",
llm_extra_body: "{not json",
});
const result = runStep(configure, values, fixture, { LLM_REASONING_EFFORT: "low" });
assert.notStrictEqual(result.status, 0, "Configure OCR must fail on malformed llm_extra_body");
assert.match(
`${result.stdout}\n${result.stderr}`,
/::error::llm_extra_body is not valid JSON/,
`the failure must name the llm_extra_body input; ${resultDescription(result)}`
);
} finally {
removeFixture(fixture);
}
}
function testConfigureRejectsNonObjectExtraBody() {
const configure = stepNamed("Configure OCR");
assert.ok(configure, "action.yml must retain the Configure OCR step");
for (const extraBody of ["null", "[]", "5", '"text"']) {
const fixture = makeFixture();
try {
const values = inputValues({
llm_url: "https://llm.example.invalid/v1",
llm_model: "contract-model",
llm_use_anthropic: "false",
llm_auth_token: "unused-token",
llm_extra_body: extraBody,
});
const result = runStep(configure, values, fixture, { LLM_REASONING_EFFORT: "low" });
assert.notStrictEqual(
result.status,
0,
`Configure OCR must fail on non-object llm_extra_body=${extraBody}; ${resultDescription(result)}`
);
assert.match(
`${result.stdout}\n${result.stderr}`,
/::error::llm_extra_body must be a JSON object/,
`the failure must name the llm_extra_body input for ${extraBody}; ${resultDescription(result)}`
);
} finally {
removeFixture(fixture);
}
}
}
function testConfigureBuildsCompleteLlmConfig() {
const configure = stepNamed("Configure OCR");
assert.ok(configure, "action.yml must retain the Configure OCR step");
const fixture = makeFixture();
try {
const extraBody = '{"thinking":{"type":"disabled"},"contract":"yes"}';
const values = inputValues({
llm_url: "https://llm.example.invalid/v1",
llm_model: "contract-model",
llm_use_anthropic: "false",
llm_auth_token: "configure-token-sentinel",
llm_extra_body: extraBody,
language: "English",
});
const result = runStep(configure, values, fixture, { OCR_LLM_TOKEN: values.llm_auth_token });
assert.strictEqual(result.status, 0, `Configure OCR shell block failed; ${resultDescription(result)}`);
const configured = configValues(readJsonLines(fixture.configPath));
assert.strictEqual(configured["llm.url"], values.llm_url);
assert.strictEqual(configured["llm.model"], values.llm_model);
assert.strictEqual(configured["llm.use_anthropic"], values.llm_use_anthropic);
assert.strictEqual(typeof configured["llm.auth_token_cmd"], "string", "llm.auth_token_cmd must be configured");
assert.match(configured["llm.auth_token_cmd"], /OCR_LLM_TOKEN/, "auth_token_cmd must reference the token env var");
const configuredExtraBody =
typeof configured["llm.extra_body"] === "string"
? JSON.parse(configured["llm.extra_body"])
: configured["llm.extra_body"];
assert.deepStrictEqual(configuredExtraBody, JSON.parse(extraBody));
} finally {
removeFixture(fixture);
}
}
function testConfigureNeverPersistsToken() {
const configure = stepNamed("Configure OCR");
assert.ok(configure, "action.yml must retain the Configure OCR step");
const token = "configure-token-sentinel-DO-NOT-PERSIST";
const fixture = makeFixture();
try {
const values = inputValues({
llm_url: "https://llm.example.invalid/v1",
llm_model: "contract-model",
llm_use_anthropic: "false",
llm_auth_token: token,
llm_extra_body: '{"thinking":{"type":"disabled"}}',
});
const result = runStep(configure, values, fixture, { OCR_LLM_TOKEN: token });
assert.strictEqual(result.status, 0, `Configure OCR shell block failed; ${resultDescription(result)}`);
const outputAndFiles = [result.stdout, result.stderr];
for (const file of allFixtureFiles(fixture.dir)) outputAndFiles.push(fs.readFileSync(file, "utf8"));
const leaked = outputAndFiles.join("\n");
assert.doesNotMatch(leaked, escapedRegExp(token), "the token must not appear in config output or files");
const configured = configValues(readJsonLines(fixture.configPath));
assert.ok(configured["llm.auth_token_cmd"], "only an auth_token_cmd should be stored for the token");
assert.match(configured["llm.auth_token_cmd"], /OCR_LLM_TOKEN/);
assert.doesNotMatch(configured["llm.auth_token_cmd"], escapedRegExp(token));
} finally {
removeFixture(fixture);
}
}
function testConfigureNeutralizesStaleProviderAndStaticToken() {
const configure = stepNamed("Configure OCR");
assert.ok(configure, "action.yml must retain the Configure OCR step");
const fixture = makeFixture();
try {
const values = inputValues({
llm_url: "https://llm.example.invalid/v1",
llm_model: "contract-model",
llm_use_anthropic: "false",
llm_auth_token: "config-token-sentinel",
llm_auth_header: "x-api-key",
llm_extra_body: '{"thinking":{"type":"disabled"}}',
});
const result = runStep(configure, values, fixture);
assert.strictEqual(result.status, 0, `Configure OCR shell block failed; ${resultDescription(result)}`);
const operations = configOperations(fixture);
const unsetProviderIndex = operations.findIndex(
(args) => args[0] === "config" && args[1] === "unset" && args[2] === "provider"
);
const clearStaticTokenIndex = operations.findIndex(
(args) => args[0] === "config" && args[1] === "set" && args[2] === "llm.auth_token" && args[3] === ""
);
const setTokenCmdIndex = operations.findIndex(
(args) => args[0] === "config" && args[1] === "set" && args[2] === "llm.auth_token_cmd"
);
const firstLegacySetIndex = operations.findIndex(
(args) => args[0] === "config" && args[1] === "set" && String(args[2]).startsWith("llm.")
);
assert.ok(unsetProviderIndex >= 0, "Configure OCR must unset a stale active provider");
assert.ok(clearStaticTokenIndex >= 0, "Configure OCR must clear stale static llm.auth_token");
assert.ok(setTokenCmdIndex >= 0, "Configure OCR must configure auth_token_cmd");
assert.ok(unsetProviderIndex < firstLegacySetIndex, "provider must be unset before legacy llm config is built");
assert.ok(clearStaticTokenIndex < setTokenCmdIndex, "static token must be cleared before auth_token_cmd is set");
const configured = configValues(operations);
assert.strictEqual(configured["llm.auth_header"], "x-api-key", "custom auth header must be stored in llm config");
assert.strictEqual(configured["llm.protocol"], "openai", "false llm_use_anthropic must set the OpenAI protocol");
} finally {
removeFixture(fixture);
}
}
function testConfigureProtocolTracksUseAnthropic() {
const configure = stepNamed("Configure OCR");
assert.ok(configure, "action.yml must retain the Configure OCR step");
for (const [useAnthropic, expectedProtocol] of [["true", "anthropic"], ["false", "openai"]]) {
const fixture = makeFixture();
try {
const values = inputValues({
llm_url: "https://llm.example.invalid/v1",
llm_model: "contract-model",
llm_use_anthropic: useAnthropic,
llm_auth_token: "protocol-token-sentinel",
});
const result = runStep(configure, values, fixture);
assert.strictEqual(result.status, 0, `Configure OCR failed for llm_use_anthropic=${useAnthropic}; ${resultDescription(result)}`);
const configured = configValues(configOperations(fixture));
assert.strictEqual(configured["llm.use_anthropic"], useAnthropic);
assert.strictEqual(configured["llm.protocol"], expectedProtocol);
} finally {
removeFixture(fixture);
}
}
}
function testConfigureHonoursExplicitProtocol() {
const configure = stepNamed("Configure OCR");
assert.ok(configure, "action.yml must retain the Configure OCR step");
const base = {
llm_url: "https://llm.example.invalid/v1",
llm_model: "contract-model",
llm_auth_token: "protocol-token-sentinel",
};
// The explicit protocol wins, and use_anthropic follows it — the boolean
// alone is what forced every Responses API user onto chat/completions.
const cases = [
{ input: "openai-responses", useAnthropic: "false", expectAnthropic: "false", expectProtocol: "openai-responses" },
{ input: "openai-responses", useAnthropic: "true", expectAnthropic: "false", expectProtocol: "openai-responses" },
{ input: "Anthropic", useAnthropic: "false", expectAnthropic: "true", expectProtocol: "anthropic" },
{ input: "openai", useAnthropic: "true", expectAnthropic: "false", expectProtocol: "openai" },
];
for (const testCase of cases) {
const fixture = makeFixture();
try {
const values = inputValues(Object.assign({}, base, {
llm_use_anthropic: testCase.useAnthropic,
llm_protocol: testCase.input,
}));
const result = runStep(configure, values, fixture);
assert.strictEqual(
result.status,
0,
`Configure OCR failed for llm_protocol=${testCase.input}; ${resultDescription(result)}`
);
const configured = configValues(configOperations(fixture));
assert.strictEqual(configured["llm.protocol"], testCase.expectProtocol);
assert.strictEqual(configured["llm.use_anthropic"], testCase.expectAnthropic);
assert.strictEqual(
readEnvAssignments(path.join(fixture.dir, "github-env")).OCR_LLM_PROTOCOL_EXPLICIT,
testCase.expectProtocol,
"the normalized explicit protocol must be exported for the checkpoint fingerprint"
);
} finally {
removeFixture(fixture);
}
}
// OCR_LLM_PROTOCOL from the job environment is honoured when the input is
// empty: it is the variable the CLI itself reads, and the one the report
// set without effect.
const inherited = makeFixture();
try {
const values = inputValues(Object.assign({}, base, { llm_use_anthropic: "false" }));
const result = runStep(configure, values, inherited, { OCR_LLM_PROTOCOL: "openai-responses" });
assert.strictEqual(result.status, 0, `Configure OCR failed with inherited OCR_LLM_PROTOCOL; ${resultDescription(result)}`);
const configured = configValues(configOperations(inherited));
assert.strictEqual(configured["llm.protocol"], "openai-responses");
assert.strictEqual(configured["llm.use_anthropic"], "false");
assert.strictEqual(
readEnvAssignments(path.join(inherited.dir, "github-env")).OCR_LLM_PROTOCOL_EXPLICIT,
"openai-responses",
"a protocol switched through the environment alone must reach the checkpoint fingerprint"
);
} finally {
removeFixture(inherited);
}
// With neither source set the export is empty, so the fingerprint stays
// exactly what it was before the input existed.
const neither = makeFixture();
try {
const values = inputValues(Object.assign({}, base, { llm_use_anthropic: "false" }));
const result = runStep(configure, values, neither);
assert.strictEqual(result.status, 0, `Configure OCR failed without a protocol source; ${resultDescription(result)}`);
assert.strictEqual(readEnvAssignments(path.join(neither.dir, "github-env")).OCR_LLM_PROTOCOL_EXPLICIT, "");
} finally {
removeFixture(neither);
}
// The resolve step fingerprints that export, not the raw input.
const resolve = STEPS.find((step) => step.name === "Resolve review range");
assert.ok(resolve, "action.yml must retain the Resolve review range step");
assert.strictEqual(
resolve.env.OCR_FP_LLM_PROTOCOL,
"${{ env.OCR_LLM_PROTOCOL_EXPLICIT }}",
"the checkpoint fingerprint must read the protocol Configure OCR resolved"
);
// The input outranks the inherited variable.
const both = makeFixture();
try {
const values = inputValues(Object.assign({}, base, { llm_use_anthropic: "false", llm_protocol: "anthropic" }));
const result = runStep(configure, values, both, { OCR_LLM_PROTOCOL: "openai-responses" });
assert.strictEqual(result.status, 0, `Configure OCR failed with both protocol sources; ${resultDescription(result)}`);
assert.strictEqual(configValues(configOperations(both))["llm.protocol"], "anthropic");
} finally {
removeFixture(both);
}
// An unknown protocol fails the step rather than silently configuring one.
const unknown = makeFixture();
try {
const values = inputValues(Object.assign({}, base, { llm_use_anthropic: "false", llm_protocol: "grpc" }));
const result = runStep(configure, values, unknown);
assert.notStrictEqual(result.status, 0, "an unknown llm_protocol must fail the Configure OCR step");
assert.match(`${result.stdout}${result.stderr}`, /llm_protocol must be/, "the failure must name the accepted protocols");
} finally {
removeFixture(unknown);
}
}
function testConfigurePreservesLegacyUseAnthropicResolution() {
const configure = stepNamed("Configure OCR");
assert.ok(configure, "action.yml must retain the Configure OCR step");
const inputBlock = ACTION_TEXT.match(
/^ llm_use_anthropic:\s*$([\s\S]*?)(?=^ [A-Za-z0-9_]+:\s*$|^outputs:|^runs:)/m
);
assert.ok(inputBlock, "action.yml must expose llm_use_anthropic metadata");
assert.match(
inputBlock[1],
/explicitly\s+supplied\s+empty\s+string/i,
"llm_use_anthropic docs must distinguish an explicit empty value from omitting the required input"
);
const cases = [
["true", "true", "anthropic"],
["TRUE", "true", "anthropic"],
["1", "true", "anthropic"],
["yes", "true", "anthropic"],
["YeS", "true", "anthropic"],
["", "true", "anthropic"],
["false", "false", "openai"],
["FALSE", "false", "openai"],
["0", "false", "openai"],
["no", "false", "openai"],
["unexpected", "false", "openai"],
];
for (const [value, expectedBoolean, expectedProtocol] of cases) {
const fixture = makeFixture();
try {
const values = inputValues({
llm_url: "https://llm.example.invalid/v1",
llm_model: "contract-model",
llm_use_anthropic: value,
llm_auth_token: "legacy-boolean-token-sentinel",
});
const result = runStep(configure, values, fixture);
assert.strictEqual(
result.status,
0,
`legacy llm_use_anthropic=${JSON.stringify(value)} must remain accepted; ${resultDescription(result)}`
);
const configured = configValues(configOperations(fixture));
assert.strictEqual(configured["llm.use_anthropic"], expectedBoolean);
assert.strictEqual(configured["llm.protocol"], expectedProtocol);
} finally {
removeFixture(fixture);
}
}
}
function testConfigureClearsStaleExtraHeadersBeforeTokenCommand() {
const configure = stepNamed("Configure OCR");
assert.ok(configure, "action.yml must retain the Configure OCR step");
const fixture = makeFixture();
try {
const values = inputValues({
llm_url: "https://llm.example.invalid/v1",
llm_model: "contract-model",
llm_use_anthropic: "false",
llm_auth_token: "extra-header-token-sentinel",
llm_extra_headers: "X-Request-ID=contract-value",
});
const result = runStep(configure, values, fixture);
assert.strictEqual(result.status, 0, `Configure OCR shell block failed; ${resultDescription(result)}`);
const operations = configOperations(fixture);
const unsetProviderIndex = operations.findIndex(
(args) => args[0] === "config" && args[1] === "unset" && args[2] === "provider"
);
const clearExtraHeadersIndex = operations.findIndex(
(args) => args[0] === "config" && args[1] === "set" && args[2] === "llm.extra_headers" && args[3] === ""
);
const setTokenCmdIndex = operations.findIndex(
(args) => args[0] === "config" && args[1] === "set" && args[2] === "llm.auth_token_cmd"
);
assert.ok(clearExtraHeadersIndex >= 0, "Configure OCR must clear stale persisted llm.extra_headers");
assert.ok(unsetProviderIndex < clearExtraHeadersIndex, "provider must be unset before legacy extra headers are cleared");
assert.ok(clearExtraHeadersIndex < setTokenCmdIndex, "stale extra headers must be cleared before auth_token_cmd is set");
assert.strictEqual(configValues(operations)["llm.extra_headers"], "");
} finally {
removeFixture(fixture);
}
}
function testConfigureClearsStaleRetryCodesBeforeEndpointConfig() {
const configure = stepNamed("Configure OCR");
assert.ok(configure, "action.yml must retain the Configure OCR step");
const fixture = makeFixture();
try {
const values = inputValues({
llm_url: "https://llm.example.invalid/v1",
llm_model: "contract-model",
llm_use_anthropic: "false",
llm_auth_token: "retry-code-token-sentinel",
});
const result = runStep(configure, values, fixture);
assert.strictEqual(result.status, 0, `Configure OCR shell block failed; ${resultDescription(result)}`);
const operations = configOperations(fixture);
const unsetProviderIndex = operations.findIndex(
(args) => args[0] === "config" && args[1] === "unset" && args[2] === "provider"
);
const clearRetryCodesIndex = operations.findIndex(
(args) => args[0] === "config" && args[1] === "set" && args[2] === "llm.retry_codes" && args[3] === ""
);
const setUrlIndex = operations.findIndex(
(args) => args[0] === "config" && args[1] === "set" && args[2] === "llm.url"
);
assert.ok(clearRetryCodesIndex >= 0, "Configure OCR must clear stale persisted llm.retry_codes");
assert.ok(unsetProviderIndex < clearRetryCodesIndex, "provider must be unset before retry codes are cleared");
assert.ok(clearRetryCodesIndex < setUrlIndex, "stale retry codes must be cleared before endpoint config is built");
assert.strictEqual(configValues(operations)["llm.retry_codes"], "");
} finally {
removeFixture(fixture);
}
}
function testRunRetainsExtraHeadersEnvironmentOverride() {
const run = stepNamed("Run OpenCodeReview");
assert.ok(run, "action.yml must retain the Run OpenCodeReview step");
const fixture = makeFixture();
try {
const values = inputValues({
review_task_timeout: "10",
llm_url: "https://llm.example.invalid/v1",
llm_auth_token: "unused-token",
llm_model: "contract-model",
llm_use_anthropic: "false",
llm_extra_headers: "X-Request-ID=contract-value",
});
const result = runStep(
run,
values,
fixture,
{ MERGE_BASE: "base-sha", HEAD_SHA: "head-sha", REVIEW_TASK_TIMEOUT: values.review_task_timeout },
{ replaceResultPaths: true }
);
assert.strictEqual(result.status, 0, `Run OpenCodeReview shell block failed; ${resultDescription(result)}`);
const reviewCall = readJsonLines(fixture.callsPath).find((call) => call.args[0] === "review");
assert.ok(reviewCall, "Run OpenCodeReview must invoke `ocr review`");
assert.strictEqual(reviewCall.env.OCR_LLM_EXTRA_HEADERS, values.llm_extra_headers);
} finally {
removeFixture(fixture);
}
}
function testRunFailsClosedWhenValidatedTaskTimeoutIsMissing() {
const run = stepNamed("Run OpenCodeReview");
assert.ok(run, "action.yml must retain the Run OpenCodeReview step");
const fixture = makeFixture();
try {
const values = inputValues({
llm_url: "https://llm.example.invalid/v1",
llm_auth_token: "unused-token",
llm_model: "contract-model",
llm_use_anthropic: "false",
});
const result = runStep(
run,
values,
fixture,
{ MERGE_BASE: "base-sha", HEAD_SHA: "head-sha", REVIEW_TASK_TIMEOUT: "" },
{ replaceResultPaths: true }
);
assert.notStrictEqual(result.status, 0, "Run OpenCodeReview must fail when validated timeout state is missing");
assert.match(
`${result.stdout}\n${result.stderr}`,
/validated review_task_timeout is missing/i,
"missing timeout failure must identify the validation boundary"
);
assert.ok(
!readJsonLines(fixture.callsPath).some((call) => call.args[0] === "review"),
"Run OpenCodeReview must fail before invoking OCR"
);
} finally {
removeFixture(fixture);
}
}
function testOfficialNpmPackageInstallIsPreserved() {
const install = installStep();
assert.ok(install, "action.yml must retain the Install OpenCodeReview step");
const fixture = makeFixture();
try {
const values = inputValues({ ocr_version: "1.9.10" });
const result = runStep(install, values, fixture);
assert.strictEqual(result.status, 0, `Install OpenCodeReview shell block failed; ${resultDescription(result)}`);
const npmCall = readJsonLines(fixture.npmCallsPath).find((args) => args[0] === "install");
assert.ok(npmCall, "Install OpenCodeReview must invoke npm install");
assert.deepStrictEqual(npmCall.slice(0, 2), ["install", "-g"]);
assert.strictEqual(npmCall[2], "@alibaba-group/open-code-review@1.9.10");
} finally {
removeFixture(fixture);
}
}
function testInstallRejectsStreamProgressBelowV198() {
const install = installStep();
assert.ok(install, "action.yml must retain the Install OpenCodeReview step");
const cases = [
{ output: "open-code-review 1.9.7 linux/amd64", streamProgress: "true", valid: false },
{ output: "open-code-review 1.9.8 linux/amd64", streamProgress: "true", valid: true },
{ output: "open-code-review v1.10.0 linux/amd64", streamProgress: "true", valid: true },
{ output: "open-code-review 1.9.7 linux/amd64", streamProgress: "false", valid: true },
];
for (const testCase of cases) {
const fixture = makeFixture();
try {
const result = runStep(install, inputValues({ ocr_version: "contract-test" }), fixture, {
OCR_FAKE_VERSION_OUTPUT: testCase.output,
STREAM_PROGRESS: testCase.streamProgress,
});
const message = `version ${JSON.stringify(testCase.output)} with stream_progress=${JSON.stringify(testCase.streamProgress)}; ${resultDescription(result)}`;
if (testCase.valid) {
assert.strictEqual(result.status, 0, `should pass: ${message}`);
} else {
assert.notStrictEqual(result.status, 0, `should fail: ${message}`);
assert.match(
`${result.stdout}\n${result.stderr}`,
/::error::The stream_progress input requires OpenCodeReview v1\.9\.8 or newer/,
`the failure must name the stream_progress input and the version floor; ${message}`
);
}
} finally {
removeFixture(fixture);
}
}
}
function testInstallRejectsEffortBelowV1100() {
const install = installStep();
assert.ok(install, "action.yml must retain the Install OpenCodeReview step");
const cases = [
{ output: "open-code-review 1.9.10 linux/amd64", effort: "low", valid: false },
{ output: "open-code-review 1.10.0 linux/amd64", effort: "low", valid: true },
{ output: "open-code-review v2.0.0 linux/amd64", effort: "high", valid: true },
{ output: "open-code-review 1.9.10 linux/amd64", effort: "", valid: true },
];
for (const testCase of cases) {
const fixture = makeFixture();
try {
const result = runStep(install, inputValues({ ocr_version: "contract-test" }), fixture, {
OCR_FAKE_VERSION_OUTPUT: testCase.output,
EFFORT: testCase.effort,
});
const message = `version ${JSON.stringify(testCase.output)} with effort=${JSON.stringify(testCase.effort)}; ${resultDescription(result)}`;
if (testCase.valid) {
assert.strictEqual(result.status, 0, `should pass: ${message}`);
} else {
assert.notStrictEqual(result.status, 0, `should fail: ${message}`);
assert.match(
`${result.stdout}\n${result.stderr}`,
/::error::The effort input requires OpenCodeReview v1\.10\.0 or newer/,
`the failure must name the effort input and the version floor; ${message}`
);
}
} finally {
removeFixture(fixture);
}
}
}
function testInstallEnforcesAuthTokenCommandVersionFloor() {
const install = installStep();
assert.ok(install, "action.yml must retain the Install OpenCodeReview step");
const inputBlock = ACTION_TEXT.match(
/^ ocr_version:\s*$([\s\S]*?)(?=^ [A-Za-z0-9_]+:\s*$|^outputs:|^runs:)/m
);
assert.ok(inputBlock, "action.yml must expose ocr_version metadata");
assert.match(inputBlock[1], /1\.9\.6/, "ocr_version must document the minimum compatible OCR release");
const cases = [
{ output: "open-code-review 1.9.5 linux/amd64", valid: false },
{ output: "open-code-review 1.9.6 linux/amd64", valid: true },
{ output: "open-code-review v1.9.10 linux/amd64", valid: true },
{ output: "open-code-review 1.10.0 linux/amd64", valid: true },
{ output: "open-code-review 1.9.6+brokerkit.1 linux/amd64", valid: true },
{ output: "open-code-review 1.9.6-rc.1 linux/amd64", valid: false },
{ output: "open-code-review 01.09.006 linux/amd64", valid: false },
{ output: "open-code-review 1.9.6+! linux/amd64", valid: false },
{ output: "version unavailable", valid: false },
{ output: "", valid: false },
{ output: "version command unavailable", status: 7, valid: false, commandFailure: true },
];
for (const testCase of cases) {
const fixture = makeFixture();
try {
const result = runStep(
install,
inputValues({ ocr_version: "contract-test" }),
fixture,
{
OCR_FAKE_VERSION_OUTPUT: testCase.output,
OCR_FAKE_VERSION_STATUS: String(testCase.status || 0),
}
);
const message = `version output ${JSON.stringify(testCase.output)}; ${resultDescription(result)}`;
if (testCase.valid) assert.strictEqual(result.status, 0, `supported ${message}`);
else assert.notStrictEqual(result.status, 0, `unsupported ${message}`);
if (testCase.commandFailure) {
assert.match(
result.stdout,
/Unable to read the installed OpenCodeReview version/,
`failed version command must produce an actionable error; ${message}`
);
}
} finally {
removeFixture(fixture);
}
}
}
function testContractHarnessFailsClosedOnUnsupportedYamlShapes() {
assert.throws(
() => parseSteps(ACTION_TEXT.replace(" - name: Configure OCR", " - name: Configure OCR")),
/unsupported|step/i,
"changed step indentation must fail instead of silently dropping a step"
);
assert.throws(
() =>
parseSteps(
ACTION_TEXT.replace(
" - name: Configure OCR",
" - uses: example/action@v1\n\n - name: Configure OCR"
)
),
/unsupported|step/i,
"nameless composite steps must fail instead of being appended to the previous named step"
);
assert.throws(
() =>
parseSteps(
ACTION_TEXT.replace(
" - name: Configure OCR",
" -\n uses: example/action@v1\n\n - name: Configure OCR"
)
),
/unsupported|step/i,
"block-style nameless composite steps must fail instead of being appended to the previous step"
);
assert.throws(
() =>
parseSteps(
ACTION_TEXT.replace(
" run: |\n if command -v git",
" run: >-\n if command -v git"
)
),
/unsupported|run/i,
"unsupported run scalar styles must fail instead of producing an undefined shell block"
);
assert.throws(
() =>
parseSteps(
ACTION_TEXT.replace(
" OCR_EXTRA_BODY: ${{ inputs.llm_extra_body }}",
" OCR_EXTRA_BODY: >-\n ${{ inputs.llm_extra_body }}"
)
),
/unsupported|env/i,
"multiline env values must fail instead of truncating the environment map"
);
assert.throws(
() => parseSteps(ACTION_TEXT.replace(" - name: Configure OCR", " - name: Run OpenCodeReview")),
/duplicate|step/i,
"duplicate step names must fail instead of making step selection ambiguous"
);
assert.throws(
() => resolveInputExpressions("${{ github.ref }}", {}),
/unsupported|unresolved|expression/i,
"non-input action expressions must not remain unresolved in executed test fixtures"
);
}
function testRequiredStepTopologyAndEnvironmentContracts() {
const required = {
"Validate inputs": [
"REVIEW_TASK_TIMEOUT",
"EFFORT_INPUT",
"MAX_TOKENS_BUDGET_INPUT",
"LLM_REASONING_EFFORT_INPUT",
"STREAM_PROGRESS_INPUT",
],
"Install OpenCodeReview": ["OCR_VERSION"],
"Configure OCR": [
"OCR_LLM_URL",
"OCR_LLM_MODEL",
"OCR_USE_ANTHROPIC",
"OCR_LLM_AUTH_HEADER",
"OCR_EXTRA_BODY",
"OCR_LANGUAGE",
],
"Run OpenCodeReview": [
"OCR_LLM_URL",
"OCR_LLM_TOKEN",
"OCR_LLM_MODEL",
"OCR_USE_ANTHROPIC",
"OCR_LLM_AUTH_HEADER",
"OCR_LLM_EXTRA_HEADERS",
"OCR_LLM_TIMEOUT",
"OCR_REVIEW_CONCURRENCY",
"OCR_BACKGROUND",
"OCR_RULE",
],
};
for (const [name, envNames] of Object.entries(required)) {
const step = stepNamed(name);
assert.ok(step, `action.yml must retain the ${name} step`);
assert.strictEqual(typeof step.run, "string", `${name} must retain a supported shell block`);
for (const envName of envNames) {
assert.ok(Object.prototype.hasOwnProperty.call(step.env, envName), `${name} must define ${envName}`);
}
}
}
function testContractsRunInDedicatedWorkflow() {
assert.match(
CONTRACT_WORKFLOW_TEXT,
/^\s+- name:\s*Test GitHub Actions contracts\s*$[\s\S]*?^\s+run:\s*npm run test:github-actions\s*$/m,
"action-contract.yml must execute the complete GitHub Actions contract suite"
);
assert.match(
CONTRACT_WORKFLOW_TEXT,
/paths:\s*[\s\S]*?examples\/github_actions\/README\.md/,
"action-contract.yml must run the contract suite when the GitHub Actions README changes"
);
assert.match(
CONTRACT_WORKFLOW_TEXT,
/uses:\s*actions\/checkout@[0-9a-f]{40}\s*#\s*v7/,
"the dedicated contract workflow must pin checkout to an immutable commit"
);
assert.match(
CONTRACT_WORKFLOW_TEXT,
/container:\s*[\s\S]*?image:\s*node:[0-9]/,
"the contract job must declare its Node runtime via a pinned container image"
);
}
function testExampleReadmeDocumentsTimeoutAndVersionContracts() {
assert.match(
EXAMPLE_README_TEXT,
/\| `review_task_timeout` \| `'15'` \|[^\n]*(?:per-file|per-task|concurrent task)[^\n]*\|/i,
"GitHub Actions README must document the per-task timeout and its default"
);
assert.match(
EXAMPLE_README_TEXT,
/\| `llm_timeout` \| `'300'` \|[^\n]*(?:LLM|HTTP)[^\n]*seconds[^\n]*\|/i,
"GitHub Actions README must document the LLM request timeout and its default"
);
assert.match(
EXAMPLE_README_TEXT,
/ocr_version[^\n]*1\.9\.6/i,
"GitHub Actions README must document the minimum compatible OCR version"
);
const sample = EXAMPLE_README_TEXT.match(
/### Use a specific OCR version[\s\S]*?ocr_version:\s*['\"]?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)['\"]?/
);
assert.ok(sample, "GitHub Actions README must include a valid stable OCR version sample");
const [major, minor, patch] = sample.slice(1, 4).map(Number);
assert.ok(
major > 1 || (major === 1 && (minor > 9 || (minor === 9 && patch >= 6))),
"GitHub Actions README must not recommend an OCR version below the supported floor"
);
}
const TESTS = [
["review_task_timeout names and describes the CLI task deadline", testReviewTaskTimeoutInputNameAndScope],
["llm_timeout defaults to the CLI's 5-minute timeout", testLlmTimeoutInputDefault],
["review_task_timeout accepts 1/10/120", testReviewTimeoutValidationAcceptsBoundaries],
["review_task_timeout rejects malformed values", testReviewTimeoutValidationRejectsMalformedValues],
["review_task_timeout validation runs before NPM install", testValidationPrecedesNpmInstall],
["review_task_timeout forwards --timeout separately from llm_timeout", testReviewTimeoutForwardedSeparatelyFromLlmTimeout],
["default llm_timeout exports independently from review_task_timeout", testDefaultLlmTimeoutExportedSeparatelyFromReviewTimeout],
["empty llm_timeout normalizes before review invocation", testEmptyLlmTimeoutNormalizesBeforeReviewInvocation],
["review_task_timeout with a leading zero normalizes across steps", testReviewTimeoutLeadingZeroIsNormalizedAcrossSteps],
["effort and max_tokens_budget reject malformed values", testValidateInputsRejectsInvalidEffortAndBudget],
["effort and max_tokens_budget normalize and forward across steps", testEffortAndBudgetNormalizeAndForwardAcrossSteps],
["max_tokens_budget=0 normalizes to unlimited", testZeroMaxTokensBudgetNormalizesToUnlimited],
["empty effort and max_tokens_budget omit the CLI flags", testEmptyEffortAndBudgetOmitTheFlags],
["llm_reasoning_effort rejects values outside the OpenAI/GLM vocabulary", testValidateInputsRejectsInvalidReasoningEffort],
["stream_progress defaults to false and describes [ocr] progress", testStreamProgressInputDefaultsToFalse],
["stream_progress validation accepts true/false case-insensitively", testValidateInputsValidatesStreamProgress],
["resolve_outdated validation fails fast on an unknown value", testValidateInputsValidatesResolveOutdated],
["Run OpenCodeReview keeps --audience agent and the log file by default", testRunKeepsAgentAudienceAndLogFileByDefault],
["Run OpenCodeReview streams live progress when opted in", testRunStreamsProgressWhenOptedIn],
["llm_extra_body defaults to disabling thinking", testLlmExtraBodyDefaultDisablesThinking],
["Configure OCR merges reasoning_effort into extra_body", testConfigureMergesReasoningEffortIntoExtraBody],
["Configure OCR rejects reasoning_effort on the anthropic protocol", testConfigureRejectsReasoningEffortOnAnthropic],
["Configure OCR rejects malformed extra_body with an actionable error", testConfigureRejectsMalformedExtraBodyWithActionableError],
["Configure OCR rejects a non-object extra_body", testConfigureRejectsNonObjectExtraBody],
["Configure OCR builds a complete llm config", testConfigureBuildsCompleteLlmConfig],
["Configure OCR never persists the token", testConfigureNeverPersistsToken],
["Configure OCR neutralizes stale provider and static token", testConfigureNeutralizesStaleProviderAndStaticToken],
["Configure OCR sets a protocol consistent with use_anthropic", testConfigureProtocolTracksUseAnthropic],
["Configure OCR honours an explicit llm_protocol", testConfigureHonoursExplicitProtocol],
["Configure OCR preserves legacy use_anthropic resolution", testConfigurePreservesLegacyUseAnthropicResolution],
["Configure OCR clears stale persisted extra headers", testConfigureClearsStaleExtraHeadersBeforeTokenCommand],
["Configure OCR clears stale persisted retry codes", testConfigureClearsStaleRetryCodesBeforeEndpointConfig],
["Run OpenCodeReview retains the extra-headers env override", testRunRetainsExtraHeadersEnvironmentOverride],
["Run OpenCodeReview fails closed without validated task timeout", testRunFailsClosedWhenValidatedTaskTimeoutIsMissing],
["the official OpenCodeReview NPM install is preserved", testOfficialNpmPackageInstallIsPreserved],
["Install OpenCodeReview enforces the auth_token_cmd version floor", testInstallEnforcesAuthTokenCommandVersionFloor],
["Install OpenCodeReview rejects the effort input below v1.10.0", testInstallRejectsEffortBelowV1100],
["Install OpenCodeReview rejects stream_progress below v1.9.8", testInstallRejectsStreamProgressBelowV198],
["contract harness fails closed on unsupported YAML shapes", testContractHarnessFailsClosedOnUnsupportedYamlShapes],
["required action steps and env contracts are present", testRequiredStepTopologyAndEnvironmentContracts],
["GitHub Actions contracts run in a dedicated workflow", testContractsRunInDedicatedWorkflow],
["GitHub Actions README documents timeout and version contracts", testExampleReadmeDocumentsTimeoutAndVersionContracts],
["pr_number is an optional, documented input", testPrNumberInputIsOptionalAndDocumented],
["the PR number resolves input, then payload, then workflow_run", testPrNumberResolutionOrder],
["an unresolvable PR number fails before any review work", testPrNumberFailsBeforeAnyReviewWork],
["pr_number rejects values that are not a PR number", testPrNumberRejectsValuesThatAreNotAPrNumber],
["the resolved PR number reaches the fork-safe head fetch", testResolvedPrNumberReachesTheHeadFetch],
["both github-script steps read the resolved PR number", testPrNumberWiredIntoBothGithubScriptSteps],
["GitHub Actions README documents PR number resolution", testExampleReadmeDocumentsPrNumberResolution],
];
function main() {
const failures = [];
for (const [name, test] of TESTS) {
try {
test();
console.log(`ok - ${name}`);
} catch (error) {
failures.push({ name, error });
console.error(`not ok - ${name}: ${error.message}`);
}
}
if (failures.length > 0) {
console.error(`\n${failures.length} action contract test(s) failed.`);
process.exitCode = 1;
} else {
console.log(`\nAll ${TESTS.length} action contract tests passed.`);
}
}
main();