#!/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 : ""} 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();