mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-10-20 06:19:47 +08:00
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
2032 lines
83 KiB
JavaScript
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();
|