Files
OmniRoute/tests/unit/check-openapi-breaking-ratchet.test.ts
Diego Rodrigues de Sa e Souza 3d4f3e4960 test(infra): retry recursive temp-dir removal instead of failing a shard on ENOTEMPTY (#11966) (#11968)
* test(infra): retry recursive temp-dir removal instead of failing a shard on ENOTEMPTY (#11966)

Two shards on release/v3.8.51 went red in one day with the same signature —
"ENOTEMPTY, Directory not empty: /tmp/omniroute-<test>-XXXXXX" — from
combo-same-provider-cascade (Unit Tests fast-path 4/4, on a PR that touches only
.github/) and auth-policy-embeddings-webfetch-7785 (the 20k-test TIA step). Both pass
alone and on re-run: the cleanup races something still writing into the directory
(SQLite WAL/-shm checkpoint, a worker, the backup) and under a loaded hosted runner
the window opens. 1154 test files do their own cleanup with
fs.rmSync(dir, { recursive: true, force: true }); 57 already asked for retries.

One-shot codemod (scripts/ad-hoc/codemod-rm-maxretries.mjs, kept for the record):
every rm / rmSync / rmdirSync option object with `recursive: true` and no
`maxRetries` gains `maxRetries: 5, retryDelay: 100` — Node itself then retries
ENOTEMPTY/EBUSY/EPERM for up to ~0.5 s before giving up. 2243 call sites in 1292
files under tests/, the shared tests/_setup/isolateDataDir.ts exit hook included.
Only the option object changes: no call site, assertion or import is touched.

Validation: prettier and ESLint (with the frozen suppressions) clean on all 1292
files; a random 20-file sample runs green (quota-redis-store hangs identically on
the untouched tree — it needs a Redis on localhost, an environment matter). The
four unit shards on this PR are the full run.

* fix(quality): let check-forgotten-sibling-tests read a 1,000-file diff

The gate shells out to `git diff` through execFileSync with Node's default 1 MB
maxBuffer; the 1,292-file codemod in this PR is the first diff large enough to
overflow it, and the gate died with `spawnSync git ENOBUFS` before comparing
anything. 64 MB is far above any real PR and costs nothing when unused.
2026-08-29 01:17:40 -03: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, maxRetries: 5, retryDelay: 100 });
}
}
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/);
});