// tests/unit/build/check-workflows.test.ts // TDD unit tests for scripts/check/check-workflows.mjs — Task 7.19. // // Strategy: test the exported pure functions without spawning actionlint, // zizmor, or touching the real .github/workflows directory. // - parseActionlintOutput() — line-based finding counting // - parseZizmorOutput() — JSON / text parsing + counting // - collectWorkflowFiles() — directory listing helper // - isBinaryAvailable() — PATH probe (tested structurally, not by // spawning real processes) // // All tests are fast and hermetic (no network, no child processes except where // explicitly exercising the PATH probe against a non-existent binary name). import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { parseActionlintOutput, parseZizmorOutput, collectWorkflowFiles, isBinaryAvailable, evaluateZizmorRatchet, readBaselineZizmorValue, // @ts-expect-error — .mjs helper has no type declarations; runtime shape is known. } from "../../../scripts/check/check-workflows.mjs"; type RatchetVerdict = { regressed: boolean; improved: boolean }; const evaluateZizmor = evaluateZizmorRatchet as ( current: number, baseline: number ) => RatchetVerdict; const readZizmorBaseline = readBaselineZizmorValue as (p?: string) => number | null; const qualityWorkflowPath = new URL("../../../.github/workflows/quality.yml", import.meta.url); function readQualityWorkflow(): string { return fs.readFileSync(qualityWorkflowPath, "utf8"); } // ───────────────────────────────────────────────────────────────────────────── // parseActionlintOutput // ───────────────────────────────────────────────────────────────────────────── test("parseActionlintOutput: empty stdout returns count=0 and empty lines", () => { const result = parseActionlintOutput(""); assert.equal(result.count, 0); assert.deepEqual(result.lines, []); }); test("parseActionlintOutput: whitespace-only stdout returns count=0", () => { const result = parseActionlintOutput(" \n \t \n"); assert.equal(result.count, 0); assert.deepEqual(result.lines, []); }); test("parseActionlintOutput: one finding line returns count=1", () => { const stdout = ".github/workflows/ci.yml:12:7: shellcheck reported issue in this script: SC2086:info:1:12: Double quote to prevent globbing and word splitting. [shellcheck]\n"; const result = parseActionlintOutput(stdout); assert.equal(result.count, 1); assert.equal(result.lines.length, 1); assert.ok(result.lines[0].includes("shellcheck")); }); test("parseActionlintOutput: multiple finding lines returns correct count", () => { const stdout = [ '.github/workflows/ci.yml:5:1: "on" is the key of workflow trigger. Use quoted "on" [syntax-check]', ".github/workflows/ci.yml:42:9: event name 'pull_request' is not available for 'workflow_dispatch' [events]", ".github/workflows/deploy.yml:8:5: unknown key 'runs-ons' in step config [syntax-check]", ].join("\n"); const result = parseActionlintOutput(stdout); assert.equal(result.count, 3); assert.equal(result.lines.length, 3); }); test("parseActionlintOutput: trailing newline does not add phantom finding", () => { const stdout = ".github/workflows/ci.yml:10:3: some issue [rule]\n\n\n"; const result = parseActionlintOutput(stdout); assert.equal(result.count, 1); }); test("parseActionlintOutput: preserves finding text exactly (trimmed)", () => { const finding = ".github/workflows/ci.yml:99:1: missing required key 'runs-on' [runner]"; const result = parseActionlintOutput(` ${finding} \n`); assert.equal(result.lines[0], finding); }); // ───────────────────────────────────────────────────────────────────────────── // parseZizmorOutput // ───────────────────────────────────────────────────────────────────────────── test("parseZizmorOutput: empty string returns count=0", () => { const result = parseZizmorOutput(""); assert.equal(result.count, 0); assert.deepEqual(result.diagnostics, []); }); test("parseZizmorOutput: JSON with empty diagnostics array returns count=0", () => { const result = parseZizmorOutput(JSON.stringify({ diagnostics: [] })); assert.equal(result.count, 0); assert.deepEqual(result.diagnostics, []); }); test("parseZizmorOutput: JSON { diagnostics: [...] } counts correctly", () => { const diagnostics = [ { id: "unpinned-uses", severity: "medium", message: "uses: actions/checkout@v4 is not pinned to a SHA", }, { id: "script-injection", severity: "high", message: "Untrusted input in run step" }, ]; const result = parseZizmorOutput(JSON.stringify({ diagnostics })); assert.equal(result.count, 2); assert.equal(result.diagnostics.length, 2); }); test("parseZizmorOutput: bare JSON array (older zizmor format) counts correctly", () => { const findings = [ { id: "unpinned-uses", workflow: "ci.yml" }, { id: "excessive-permissions", workflow: "deploy.yml" }, { id: "pull-request-target", workflow: "docker.yml" }, ]; const result = parseZizmorOutput(JSON.stringify(findings)); assert.equal(result.count, 3); assert.equal(result.diagnostics.length, 3); }); test("parseZizmorOutput: invalid JSON falls back to line counting", () => { // Non-JSON output (e.g. text format or error message) — each non-empty line = 1 const textOutput = "warning: unpinned action\nerror: script injection risk\n"; const result = parseZizmorOutput(textOutput); assert.equal(result.count, 2); // diagnostics is empty array in fallback mode assert.deepEqual(result.diagnostics, []); }); test("parseZizmorOutput: JSON with unknown shape returns count=0 (graceful)", () => { // Unexpected but valid JSON — neither array nor { diagnostics } const result = parseZizmorOutput(JSON.stringify({ errors: [], warnings: [] })); assert.equal(result.count, 0); }); test("parseZizmorOutput: whitespace-only returns count=0", () => { const result = parseZizmorOutput(" \n\t\n "); assert.equal(result.count, 0); }); test("parseZizmorOutput: large diagnostics array counted correctly", () => { const diagnostics = Array.from({ length: 47 }, (_, i) => ({ id: "unpinned-uses", step: `step-${i}`, })); const result = parseZizmorOutput(JSON.stringify({ diagnostics })); assert.equal(result.count, 47); }); // ───────────────────────────────────────────────────────────────────────────── // collectWorkflowFiles // ───────────────────────────────────────────────────────────────────────────── test("collectWorkflowFiles: returns empty array for non-existent directory", () => { const result = collectWorkflowFiles("/this/path/does/not/exist/at/all-99999"); assert.deepEqual(result, []); }); test("collectWorkflowFiles: returns .yml files from directory", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "check-workflows-test-")); try { fs.writeFileSync(path.join(dir, "ci.yml"), "name: CI\n"); fs.writeFileSync(path.join(dir, "deploy.yml"), "name: Deploy\n"); fs.writeFileSync(path.join(dir, "README.md"), "# docs\n"); // not a workflow const files = collectWorkflowFiles(dir); assert.equal(files.length, 2); assert.ok(files.some((f) => f.endsWith("ci.yml"))); assert.ok(files.some((f) => f.endsWith("deploy.yml"))); assert.ok(!files.some((f) => f.endsWith("README.md"))); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("collectWorkflowFiles: also collects .yaml extension", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "check-workflows-test-")); try { fs.writeFileSync(path.join(dir, "ci.yaml"), "name: CI\n"); fs.writeFileSync(path.join(dir, "deploy.yml"), "name: Deploy\n"); const files = collectWorkflowFiles(dir); assert.equal(files.length, 2); assert.ok(files.some((f) => f.endsWith(".yaml"))); assert.ok(files.some((f) => f.endsWith(".yml"))); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("collectWorkflowFiles: returns absolute paths", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "check-workflows-test-")); try { fs.writeFileSync(path.join(dir, "ci.yml"), "name: CI\n"); const files = collectWorkflowFiles(dir); assert.equal(files.length, 1); assert.ok(path.isAbsolute(files[0])); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("collectWorkflowFiles: empty directory returns empty array", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "check-workflows-test-")); try { const files = collectWorkflowFiles(dir); assert.deepEqual(files, []); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); // ───────────────────────────────────────────────────────────────────────────── // isBinaryAvailable // ───────────────────────────────────────────────────────────────────────────── test("isBinaryAvailable: returns false for a nonsense binary name", () => { // A binary named like this cannot exist in any real PATH. const result = isBinaryAvailable("__this_binary_definitely_does_not_exist_zzz99999__"); assert.equal(result, false); }); test("isBinaryAvailable: returns boolean (not null/undefined)", () => { const result = isBinaryAvailable("node"); // node IS in PATH in this environment — but we only assert the type here // to avoid environment coupling. assert.equal(typeof result, "boolean"); }); test("isBinaryAvailable: node is available (sanity check for test environment)", () => { // node must be in PATH for this test suite to even run. assert.equal(isBinaryAvailable("node"), true); }); // ───────────────────────────────────────────────────────────────────────────── // evaluateZizmorRatchet — ratchet direction:down, zizmorFindings ONLY (Etapa 2) // Regression when measured > baseline. actionlint is reported, not ratcheted. // ───────────────────────────────────────────────────────────────────────────── test("evaluateZizmorRatchet: measured == baseline passes (192 vs 192)", () => { const r = evaluateZizmor(192, 192); assert.equal(r.regressed, false); assert.equal(r.improved, false); }); test("evaluateZizmorRatchet: one more than baseline is a regression (193 vs 192)", () => { const r = evaluateZizmor(193, 192); assert.equal(r.regressed, true, "a single new zizmor finding must block"); assert.equal(r.improved, false); }); test("evaluateZizmorRatchet: fewer than baseline is an improvement (190 vs 192)", () => { const r = evaluateZizmor(190, 192); assert.equal(r.regressed, false); assert.equal(r.improved, true); }); test("evaluateZizmorRatchet: strict integer comparison — any increase regresses", () => { assert.equal(evaluateZizmor(193, 192).regressed, true); assert.equal(evaluateZizmor(192, 192).regressed, false); assert.equal(evaluateZizmor(191, 192).regressed, false); }); // ───────────────────────────────────────────────────────────────────────────── // readBaselineZizmorValue — tolerant read of quality-baseline.json // ───────────────────────────────────────────────────────────────────────────── function withTmpBaseline(content: string | null, fn: (p: string) => void) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "workflows-baseline-")); const p = path.join(dir, "quality-baseline.json"); if (content !== null) fs.writeFileSync(p, content); try { fn(p); } finally { fs.rmSync(dir, { recursive: true, force: true }); } } test("readBaselineZizmorValue: reads metrics.zizmorFindings.value", () => { withTmpBaseline(JSON.stringify({ metrics: { zizmorFindings: { value: 192 } } }), (p) => { assert.equal(readZizmorBaseline(p), 192); }); }); test("readBaselineZizmorValue: missing file returns null (graceful SKIP)", () => { assert.equal(readZizmorBaseline("/tmp/does-not-exist-88888/quality-baseline.json"), null); }); test("readBaselineZizmorValue: missing metric returns null", () => { withTmpBaseline(JSON.stringify({ metrics: {} }), (p) => { assert.equal(readZizmorBaseline(p), null); }); }); test("readBaselineZizmorValue: non-numeric value returns null", () => { withTmpBaseline(JSON.stringify({ metrics: { zizmorFindings: { value: "192" } } }), (p) => { assert.equal(readZizmorBaseline(p), null); }); }); test("readBaselineZizmorValue: invalid JSON returns null (does not throw)", () => { withTmpBaseline("{ broken", (p) => { assert.equal(readZizmorBaseline(p), null); }); }); // ───────────────────────────────────────────────────────────────────────────── // quality.yml — release PR build gate regression coverage (#7307) // ───────────────────────────────────────────────────────────────────────────── test("#7307 quality.yml adds an advisory production build for release PR code changes", () => { const source = readQualityWorkflow(); const buildJob = source.match(/\n build:\n[\s\S]*?\n # Docs\/OpenAPI contract gates only/); assert.match(source, /pull_request:\n\s+branches: \["release\/\*\*"\]/); assert.ok(buildJob, "quality.yml must define the build job before docs-gates"); assert.match(buildJob[0], /name: Build \(advisory\)/); assert.match(buildJob[0], /needs: changes/); assert.match(buildJob[0], /needs\.changes\.outputs\.code == 'true'/); assert.match(buildJob[0], /github\.event\.pull_request\.draft == false/); assert.match(buildJob[0], /startsWith\(github\.head_ref, 'mergify\/merge-queue\/'\)/); assert.match( buildJob[0], /github\.event\.pull_request\.head\.repo\.full_name == github\.repository/ ); assert.match(buildJob[0], /fromJSON\('\["self-hosted","omni-release"\]'\) \|\| 'ubuntu-latest'/); assert.match(buildJob[0], /continue-on-error: true/); assert.match(buildJob[0], /uses: actions\/checkout@[0-9a-f]{40} # v7/); assert.match(buildJob[0], /uses: actions\/setup-node@[0-9a-f]{40} # v7/); assert.match(buildJob[0], /uses: \.\/\.github\/actions\/npm-ci-retry/); assert.match(buildJob[0], /npm run check:node-runtime/); assert.match(buildJob[0], /npm run build/); assert.match(buildJob[0], /OMNIROUTE_USE_TURBOPACK: "1"/); assert.doesNotMatch(buildJob[0], /actions\/upload-artifact/); assert.match( buildJob[0], /remove\s+# continue-on-error after the production-build signal is stable/ ); });