Files
diegosouzapw--omniroute/tests/unit/check-openapi-breaking-ratchet.test.ts
2026-07-13 13:39:12 +08:00

166 lines
6.9 KiB
TypeScript

// tests/unit/check-openapi-breaking-ratchet.test.ts
// TDD unit tests for the --ratchet mode added to scripts/check/check-openapi-breaking.mjs
// (Fase 9 Onda 0 — flip the oasdiff breaking-change gate from advisory to blocking).
//
// Strategy: test the exported pure evaluator without spawning oasdiff or touching
// git. The evaluator is the load-bearing decision: regression iff a real breaking
// change appears (measured > baseline); a null measurement (graceful skip) never
// blocks. End-to-end SKIP behavior (binary absent / base unresolved) is covered by
// the script's own advisory tests and a process-level skip assertion below.
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 { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import {
evaluateOpenapiRatchet,
readBaselineOpenapiValue,
releaseBranchForVersion,
// @ts-expect-error — .mjs helper has no type declarations; runtime shape is known.
} from "../../scripts/check/check-openapi-breaking.mjs";
type RatchetVerdict = { regressed: boolean; skipped: boolean };
const evaluate = evaluateOpenapiRatchet as (args: {
current: number | null;
baseline: number | null;
}) => RatchetVerdict;
const readBaseline = readBaselineOpenapiValue as (p?: string) => number | null;
const releaseBranch = releaseBranchForVersion as (v: string | null | undefined) => string | null;
const SCRIPT_PATH = fileURLToPath(
new URL("../../scripts/check/check-openapi-breaking.mjs", import.meta.url)
);
// ---------------------------------------------------------------------------
// releaseBranchForVersion — the default base ref derives from the package
// version so it never goes stale across release cycles (was a hard-coded
// "origin/release/v3.8.27" that drifted into the v3.8.29 cycle).
// ---------------------------------------------------------------------------
test("releaseBranchForVersion: a clean semver derives the matching release branch", () => {
assert.equal(releaseBranch("3.8.29"), "origin/release/v3.8.29");
});
test("releaseBranchForVersion: a prerelease/build suffix is ignored", () => {
assert.equal(releaseBranch("3.8.29-dev.2"), "origin/release/v3.8.29");
assert.equal(releaseBranch("10.0.0+build.7"), "origin/release/v10.0.0");
});
test("releaseBranchForVersion: a non-semver value yields null (caller falls back)", () => {
assert.equal(releaseBranch(""), null);
assert.equal(releaseBranch(null), null);
assert.equal(releaseBranch(undefined), null);
assert.equal(releaseBranch("not-a-version"), null);
});
// ---------------------------------------------------------------------------
// evaluateOpenapiRatchet — the three contract cases from the plan
// ---------------------------------------------------------------------------
test("evaluateOpenapiRatchet: current=0 baseline=0 → not regressed", () => {
const r = evaluate({ current: 0, baseline: 0 });
assert.equal(r.regressed, false);
assert.equal(r.skipped, false);
});
test("evaluateOpenapiRatchet: current=1 baseline=0 → regressed (a single breaking change blocks)", () => {
const r = evaluate({ current: 1, baseline: 0 });
assert.equal(r.regressed, true);
assert.equal(r.skipped, false);
});
test("evaluateOpenapiRatchet: current=null baseline=0 → graceful skip (no measurement never blocks)", () => {
const r = evaluate({ current: null, baseline: 0 });
assert.equal(r.regressed, false);
assert.equal(r.skipped, true);
});
// ---------------------------------------------------------------------------
// evaluateOpenapiRatchet — additional edge cases for the ratchet semantics
// ---------------------------------------------------------------------------
test("evaluateOpenapiRatchet: null baseline → graceful skip (no baseline, no ratchet)", () => {
const r = evaluate({ current: 3, baseline: null });
assert.equal(r.regressed, false);
assert.equal(r.skipped, true);
});
test("evaluateOpenapiRatchet: undefined current → graceful skip", () => {
const r = evaluate({ current: undefined as unknown as null, baseline: 0 });
assert.equal(r.regressed, false);
assert.equal(r.skipped, true);
});
test("evaluateOpenapiRatchet: measured == baseline → not regressed", () => {
const r = evaluate({ current: 2, baseline: 2 });
assert.equal(r.regressed, false);
assert.equal(r.skipped, false);
});
test("evaluateOpenapiRatchet: measured < baseline → not regressed (improvement)", () => {
const r = evaluate({ current: 1, baseline: 5 });
assert.equal(r.regressed, false);
assert.equal(r.skipped, false);
});
// ---------------------------------------------------------------------------
// readBaselineOpenapiValue — tolerant read of quality-baseline.json
// ---------------------------------------------------------------------------
function withTmpBaseline(content: string | null, fn: (p: string) => void) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openapi-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("readBaselineOpenapiValue: reads metrics.openapiBreaking.value", () => {
withTmpBaseline(JSON.stringify({ metrics: { openapiBreaking: { value: 0 } } }), (p) => {
assert.equal(readBaseline(p), 0);
});
});
test("readBaselineOpenapiValue: missing file returns null (graceful skip)", () => {
assert.equal(readBaseline("/tmp/does-not-exist-99999/quality-baseline.json"), null);
});
test("readBaselineOpenapiValue: missing metric returns null", () => {
withTmpBaseline(JSON.stringify({ metrics: {} }), (p) => {
assert.equal(readBaseline(p), null);
});
});
test("readBaselineOpenapiValue: non-numeric value returns null", () => {
withTmpBaseline(JSON.stringify({ metrics: { openapiBreaking: { value: "0" } } }), (p) => {
assert.equal(readBaseline(p), null);
});
});
test("readBaselineOpenapiValue: invalid JSON returns null (does not throw)", () => {
withTmpBaseline("{ not valid json", (p) => {
assert.equal(readBaseline(p), null);
});
});
// ---------------------------------------------------------------------------
// --ratchet end-to-end: binary-absent SKIP exits 0 (a missing measurement never
// blocks). Runs the script with an empty PATH so oasdiff is unresolvable.
// ---------------------------------------------------------------------------
test("--ratchet with oasdiff absent (empty PATH) SKIPs and exits 0", () => {
const res = spawnSync(process.execPath, [SCRIPT_PATH, "--ratchet", "--quiet"], {
encoding: "utf8",
// Empty PATH → `which`/`oasdiff` unresolvable → findOasdiff() returns null.
env: { ...process.env, PATH: "/nonexistent-bin-dir" },
timeout: 30_000,
});
assert.equal(res.status, 0, "binary-absent SKIP must exit 0 even with --ratchet");
assert.match(res.stdout, /openapiBreaking=SKIP reason=binary-absent/);
});