chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
// @ts-expect-error — .mjs helper has no type declarations; runtime shape is known.
|
||||
import { reportStaleEntries, assertNoStale } from "../../../scripts/check/lib/allowlist.mjs";
|
||||
|
||||
test("reportStaleEntries: entrada da allowlist não mais violada => stale", () => {
|
||||
const stale = reportStaleEntries(["/api/dead", "/api/live"], ["/api/live"], "fetch-targets");
|
||||
assert.deepEqual(stale, ["/api/dead"]);
|
||||
});
|
||||
|
||||
test("reportStaleEntries: todas as entradas ainda violadas => vazio", () => {
|
||||
assert.deepEqual(reportStaleEntries(["a", "b"], ["a", "b"], "x"), []);
|
||||
});
|
||||
|
||||
test("reportStaleEntries: Set como liveViolations funciona igual a array", () => {
|
||||
const live = new Set(["/api/live"]);
|
||||
const stale = reportStaleEntries(["/api/dead", "/api/live"], live, "fetch-targets");
|
||||
assert.deepEqual(stale, ["/api/dead"]);
|
||||
});
|
||||
|
||||
test("reportStaleEntries: allowlist vazia => sempre vazio", () => {
|
||||
assert.deepEqual(reportStaleEntries([], ["/api/anything"], "x"), []);
|
||||
});
|
||||
|
||||
test("assertNoStale: seta process.exitCode=1 quando há entradas stale", () => {
|
||||
const original = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
const stale = assertNoStale(["/api/dead", "/api/live"], ["/api/live"], "fetch-targets");
|
||||
assert.equal(process.exitCode, 1);
|
||||
assert.deepEqual(stale, ["/api/dead"]);
|
||||
process.exitCode = original;
|
||||
});
|
||||
|
||||
test("assertNoStale: NÃO seta process.exitCode quando não há stale", () => {
|
||||
const original = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
const stale = assertNoStale(["a", "b"], ["a", "b"], "x");
|
||||
assert.equal(process.exitCode, 0);
|
||||
assert.deepEqual(stale, []);
|
||||
process.exitCode = original;
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
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 {
|
||||
assembleStandalone,
|
||||
syncStandaloneNativeAssets,
|
||||
syncStandaloneExtraModules,
|
||||
} from "../../../scripts/build/assembleStandalone.mjs";
|
||||
|
||||
/** Recursively list relative file paths under dir (forward-slash normalised). */
|
||||
function listFiles(dir: string, rootDir: string = dir, out: string[] = []): string[] {
|
||||
let entries: string[] = [];
|
||||
try {
|
||||
entries = fs.readdirSync(dir);
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry);
|
||||
if (fs.statSync(full).isDirectory()) {
|
||||
listFiles(full, rootDir, out);
|
||||
} else {
|
||||
out.push(path.relative(rootDir, full).replace(/\\/g, "/"));
|
||||
}
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
/** Build a synthetic projectRoot containing every sidecar source the assembler copies. */
|
||||
function seedSidecarSources(root: string) {
|
||||
const files = [
|
||||
"node_modules/wreq-js/rust/lib.so",
|
||||
"node_modules/better-sqlite3/build/Release/better_sqlite3.node",
|
||||
"src/mitm/tproxy/native/build/Release/transparent.node",
|
||||
"node_modules/@swc/helpers/package.json",
|
||||
"node_modules/pino-abstract-transport/index.js",
|
||||
"node_modules/pino-pretty/index.js",
|
||||
"node_modules/split2/index.js",
|
||||
"node_modules/playwright-core/index.js",
|
||||
"node_modules/sqlite-vec/index.js",
|
||||
"node_modules/sqlite-vec-linux-x64/vec0.so",
|
||||
"src/lib/db/migrations/001_init.sql",
|
||||
"src/mitm/server.cjs",
|
||||
"scripts/dev/run-standalone.mjs",
|
||||
"scripts/dev/standalone-server-ws.mjs",
|
||||
"scripts/dev/peer-stamp.mjs",
|
||||
"scripts/dev/responses-ws-proxy.mjs",
|
||||
"scripts/build/runtime-env.mjs",
|
||||
"scripts/build/bootstrap-env.mjs",
|
||||
"scripts/dev/healthcheck.mjs",
|
||||
"public/logo.svg",
|
||||
];
|
||||
for (const rel of files) {
|
||||
const full = path.join(root, rel);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, `// ${rel}`);
|
||||
}
|
||||
}
|
||||
|
||||
test("assembleStandalone copies standalone + static + public + sidecars into outDir", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "assemble-"));
|
||||
const distDir = path.join(tmp, ".build/next");
|
||||
const outDir = path.join(tmp, "dist");
|
||||
// minimal fake standalone tree
|
||||
fs.mkdirSync(path.join(distDir, "standalone"), { recursive: true });
|
||||
fs.writeFileSync(path.join(distDir, "standalone", "server.js"), "// server");
|
||||
fs.mkdirSync(path.join(distDir, "static"), { recursive: true });
|
||||
fs.writeFileSync(path.join(distDir, "static", "x.js"), "x");
|
||||
fs.mkdirSync(path.join(tmp, "public"), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmp, "public", "logo.svg"), "<svg/>");
|
||||
|
||||
assembleStandalone({
|
||||
distDir,
|
||||
outDir,
|
||||
projectRoot: tmp,
|
||||
sanitizePaths: false,
|
||||
copyNatives: false,
|
||||
});
|
||||
|
||||
assert.ok(fs.existsSync(path.join(outDir, "server.js")), "server.js copied");
|
||||
// Static lands under the distDir path (.build/next/static), where the standalone
|
||||
// server.js — built with distDir baked into its config — serves /_next/static from.
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(outDir, ".build/next/static/x.js")),
|
||||
"static copied under distDir"
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(outDir, ".next/static/x.js")),
|
||||
"static is NOT placed under a literal .next (would 404 against distDir server)"
|
||||
);
|
||||
assert.ok(fs.existsSync(path.join(outDir, "public/logo.svg")), "public copied");
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Drift guard: the async path (syncStandaloneNativeAssets / syncStandaloneExtraModules,
|
||||
// used by build-next-isolated) and the sync path (assembleStandalone copyNatives, used by
|
||||
// prepublish/electron) must copy the SAME sidecar tree. After the single-source refactor
|
||||
// both derive from NATIVE_ASSET_ENTRIES/EXTRA_MODULE_ENTRIES — this test fails if a future
|
||||
// edit reintroduces two divergent lists.
|
||||
test("async and sync sidecar copy paths produce identical bundle trees", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "assemble-parity-"));
|
||||
const projectRoot = path.join(tmp, "src-root");
|
||||
seedSidecarSources(projectRoot);
|
||||
|
||||
// Async path: copy into outAsync via the exported sync* helpers.
|
||||
const outAsync = path.join(tmp, "out-async");
|
||||
fs.mkdirSync(outAsync, { recursive: true });
|
||||
const silent = { log() {} };
|
||||
// The exported helpers derive the standalone root from NEXT_DIST_DIR; point them at outAsync.
|
||||
await syncStandaloneNativeAssets(projectRoot, fs.promises, silent, outAsync);
|
||||
await syncStandaloneExtraModules(projectRoot, fs.promises, silent, outAsync);
|
||||
|
||||
// Sync path: assembleStandalone(copyNatives) needs a standalone dir to copy first.
|
||||
const distDir = path.join(projectRoot, ".build/next");
|
||||
fs.mkdirSync(path.join(distDir, "standalone"), { recursive: true });
|
||||
fs.writeFileSync(path.join(distDir, "standalone", "server.js"), "// server");
|
||||
const outSync = path.join(tmp, "out-sync");
|
||||
assembleStandalone({
|
||||
distDir,
|
||||
outDir: outSync,
|
||||
projectRoot,
|
||||
sanitizePaths: false,
|
||||
copyNatives: true,
|
||||
});
|
||||
|
||||
const asyncTree = listFiles(outAsync);
|
||||
// The sync path also copies the standalone server.js + patches package.json; compare only
|
||||
// the sidecar files the two paths share (drop server.js which is unique to assembleStandalone).
|
||||
const syncTree = listFiles(outSync).filter((f) => f !== "server.js");
|
||||
|
||||
assert.deepEqual(
|
||||
syncTree,
|
||||
asyncTree,
|
||||
"sync (assembleStandalone) and async (sync*ToDir) must copy the same sidecar tree"
|
||||
);
|
||||
// The TPROXY native addon must land at the cwd-relative path the runtime loader
|
||||
// (transparentSocket.ts) resolves in the standalone bundle.
|
||||
assert.ok(
|
||||
asyncTree.includes("src/mitm/tproxy/native/build/Release/transparent.node"),
|
||||
"TPROXY transparent.node copied into the standalone bundle"
|
||||
);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("the TPROXY addon source is skipped gracefully when it was not built (non-Linux)", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "assemble-skip-"));
|
||||
const projectRoot = path.join(tmp, "src-root");
|
||||
// Seed everything EXCEPT the tproxy addon (simulating a non-Linux build).
|
||||
fs.mkdirSync(projectRoot, { recursive: true });
|
||||
const out = path.join(tmp, "out");
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
// No throw even though src/mitm/tproxy/native/... is absent.
|
||||
await syncStandaloneNativeAssets(projectRoot, fs.promises, { log() {} }, out);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(out, "src/mitm/tproxy/native/build/Release/transparent.node")),
|
||||
"absent addon is simply not copied (graceful skip)"
|
||||
);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
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 {
|
||||
parseSizeLimitResults,
|
||||
measureViaFileStat,
|
||||
runSizeLimit,
|
||||
evaluateBundleSizeRatchet,
|
||||
readBaselineBundleSizeValue,
|
||||
// @ts-expect-error — .mjs helper has no type declarations; runtime shape is known.
|
||||
} from "../../../scripts/check/check-bundle-size.mjs";
|
||||
|
||||
type RatchetVerdict = { regressed: boolean; improved: boolean };
|
||||
const evaluateSize = evaluateBundleSizeRatchet as (
|
||||
current: number,
|
||||
baseline: number
|
||||
) => RatchetVerdict;
|
||||
const readSizeBaseline = readBaselineBundleSizeValue as (p?: string) => number | null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseSizeLimitResults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseSizeLimitResults: soma os campos size de cada entrada", () => {
|
||||
const results = [
|
||||
{ name: "entry-a", size: 1024, passed: true },
|
||||
{ name: "entry-b", size: 2048, passed: true },
|
||||
];
|
||||
assert.equal(parseSizeLimitResults(results), 3072);
|
||||
});
|
||||
|
||||
test("parseSizeLimitResults: ignora entradas sem campo size", () => {
|
||||
const results = [
|
||||
{ name: "entry-a", size: 500 },
|
||||
{ name: "entry-b" }, // sem size
|
||||
];
|
||||
assert.equal(parseSizeLimitResults(results), 500);
|
||||
});
|
||||
|
||||
test("parseSizeLimitResults: lança TypeError para argumento não-array", () => {
|
||||
assert.throws(
|
||||
() => parseSizeLimitResults({ name: "x", size: 100 } as unknown as never[]),
|
||||
TypeError
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSizeLimitResults: lança Error quando nenhuma entrada tem size", () => {
|
||||
const results = [{ name: "entry-a" }, { name: "entry-b" }];
|
||||
assert.throws(() => parseSizeLimitResults(results), Error);
|
||||
});
|
||||
|
||||
test("parseSizeLimitResults: array vazio lança Error (sem medições)", () => {
|
||||
assert.throws(() => parseSizeLimitResults([]), Error);
|
||||
});
|
||||
|
||||
test("parseSizeLimitResults: aceita size=0 como medição válida", () => {
|
||||
const results = [{ name: "empty-entry", size: 0 }];
|
||||
assert.equal(parseSizeLimitResults(results), 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// measureViaFileStat (fallback de leitura direta de arquivo)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeTmpConfig(dir: string, entries: { name: string; path: string }[]): string {
|
||||
const configPath = path.join(dir, ".size-limit.json");
|
||||
fs.writeFileSync(configPath, JSON.stringify(entries));
|
||||
return configPath;
|
||||
}
|
||||
|
||||
function writeTmpFile(dir: string, name: string, content: string): string {
|
||||
const p = path.join(dir, name);
|
||||
fs.writeFileSync(p, content);
|
||||
return p;
|
||||
}
|
||||
|
||||
test("measureViaFileStat: soma bytes de arquivos existentes", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-size-test-"));
|
||||
const content = "hello world!"; // 12 bytes
|
||||
writeTmpFile(dir, "entry.mjs", content);
|
||||
const configPath = makeTmpConfig(dir, [{ name: "Entry", path: "entry.mjs" }]);
|
||||
|
||||
const { total, entries, allMissing } = measureViaFileStat(configPath, dir);
|
||||
|
||||
assert.equal(total, Buffer.byteLength(content));
|
||||
assert.equal(allMissing, false);
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0]!.size, Buffer.byteLength(content));
|
||||
});
|
||||
|
||||
test("measureViaFileStat: arquivo ausente é registrado com size null sem lançar", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-size-test-"));
|
||||
const configPath = makeTmpConfig(dir, [{ name: "Missing", path: "does-not-exist.mjs" }]);
|
||||
|
||||
const { total, entries, allMissing } = measureViaFileStat(configPath, dir);
|
||||
|
||||
assert.equal(total, 0);
|
||||
assert.equal(allMissing, true);
|
||||
assert.equal(entries[0]!.size, null);
|
||||
});
|
||||
|
||||
test("measureViaFileStat: allMissing=false quando pelo menos um arquivo existe", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-size-test-"));
|
||||
writeTmpFile(dir, "present.mjs", "data");
|
||||
const configPath = makeTmpConfig(dir, [
|
||||
{ name: "Present", path: "present.mjs" },
|
||||
{ name: "Missing", path: "missing.mjs" },
|
||||
]);
|
||||
|
||||
const { allMissing, entries } = measureViaFileStat(configPath, dir);
|
||||
|
||||
assert.equal(allMissing, false);
|
||||
assert.equal(entries.filter((e) => e.size !== null).length, 1);
|
||||
});
|
||||
|
||||
test("measureViaFileStat: soma múltiplos arquivos existentes", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-size-test-"));
|
||||
const a = "AAA"; // 3 bytes
|
||||
const b = "BBBBB"; // 5 bytes
|
||||
writeTmpFile(dir, "a.mjs", a);
|
||||
writeTmpFile(dir, "b.mjs", b);
|
||||
const configPath = makeTmpConfig(dir, [
|
||||
{ name: "A", path: "a.mjs" },
|
||||
{ name: "B", path: "b.mjs" },
|
||||
]);
|
||||
|
||||
const { total } = measureViaFileStat(configPath, dir);
|
||||
|
||||
assert.equal(total, Buffer.byteLength(a) + Buffer.byteLength(b));
|
||||
});
|
||||
|
||||
test("measureViaFileStat: config ausente retorna allMissing=true e total=0", () => {
|
||||
const { total, entries, allMissing } = measureViaFileStat(
|
||||
"/tmp/nonexistent/.size-limit.json",
|
||||
"/tmp"
|
||||
);
|
||||
assert.equal(total, 0);
|
||||
assert.equal(allMissing, true);
|
||||
assert.deepEqual(entries, []);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// runSizeLimit — comportamento quando o binário não existe
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("runSizeLimit: lança com code SL_NO_BIN quando binário não existe", () => {
|
||||
assert.throws(
|
||||
() => runSizeLimit("/tmp", "/nonexistent/path/size-limit"),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof Error);
|
||||
assert.equal((err as NodeJS.ErrnoException & { code?: string }).code, "SL_NO_BIN");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// evaluateBundleSizeRatchet — ratchet direction:down (Etapa 2: flip to blocking)
|
||||
// Regression when measured > baseline; baseline=5601 (gzip via @size-limit/file).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("evaluateBundleSizeRatchet: medido == baseline passa (5601 vs 5601)", () => {
|
||||
const r = evaluateSize(5601, 5601);
|
||||
assert.equal(r.regressed, false);
|
||||
assert.equal(r.improved, false);
|
||||
});
|
||||
|
||||
test("evaluateBundleSizeRatchet: um byte a mais que o baseline é regressão", () => {
|
||||
const r = evaluateSize(5602, 5601);
|
||||
assert.equal(r.regressed, true, "any size increase must block");
|
||||
assert.equal(r.improved, false);
|
||||
});
|
||||
|
||||
test("evaluateBundleSizeRatchet: menor que o baseline é melhoria", () => {
|
||||
const r = evaluateSize(5000, 5601);
|
||||
assert.equal(r.regressed, false);
|
||||
assert.equal(r.improved, true);
|
||||
});
|
||||
|
||||
test("evaluateBundleSizeRatchet: comparação inteira estrita — qualquer aumento regride", () => {
|
||||
assert.equal(evaluateSize(5602, 5601).regressed, true);
|
||||
assert.equal(evaluateSize(5601, 5601).regressed, false);
|
||||
assert.equal(evaluateSize(5600, 5601).regressed, false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// readBaselineBundleSizeValue — leitura tolerante do quality-baseline.json
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function withTmpBundleBaseline(content: string | null, fn: (p: string) => void) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-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("readBaselineBundleSizeValue: lê metrics.bundleSize.value", () => {
|
||||
withTmpBundleBaseline(JSON.stringify({ metrics: { bundleSize: { value: 5601 } } }), (p) => {
|
||||
assert.equal(readSizeBaseline(p), 5601);
|
||||
});
|
||||
});
|
||||
|
||||
test("readBaselineBundleSizeValue: arquivo ausente retorna null (SKIP gracioso)", () => {
|
||||
assert.equal(readSizeBaseline("/tmp/does-not-exist-77777/quality-baseline.json"), null);
|
||||
});
|
||||
|
||||
test("readBaselineBundleSizeValue: métrica ausente retorna null", () => {
|
||||
withTmpBundleBaseline(JSON.stringify({ metrics: {} }), (p) => {
|
||||
assert.equal(readSizeBaseline(p), null);
|
||||
});
|
||||
});
|
||||
|
||||
test("readBaselineBundleSizeValue: value não-numérico retorna null", () => {
|
||||
withTmpBundleBaseline(JSON.stringify({ metrics: { bundleSize: { value: "5601" } } }), (p) => {
|
||||
assert.equal(readSizeBaseline(p), null);
|
||||
});
|
||||
});
|
||||
|
||||
test("readBaselineBundleSizeValue: JSON inválido retorna null (não lança)", () => {
|
||||
withTmpBundleBaseline("not json at all", (p) => {
|
||||
assert.equal(readSizeBaseline(p), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// tests/unit/build/check-circular-deps.test.ts
|
||||
// TDD test for the parseDpdmOutput function in check-circular-deps.mjs.
|
||||
// Validates JSON parsing logic without executing dpdm (which is slow and
|
||||
// requires network-resolved node_modules at test time).
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseDpdmOutput } from "../../../scripts/check/check-circular-deps.mjs";
|
||||
|
||||
test("parseDpdmOutput: empty circulars array returns count 0", () => {
|
||||
const result = parseDpdmOutput(JSON.stringify({ circulars: [], tree: {}, entries: [] }));
|
||||
assert.equal(result.count, 0);
|
||||
assert.deepEqual(result.circulars, []);
|
||||
});
|
||||
|
||||
test("parseDpdmOutput: counts each circular path as one entry", () => {
|
||||
const synthetic = {
|
||||
entries: ["src/a.ts", "src/b.ts"],
|
||||
tree: {},
|
||||
circulars: [
|
||||
["src/a.ts", "src/b.ts"],
|
||||
["src/b.ts", "src/a.ts"],
|
||||
["src/c.ts", "src/d.ts", "src/c.ts"],
|
||||
],
|
||||
};
|
||||
const result = parseDpdmOutput(JSON.stringify(synthetic));
|
||||
assert.equal(result.count, 3);
|
||||
assert.equal(result.circulars.length, 3);
|
||||
});
|
||||
|
||||
test("parseDpdmOutput: preserves circular path arrays", () => {
|
||||
const path1 = ["src/lib/db/core.ts", "src/lib/db/settings.ts"];
|
||||
const path2 = ["open-sse/handlers/chatCore.ts", "open-sse/services/combo.ts"];
|
||||
const result = parseDpdmOutput(JSON.stringify({ circulars: [path1, path2] }));
|
||||
assert.equal(result.count, 2);
|
||||
assert.deepEqual(result.circulars[0], path1);
|
||||
assert.deepEqual(result.circulars[1], path2);
|
||||
});
|
||||
|
||||
test("parseDpdmOutput: missing circulars key returns count 0", () => {
|
||||
// dpdm omits circulars when there are none in some versions
|
||||
const result = parseDpdmOutput(JSON.stringify({ entries: [], tree: {} }));
|
||||
assert.equal(result.count, 0);
|
||||
assert.deepEqual(result.circulars, []);
|
||||
});
|
||||
|
||||
test("parseDpdmOutput: null circulars treated as empty", () => {
|
||||
// defensive: dpdm could theoretically return null
|
||||
const result = parseDpdmOutput(JSON.stringify({ circulars: null }));
|
||||
assert.equal(result.count, 0);
|
||||
assert.deepEqual(result.circulars, []);
|
||||
});
|
||||
|
||||
test("parseDpdmOutput: throws on invalid JSON", () => {
|
||||
assert.throws(() => parseDpdmOutput("not-json{{{"), /dpdm JSON parse failed/);
|
||||
});
|
||||
|
||||
test("parseDpdmOutput: large synthetic output counts correctly", () => {
|
||||
const circulars = Array.from({ length: 89 }, (_, i) => [
|
||||
`src/lib/module${i}.ts`,
|
||||
`src/lib/dep${i}.ts`,
|
||||
]);
|
||||
const result = parseDpdmOutput(JSON.stringify({ circulars }));
|
||||
assert.equal(result.count, 89);
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
// tests/unit/build/check-codeql-ratchet.test.ts
|
||||
// TDD unit tests for scripts/check/check-codeql-ratchet.mjs — Task 7.3 CodeQL ratchet.
|
||||
//
|
||||
// Strategy: test the exported pure function without calling the GitHub API.
|
||||
// All fixtures are synthetic GitHub API responses.
|
||||
// - parseCodeQLAlerts() — filters + counts open, non-dismissed CodeQL alerts
|
||||
// (Hard Rule #14: dismissed alerts do not count)
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
// @ts-expect-error — .mjs helper has no type declarations; runtime shape is known.
|
||||
import {
|
||||
parseCodeQLAlerts,
|
||||
evaluateCodeqlRatchet,
|
||||
} from "../../../scripts/check/check-codeql-ratchet.mjs";
|
||||
|
||||
type RatchetVerdict = { regressed: boolean; improved: boolean };
|
||||
const evaluate = evaluateCodeqlRatchet as (current: number, baseline: number) => RatchetVerdict;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures — synthetic GitHub code-scanning/alerts API responses
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Helper to build a minimal alert object. */
|
||||
function makeAlert(
|
||||
overrides: {
|
||||
number?: number;
|
||||
state?: "open" | "dismissed" | "fixed";
|
||||
tool?: string;
|
||||
ruleId?: string;
|
||||
severity?: string;
|
||||
securitySeverity?: string;
|
||||
dismissedReason?: string | null;
|
||||
} = {}
|
||||
) {
|
||||
return {
|
||||
number: overrides.number ?? 1,
|
||||
state: overrides.state ?? "open",
|
||||
dismissed_reason: overrides.dismissedReason ?? null,
|
||||
dismissed_at: overrides.dismissedReason ? "2026-01-01T00:00:00Z" : null,
|
||||
tool: {
|
||||
name: overrides.tool ?? "CodeQL",
|
||||
guid: null,
|
||||
version: "2.16.0",
|
||||
},
|
||||
rule: {
|
||||
id: overrides.ruleId ?? "js/sql-injection",
|
||||
name: overrides.ruleId ?? "SQL Injection",
|
||||
severity: overrides.severity ?? "error",
|
||||
security_severity_level: overrides.securitySeverity ?? "high",
|
||||
description: "Vulnerability description",
|
||||
},
|
||||
most_recent_instance: {
|
||||
ref: "refs/heads/main",
|
||||
state: overrides.state ?? "open",
|
||||
},
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T00:00:00Z",
|
||||
url: "https://api.github.com/repos/owner/repo/code-scanning/alerts/1",
|
||||
html_url: "https://github.com/owner/repo/security/code-scanning/1",
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseCodeQLAlerts — input inválido
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseCodeQLAlerts: null retorna alertCount=0", () => {
|
||||
const result = parseCodeQLAlerts(null);
|
||||
assert.equal(result.alertCount, 0);
|
||||
assert.deepEqual(result.bySeverity, {});
|
||||
assert.deepEqual(result.byRule, {});
|
||||
});
|
||||
|
||||
test("parseCodeQLAlerts: undefined retorna alertCount=0", () => {
|
||||
const result = parseCodeQLAlerts(undefined as unknown as null);
|
||||
assert.equal(result.alertCount, 0);
|
||||
});
|
||||
|
||||
test("parseCodeQLAlerts: objeto (não-array) retorna alertCount=0", () => {
|
||||
const result = parseCodeQLAlerts({ number: 1, state: "open" } as unknown as null);
|
||||
assert.equal(result.alertCount, 0);
|
||||
});
|
||||
|
||||
test("parseCodeQLAlerts: string retorna alertCount=0", () => {
|
||||
const result = parseCodeQLAlerts("open" as unknown as null);
|
||||
assert.equal(result.alertCount, 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseCodeQLAlerts — array vazio
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseCodeQLAlerts: array vazio retorna alertCount=0", () => {
|
||||
const result = parseCodeQLAlerts([]);
|
||||
assert.equal(result.alertCount, 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseCodeQLAlerts — Hard Rule #14: dismissed alerts don't count
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseCodeQLAlerts: alerta dismissed NÃO conta (Hard Rule #14)", () => {
|
||||
const alerts = [makeAlert({ state: "dismissed", dismissedReason: "false positive" })];
|
||||
const result = parseCodeQLAlerts(alerts);
|
||||
assert.equal(result.alertCount, 0, "dismissed alert must not be counted");
|
||||
});
|
||||
|
||||
test("parseCodeQLAlerts: alerta dismissed com razão 'used in tests' NÃO conta", () => {
|
||||
const alerts = [makeAlert({ state: "dismissed", dismissedReason: "used in tests" })];
|
||||
const result = parseCodeQLAlerts(alerts);
|
||||
assert.equal(result.alertCount, 0);
|
||||
});
|
||||
|
||||
test("parseCodeQLAlerts: alerta dismissed independente da razão NÃO conta", () => {
|
||||
const alerts = [
|
||||
makeAlert({ state: "dismissed", dismissedReason: "wont fix" }),
|
||||
makeAlert({ number: 2, state: "dismissed", dismissedReason: "false positive" }),
|
||||
makeAlert({ number: 3, state: "dismissed", dismissedReason: "used in tests" }),
|
||||
];
|
||||
const result = parseCodeQLAlerts(alerts);
|
||||
assert.equal(result.alertCount, 0, "all dismissed states must be excluded");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseCodeQLAlerts — estado fixed não conta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseCodeQLAlerts: alerta fixed NÃO conta", () => {
|
||||
const alerts = [makeAlert({ state: "fixed" as "open" | "dismissed" | "fixed" })];
|
||||
const result = parseCodeQLAlerts(alerts);
|
||||
assert.equal(result.alertCount, 0, "fixed alerts are not open, must not count");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseCodeQLAlerts — somente alertas CodeQL (filtra outras ferramentas)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseCodeQLAlerts: alertas de outras ferramentas são ignorados", () => {
|
||||
const alerts = [
|
||||
makeAlert({ tool: "Semgrep", ruleId: "semgrep-rule-1" }),
|
||||
makeAlert({ number: 2, tool: "ESLint", ruleId: "eslint-rule-1" }),
|
||||
];
|
||||
const result = parseCodeQLAlerts(alerts);
|
||||
assert.equal(result.alertCount, 0, "only CodeQL alerts should be counted");
|
||||
});
|
||||
|
||||
test("parseCodeQLAlerts: tool 'CodeQL' (maiúsculas) conta", () => {
|
||||
const alerts = [makeAlert({ tool: "CodeQL" })];
|
||||
const result = parseCodeQLAlerts(alerts);
|
||||
assert.equal(result.alertCount, 1);
|
||||
});
|
||||
|
||||
test("parseCodeQLAlerts: tool 'codeql' (minúsculas) conta (case-insensitive)", () => {
|
||||
const alerts = [makeAlert({ tool: "codeql" })];
|
||||
const result = parseCodeQLAlerts(alerts);
|
||||
assert.equal(result.alertCount, 1);
|
||||
});
|
||||
|
||||
test("parseCodeQLAlerts: tool 'CodeQL Community' também conta (contém 'codeql')", () => {
|
||||
const alerts = [makeAlert({ tool: "CodeQL Community" })];
|
||||
const result = parseCodeQLAlerts(alerts);
|
||||
assert.equal(result.alertCount, 1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseCodeQLAlerts — contagem de alertas open
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseCodeQLAlerts: 1 alerta open CodeQL retorna alertCount=1", () => {
|
||||
const alerts = [makeAlert({ state: "open" })];
|
||||
const result = parseCodeQLAlerts(alerts);
|
||||
assert.equal(result.alertCount, 1);
|
||||
});
|
||||
|
||||
test("parseCodeQLAlerts: 3 alertas open retorna alertCount=3", () => {
|
||||
const alerts = [
|
||||
makeAlert({ number: 1, state: "open" }),
|
||||
makeAlert({ number: 2, state: "open", ruleId: "js/xss" }),
|
||||
makeAlert({ number: 3, state: "open", ruleId: "js/path-injection" }),
|
||||
];
|
||||
const result = parseCodeQLAlerts(alerts);
|
||||
assert.equal(result.alertCount, 3);
|
||||
});
|
||||
|
||||
test("parseCodeQLAlerts: mix de open, dismissed, fixed — conta só open", () => {
|
||||
const alerts = [
|
||||
makeAlert({ number: 1, state: "open" }),
|
||||
makeAlert({ number: 2, state: "dismissed", dismissedReason: "false positive" }),
|
||||
makeAlert({ number: 3, state: "fixed" as "open" | "dismissed" | "fixed" }),
|
||||
makeAlert({ number: 4, state: "open", ruleId: "js/xss" }),
|
||||
];
|
||||
const result = parseCodeQLAlerts(alerts);
|
||||
assert.equal(result.alertCount, 2, "only 2 open, non-dismissed alerts");
|
||||
});
|
||||
|
||||
test("parseCodeQLAlerts: mix de CodeQL e outras ferramentas — conta só CodeQL", () => {
|
||||
const alerts = [
|
||||
makeAlert({ number: 1, tool: "CodeQL", state: "open" }),
|
||||
makeAlert({ number: 2, tool: "Semgrep", state: "open" }),
|
||||
makeAlert({ number: 3, tool: "CodeQL", state: "open", ruleId: "js/xss" }),
|
||||
];
|
||||
const result = parseCodeQLAlerts(alerts);
|
||||
assert.equal(result.alertCount, 2, "only CodeQL open alerts");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseCodeQLAlerts — severidade
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseCodeQLAlerts: coleta bySeverity de security_severity_level", () => {
|
||||
const alerts = [
|
||||
makeAlert({ number: 1, securitySeverity: "critical" }),
|
||||
makeAlert({ number: 2, securitySeverity: "high" }),
|
||||
makeAlert({ number: 3, securitySeverity: "medium" }),
|
||||
makeAlert({ number: 4, securitySeverity: "high" }), // segundo high
|
||||
];
|
||||
const result = parseCodeQLAlerts(alerts);
|
||||
assert.equal(result.alertCount, 4);
|
||||
assert.equal(result.bySeverity["critical"], 1);
|
||||
assert.equal(result.bySeverity["high"], 2);
|
||||
assert.equal(result.bySeverity["medium"], 1);
|
||||
});
|
||||
|
||||
test("parseCodeQLAlerts: alerta sem security_severity_level usa rule.severity", () => {
|
||||
const alerts = [
|
||||
{
|
||||
...makeAlert({ number: 1 }),
|
||||
rule: {
|
||||
id: "js/unused-local-variable",
|
||||
name: "Unused variable",
|
||||
severity: "warning",
|
||||
// sem security_severity_level
|
||||
description: "Local var not used",
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = parseCodeQLAlerts(alerts);
|
||||
assert.equal(result.alertCount, 1);
|
||||
assert.ok(
|
||||
"warning" in result.bySeverity || "unknown" in result.bySeverity,
|
||||
"severity should be captured"
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseCodeQLAlerts — byRule
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseCodeQLAlerts: agrupa por rule.id em byRule", () => {
|
||||
const alerts = [
|
||||
makeAlert({ number: 1, ruleId: "js/sql-injection" }),
|
||||
makeAlert({ number: 2, ruleId: "js/xss" }),
|
||||
makeAlert({ number: 3, ruleId: "js/sql-injection" }), // segundo do mesmo rule
|
||||
];
|
||||
const result = parseCodeQLAlerts(alerts);
|
||||
assert.equal(result.byRule["js/sql-injection"], 2);
|
||||
assert.equal(result.byRule["js/xss"], 1);
|
||||
});
|
||||
|
||||
test("parseCodeQLAlerts: alerta sem rule retorna byRule com 'unknown'", () => {
|
||||
const alert = {
|
||||
number: 1,
|
||||
state: "open",
|
||||
dismissed_reason: null,
|
||||
dismissed_at: null,
|
||||
tool: { name: "CodeQL", guid: null, version: "2.16.0" },
|
||||
// sem rule
|
||||
};
|
||||
const result = parseCodeQLAlerts([alert]);
|
||||
assert.equal(result.alertCount, 1);
|
||||
assert.ok("unknown" in result.byRule);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseCodeQLAlerts — dismissed não contamina contagens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseCodeQLAlerts: dismissed com mesmo ruleId que open — dismissed não aparece em byRule", () => {
|
||||
const alerts = [
|
||||
makeAlert({ number: 1, state: "open", ruleId: "js/sql-injection" }),
|
||||
makeAlert({
|
||||
number: 2,
|
||||
state: "dismissed",
|
||||
ruleId: "js/sql-injection",
|
||||
dismissedReason: "false positive",
|
||||
}),
|
||||
];
|
||||
const result = parseCodeQLAlerts(alerts);
|
||||
assert.equal(result.alertCount, 1);
|
||||
assert.equal(result.byRule["js/sql-injection"], 1, "only the open alert should appear in byRule");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// evaluateCodeqlRatchet — ratchet direction:down (Task 7.3 promote to blocking)
|
||||
// Mirror of evaluateDeadCode: regression when measured > baseline; the baseline
|
||||
// is 0 (clean), so ANY open CodeQL alert is a regression that blocks.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("evaluateCodeqlRatchet: equal to baseline passes (0 vs 0 — clean)", () => {
|
||||
const r = evaluate(0, 0);
|
||||
assert.equal(r.regressed, false);
|
||||
assert.equal(r.improved, false);
|
||||
});
|
||||
|
||||
test("evaluateCodeqlRatchet: one more alert than baseline 0 is a regression", () => {
|
||||
const r = evaluate(1, 0);
|
||||
assert.equal(r.regressed, true, "a single new open CodeQL alert must block");
|
||||
assert.equal(r.improved, false);
|
||||
});
|
||||
|
||||
test("evaluateCodeqlRatchet: fewer alerts than a non-zero baseline is an improvement", () => {
|
||||
const r = evaluate(2, 5);
|
||||
assert.equal(r.regressed, false);
|
||||
assert.equal(r.improved, true);
|
||||
});
|
||||
|
||||
test("evaluateCodeqlRatchet: zero alerts against a non-zero baseline is a maximum improvement", () => {
|
||||
const r = evaluate(0, 5);
|
||||
assert.equal(r.regressed, false);
|
||||
assert.equal(r.improved, true);
|
||||
});
|
||||
|
||||
test("evaluateCodeqlRatchet: strict integer comparison — any increase regresses", () => {
|
||||
assert.equal(evaluate(6, 5).regressed, true);
|
||||
assert.equal(evaluate(5, 5).regressed, false);
|
||||
assert.equal(evaluate(4, 5).regressed, false);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
// tests/unit/build/check-cognitive-complexity.test.ts
|
||||
// Unit tests for the JSON-parsing helper in check-cognitive-complexity.mjs.
|
||||
// These tests validate countCognitiveViolations() against synthetic ESLint
|
||||
// JSON output — no filesystem access, no ESLint subprocess.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { countCognitiveViolations } from "../../../scripts/check/check-cognitive-complexity.mjs";
|
||||
|
||||
// Minimal ESLint JSON message shape used in fixtures.
|
||||
type EslintMessage = {
|
||||
ruleId: string;
|
||||
severity: number;
|
||||
message: string;
|
||||
line: number;
|
||||
column: number;
|
||||
};
|
||||
type EslintFileResult = {
|
||||
filePath: string;
|
||||
messages: EslintMessage[];
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
};
|
||||
|
||||
function makeResult(filePath: string, messages: EslintMessage[]): EslintFileResult {
|
||||
return {
|
||||
filePath,
|
||||
messages,
|
||||
errorCount: messages.filter((m) => m.severity === 2).length,
|
||||
warningCount: messages.filter((m) => m.severity === 1).length,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMsg(ruleId: string): EslintMessage {
|
||||
return { ruleId, severity: 2, message: "violation", line: 1, column: 1 };
|
||||
}
|
||||
|
||||
test("countCognitiveViolations: empty report returns 0", () => {
|
||||
assert.equal(countCognitiveViolations([]), 0);
|
||||
});
|
||||
|
||||
test("countCognitiveViolations: no cognitive-complexity messages returns 0", () => {
|
||||
const report = [
|
||||
makeResult("src/foo.ts", [makeMsg("complexity"), makeMsg("max-lines-per-function")]),
|
||||
];
|
||||
assert.equal(countCognitiveViolations(report), 0);
|
||||
});
|
||||
|
||||
test("countCognitiveViolations: single cognitive-complexity violation in one file", () => {
|
||||
const report = [
|
||||
makeResult("src/foo.ts", [makeMsg("sonarjs/cognitive-complexity")]),
|
||||
];
|
||||
assert.equal(countCognitiveViolations(report), 1);
|
||||
});
|
||||
|
||||
test("countCognitiveViolations: multiple cognitive-complexity violations across files", () => {
|
||||
const report = [
|
||||
makeResult("src/foo.ts", [
|
||||
makeMsg("sonarjs/cognitive-complexity"),
|
||||
makeMsg("sonarjs/cognitive-complexity"),
|
||||
]),
|
||||
makeResult("open-sse/bar.ts", [
|
||||
makeMsg("sonarjs/cognitive-complexity"),
|
||||
]),
|
||||
];
|
||||
assert.equal(countCognitiveViolations(report), 3);
|
||||
});
|
||||
|
||||
test("countCognitiveViolations: ignores unrelated sonarjs rules", () => {
|
||||
const report = [
|
||||
makeResult("src/baz.ts", [
|
||||
makeMsg("sonarjs/no-duplicate-string"),
|
||||
makeMsg("sonarjs/cognitive-complexity"),
|
||||
makeMsg("sonarjs/no-identical-functions"),
|
||||
]),
|
||||
];
|
||||
assert.equal(countCognitiveViolations(report), 1);
|
||||
});
|
||||
|
||||
test("countCognitiveViolations: file with no messages contributes 0", () => {
|
||||
const report = [
|
||||
makeResult("src/clean.ts", []),
|
||||
makeResult("src/complex.ts", [makeMsg("sonarjs/cognitive-complexity")]),
|
||||
];
|
||||
assert.equal(countCognitiveViolations(report), 1);
|
||||
});
|
||||
|
||||
test("countCognitiveViolations: mixes of rule IDs only counts cognitive-complexity", () => {
|
||||
const report = [
|
||||
makeResult("src/mixed.ts", [
|
||||
makeMsg("complexity"),
|
||||
makeMsg("sonarjs/cognitive-complexity"),
|
||||
makeMsg("max-lines-per-function"),
|
||||
makeMsg("sonarjs/cognitive-complexity"),
|
||||
]),
|
||||
];
|
||||
assert.equal(countCognitiveViolations(report), 2);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
// tests/unit/build/check-complexity.test.ts
|
||||
// TDD test for check-complexity.mjs: the complexity ratchet must scan the SAME first-party
|
||||
// scope documented in eslint.complexity.config.mjs `files` and in complexity-baseline.json
|
||||
// (src + open-sse + electron + bin). Task 6A.11 re-baselined the count claiming bin/electron
|
||||
// coverage, but ESLINT_ARGS only passed src+open-sse — a fake-green gap (a god-function added
|
||||
// under bin/ would pass the gate unseen). This test locks the scan scope to the documented one.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { ESLINT_ARGS } from "../../../scripts/check/check-complexity.mjs";
|
||||
|
||||
test("check-complexity scans the full documented scope (src, open-sse, electron, bin)", () => {
|
||||
assert.ok(
|
||||
ESLINT_ARGS.includes("bin"),
|
||||
"ESLINT_ARGS must include 'bin' — a new god-function under bin/ must not pass the gate green",
|
||||
);
|
||||
assert.ok(
|
||||
ESLINT_ARGS.includes("electron"),
|
||||
"ESLINT_ARGS must include 'electron' to match eslint.complexity.config.mjs `files` and the baseline scope",
|
||||
);
|
||||
assert.ok(ESLINT_ARGS.includes("src"), "ESLINT_ARGS must include 'src'");
|
||||
assert.ok(ESLINT_ARGS.includes("open-sse"), "ESLINT_ARGS must include 'open-sse'");
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
// @ts-expect-error — .mjs helper has no type declarations; runtime shape is known.
|
||||
import { parseKnipMetrics } from "../../../scripts/check/check-dead-code.mjs";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures — JSON sintético com formato do knip --reporter json
|
||||
// O reporter emite: { issues: Array<{ file, exports?, files?, types?, ... }> }
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Retorna um JSON vazio válido (nenhum problema encontrado). */
|
||||
function makeEmptyReport() {
|
||||
return { issues: [] };
|
||||
}
|
||||
|
||||
/** Arquivo com 2 exports mortos e 1 tipo morto. */
|
||||
function makeReportWithExports() {
|
||||
return {
|
||||
issues: [
|
||||
{
|
||||
file: "src/lib/utils.ts",
|
||||
exports: [
|
||||
{ name: "unusedHelper", line: 10, col: 0 },
|
||||
{ name: "deadFn", line: 20, col: 0 },
|
||||
],
|
||||
types: [
|
||||
{ name: "DeadType", line: 5, col: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Arquivo morto (inteiro não importado em lugar nenhum). */
|
||||
function makeReportWithDeadFile() {
|
||||
return {
|
||||
issues: [
|
||||
{
|
||||
file: "src/lib/orphan.ts",
|
||||
files: [{ name: "src/lib/orphan.ts" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Misto: 1 arquivo morto + 3 exports mortos em outro arquivo. */
|
||||
function makeReportMixed() {
|
||||
return {
|
||||
issues: [
|
||||
{
|
||||
file: "src/lib/orphan.ts",
|
||||
files: [{ name: "src/lib/orphan.ts" }],
|
||||
},
|
||||
{
|
||||
file: "src/lib/active.ts",
|
||||
exports: [
|
||||
{ name: "deadExport1", line: 1, col: 0 },
|
||||
{ name: "deadExport2", line: 2, col: 0 },
|
||||
],
|
||||
nsExports: [
|
||||
{ name: "deadNsExport", line: 3, col: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Múltiplos tipos de dead exports: types, nsExports, nsTypes, enumMembers. */
|
||||
function makeReportAllExportTypes() {
|
||||
return {
|
||||
issues: [
|
||||
{
|
||||
file: "src/lib/all-types.ts",
|
||||
exports: [{ name: "e1", line: 1, col: 0 }],
|
||||
types: [{ name: "t1", line: 2, col: 0 }],
|
||||
nsExports: [{ name: "ns1", line: 3, col: 0 }],
|
||||
nsTypes: [{ name: "nst1", line: 4, col: 0 }],
|
||||
enumMembers: [{ name: "em1", line: 5, col: 0 }],
|
||||
namespaceMembers: [{ name: "nm1", line: 6, col: 0 }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Testes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseKnipMetrics: report vazio retorna tudo zero", () => {
|
||||
const result = parseKnipMetrics(makeEmptyReport());
|
||||
assert.deepEqual(result, { deadExports: 0, deadFiles: 0, deadTotal: 0 });
|
||||
});
|
||||
|
||||
test("parseKnipMetrics: conta exports mortos e tipos mortos corretamente", () => {
|
||||
const result = parseKnipMetrics(makeReportWithExports());
|
||||
// 2 exports + 1 type = 3 deadExports, 0 deadFiles
|
||||
assert.equal(result.deadExports, 3);
|
||||
assert.equal(result.deadFiles, 0);
|
||||
assert.equal(result.deadTotal, 3);
|
||||
});
|
||||
|
||||
test("parseKnipMetrics: conta arquivos mortos corretamente", () => {
|
||||
const result = parseKnipMetrics(makeReportWithDeadFile());
|
||||
assert.equal(result.deadExports, 0);
|
||||
assert.equal(result.deadFiles, 1);
|
||||
assert.equal(result.deadTotal, 1);
|
||||
});
|
||||
|
||||
test("parseKnipMetrics: relatório misto — arquivos mortos + exports mortos", () => {
|
||||
const result = parseKnipMetrics(makeReportMixed());
|
||||
// 1 arquivo morto + (2 exports + 1 nsExport) = 4 total
|
||||
assert.equal(result.deadExports, 3);
|
||||
assert.equal(result.deadFiles, 1);
|
||||
assert.equal(result.deadTotal, 4);
|
||||
});
|
||||
|
||||
test("parseKnipMetrics: soma todos os tipos de dead export (exports/types/nsExports/nsTypes/enumMembers/namespaceMembers)", () => {
|
||||
const result = parseKnipMetrics(makeReportAllExportTypes());
|
||||
// 1 de cada tipo × 6 tipos = 6 deadExports
|
||||
assert.equal(result.deadExports, 6);
|
||||
assert.equal(result.deadFiles, 0);
|
||||
assert.equal(result.deadTotal, 6);
|
||||
});
|
||||
|
||||
test("parseKnipMetrics: null retorna zeros (input inválido)", () => {
|
||||
const result = parseKnipMetrics(null);
|
||||
assert.deepEqual(result, { deadExports: 0, deadFiles: 0, deadTotal: 0 });
|
||||
});
|
||||
|
||||
test("parseKnipMetrics: input sem campo issues retorna zeros", () => {
|
||||
const result = parseKnipMetrics({ otherField: 123 });
|
||||
assert.deepEqual(result, { deadExports: 0, deadFiles: 0, deadTotal: 0 });
|
||||
});
|
||||
|
||||
test("parseKnipMetrics: entry sem campos de export não incrementa contador", () => {
|
||||
// Arquivo que aparece na lista mas sem exports mortos e sem files
|
||||
const report = {
|
||||
issues: [
|
||||
{ file: "src/lib/clean.ts" },
|
||||
],
|
||||
};
|
||||
const result = parseKnipMetrics(report);
|
||||
assert.deepEqual(result, { deadExports: 0, deadFiles: 0, deadTotal: 0 });
|
||||
});
|
||||
|
||||
test("parseKnipMetrics: deadTotal == deadExports + deadFiles sempre", () => {
|
||||
const report = makeReportMixed();
|
||||
const result = parseKnipMetrics(report);
|
||||
assert.equal(result.deadTotal, result.deadExports + result.deadFiles);
|
||||
});
|
||||
|
||||
test("parseKnipMetrics: múltiplos arquivos mortos no mesmo relatório", () => {
|
||||
const report = {
|
||||
issues: [
|
||||
{ file: "src/lib/orphan1.ts", files: [{ name: "src/lib/orphan1.ts" }] },
|
||||
{ file: "src/lib/orphan2.ts", files: [{ name: "src/lib/orphan2.ts" }] },
|
||||
{ file: "src/lib/orphan3.ts", files: [{ name: "src/lib/orphan3.ts" }] },
|
||||
],
|
||||
};
|
||||
const result = parseKnipMetrics(report);
|
||||
assert.equal(result.deadFiles, 3);
|
||||
assert.equal(result.deadExports, 0);
|
||||
assert.equal(result.deadTotal, 3);
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
// tests/unit/build/check-licenses.test.ts
|
||||
// TDD unit tests for scripts/check/check-licenses.mjs — Task 7.20 license compliance.
|
||||
//
|
||||
// Strategy: test the three exported pure functions without spawning license-checker
|
||||
// or reading the real .license-allowlist.json. All fixtures are synthetic.
|
||||
// - loadAllowlist() — parses + validates the allowlist JSON shape
|
||||
// - classifyLicense() — core policy decision (allowed / exception / denied)
|
||||
// - stripVersion() — strips @version suffix from package keys
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
// @ts-expect-error — .mjs helper has no type declarations; runtime shape is known.
|
||||
import {
|
||||
classifyLicense,
|
||||
stripVersion,
|
||||
loadAllowlist,
|
||||
} from "../../../scripts/check/check-licenses.mjs";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — synthetic allowlists for testing classifyLicense in isolation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeAllowlist(overrides: Partial<{
|
||||
allowed: string[];
|
||||
allowedExpressions: string[];
|
||||
exceptions: Record<string, { license: string; justification: string; risk: string }>;
|
||||
}> = {}) {
|
||||
return {
|
||||
allowed: ["MIT", "Apache-2.0", "BSD-3-Clause", "ISC", "0BSD"],
|
||||
allowedExpressions: ["(MIT OR Apache-2.0)", "MIT AND ISC", "MIT*"],
|
||||
exceptions: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// stripVersion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("stripVersion: strips @version from a regular package", () => {
|
||||
assert.equal(stripVersion("lodash@4.17.21"), "lodash");
|
||||
});
|
||||
|
||||
test("stripVersion: strips @version from a scoped package", () => {
|
||||
assert.equal(stripVersion("@img/sharp-libvips-linux-x64@1.2.4"), "@img/sharp-libvips-linux-x64");
|
||||
});
|
||||
|
||||
test("stripVersion: returns bare name unchanged (no version)", () => {
|
||||
assert.equal(stripVersion("lodash"), "lodash");
|
||||
});
|
||||
|
||||
test("stripVersion: handles scoped package without version", () => {
|
||||
assert.equal(stripVersion("@scope/pkg"), "@scope/pkg");
|
||||
});
|
||||
|
||||
test("stripVersion: handles nested scope-like name with version", () => {
|
||||
assert.equal(stripVersion("@aws-sdk/client-bedrock-runtime@3.1063.0"), "@aws-sdk/client-bedrock-runtime");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// classifyLicense — allowed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("classifyLicense: MIT is allowed", () => {
|
||||
const result = classifyLicense("some-pkg@1.0.0", "MIT", makeAllowlist());
|
||||
assert.equal(result.status, "allowed");
|
||||
});
|
||||
|
||||
test("classifyLicense: Apache-2.0 is allowed", () => {
|
||||
const result = classifyLicense("some-pkg@1.0.0", "Apache-2.0", makeAllowlist());
|
||||
assert.equal(result.status, "allowed");
|
||||
});
|
||||
|
||||
test("classifyLicense: ISC is allowed", () => {
|
||||
const result = classifyLicense("some-pkg@1.0.0", "ISC", makeAllowlist());
|
||||
assert.equal(result.status, "allowed");
|
||||
});
|
||||
|
||||
test("classifyLicense: 0BSD is allowed", () => {
|
||||
const result = classifyLicense("some-pkg@1.0.0", "0BSD", makeAllowlist());
|
||||
assert.equal(result.status, "allowed");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// classifyLicense — allowed expressions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("classifyLicense: (MIT OR Apache-2.0) expression is allowed", () => {
|
||||
const result = classifyLicense("some-pkg@1.0.0", "(MIT OR Apache-2.0)", makeAllowlist());
|
||||
assert.equal(result.status, "allowed");
|
||||
});
|
||||
|
||||
test("classifyLicense: MIT AND ISC expression is allowed", () => {
|
||||
const result = classifyLicense("some-pkg@1.0.0", "MIT AND ISC", makeAllowlist());
|
||||
assert.equal(result.status, "allowed");
|
||||
});
|
||||
|
||||
test("classifyLicense: MIT* expression is allowed (e.g. khroma)", () => {
|
||||
const result = classifyLicense("khroma@2.1.0", "MIT*", makeAllowlist());
|
||||
assert.equal(result.status, "allowed");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// classifyLicense — denied
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("classifyLicense: GPL-3.0 is denied", () => {
|
||||
const result = classifyLicense("gpl-pkg@1.0.0", "GPL-3.0", makeAllowlist());
|
||||
assert.equal(result.status, "denied");
|
||||
assert.ok(result.reason.includes("GPL-3.0"), `reason should mention license: ${result.reason}`);
|
||||
});
|
||||
|
||||
test("classifyLicense: AGPL-3.0 is denied (strong copyleft)", () => {
|
||||
const result = classifyLicense("agpl-pkg@1.0.0", "AGPL-3.0", makeAllowlist());
|
||||
assert.equal(result.status, "denied");
|
||||
});
|
||||
|
||||
test("classifyLicense: LGPL-3.0-or-later is denied without exception", () => {
|
||||
const result = classifyLicense("lgpl-pkg@1.0.0", "LGPL-3.0-or-later", makeAllowlist());
|
||||
assert.equal(result.status, "denied");
|
||||
});
|
||||
|
||||
test("classifyLicense: MPL-2.0 is denied without exception or expression", () => {
|
||||
const result = classifyLicense("mpl-pkg@1.0.0", "MPL-2.0", makeAllowlist());
|
||||
assert.equal(result.status, "denied");
|
||||
});
|
||||
|
||||
test("classifyLicense: unknown/UNKNOWN license is denied", () => {
|
||||
const result = classifyLicense("mystery-pkg@1.0.0", "UNKNOWN", makeAllowlist());
|
||||
assert.equal(result.status, "denied");
|
||||
});
|
||||
|
||||
test("classifyLicense: Custom license is denied", () => {
|
||||
const result = classifyLicense("custom-pkg@1.0.0", "Custom: LICENSE", makeAllowlist());
|
||||
assert.equal(result.status, "denied");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// classifyLicense — exceptions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("classifyLicense: LGPL package with registered exception returns 'exception'", () => {
|
||||
const allowlist = makeAllowlist({
|
||||
exceptions: {
|
||||
"lgpl-native-pkg": {
|
||||
license: "LGPL-3.0-or-later",
|
||||
justification: "Dynamically linked native binary; user can replace.",
|
||||
risk: "low",
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = classifyLicense("lgpl-native-pkg@1.2.3", "LGPL-3.0-or-later", allowlist);
|
||||
assert.equal(result.status, "exception");
|
||||
assert.ok(result.reason.includes("exception"), `reason should mention exception: ${result.reason}`);
|
||||
});
|
||||
|
||||
test("classifyLicense: scoped package with exception: version is stripped for lookup", () => {
|
||||
const allowlist = makeAllowlist({
|
||||
exceptions: {
|
||||
"@img/sharp-libvips-linux-x64": {
|
||||
license: "LGPL-3.0-or-later",
|
||||
justification: "Prebuilt shared lib.",
|
||||
risk: "low",
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = classifyLicense(
|
||||
"@img/sharp-libvips-linux-x64@1.2.4",
|
||||
"LGPL-3.0-or-later",
|
||||
allowlist
|
||||
);
|
||||
assert.equal(result.status, "exception", "scoped exception should be found after version strip");
|
||||
});
|
||||
|
||||
test("classifyLicense: exception does not apply to different package", () => {
|
||||
const allowlist = makeAllowlist({
|
||||
exceptions: {
|
||||
"only-this-pkg": {
|
||||
license: "GPL-3.0",
|
||||
justification: "Special case.",
|
||||
risk: "high",
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = classifyLicense("other-gpl-pkg@1.0.0", "GPL-3.0", allowlist);
|
||||
assert.equal(result.status, "denied", "exception must be per-package, not per-license");
|
||||
});
|
||||
|
||||
test("classifyLicense: exception with risk=medium still returns 'exception' (not denied)", () => {
|
||||
const allowlist = makeAllowlist({
|
||||
exceptions: {
|
||||
"tls-client-node": {
|
||||
license: "Custom: LICENSE",
|
||||
justification: "Commons Clause + Apache-2.0. TODO: revisar.",
|
||||
risk: "medium",
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = classifyLicense("tls-client-node@0.2.0", "Custom: LICENSE", allowlist);
|
||||
assert.equal(result.status, "exception");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// classifyLicense — reason field content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("classifyLicense: denied result includes package name in reason", () => {
|
||||
const result = classifyLicense("bad-pkg@1.0.0", "GPL-3.0", makeAllowlist());
|
||||
assert.ok(
|
||||
result.reason.includes("bad-pkg"),
|
||||
`reason should include package name; got: ${result.reason}`
|
||||
);
|
||||
});
|
||||
|
||||
test("classifyLicense: allowed result mentions the matched license", () => {
|
||||
const result = classifyLicense("ok-pkg@1.0.0", "MIT", makeAllowlist());
|
||||
assert.ok(result.reason.includes("MIT"), `reason should include license; got: ${result.reason}`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// loadAllowlist — shape validation (reads the real .license-allowlist.json)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("loadAllowlist: returns an object with allowed, allowedExpressions, and exceptions keys", () => {
|
||||
const allowlist = loadAllowlist();
|
||||
assert.ok(typeof allowlist === "object" && allowlist !== null, "should be an object");
|
||||
assert.ok(Array.isArray(allowlist.allowed), "allowed should be an array");
|
||||
assert.ok(Array.isArray(allowlist.allowedExpressions), "allowedExpressions should be an array");
|
||||
assert.ok(typeof allowlist.exceptions === "object", "exceptions should be an object");
|
||||
});
|
||||
|
||||
test("loadAllowlist: allowed includes MIT", () => {
|
||||
const allowlist = loadAllowlist();
|
||||
assert.ok(allowlist.allowed.includes("MIT"), "MIT must be in allowed");
|
||||
});
|
||||
|
||||
test("loadAllowlist: allowed includes Apache-2.0", () => {
|
||||
const allowlist = loadAllowlist();
|
||||
assert.ok(allowlist.allowed.includes("Apache-2.0"), "Apache-2.0 must be in allowed");
|
||||
});
|
||||
|
||||
test("loadAllowlist: allowed includes ISC", () => {
|
||||
const allowlist = loadAllowlist();
|
||||
assert.ok(allowlist.allowed.includes("ISC"), "ISC must be in allowed");
|
||||
});
|
||||
|
||||
test("loadAllowlist: exceptions entries have required fields", () => {
|
||||
const allowlist = loadAllowlist();
|
||||
for (const [pkgName, exc] of Object.entries(allowlist.exceptions)) {
|
||||
assert.ok(
|
||||
typeof (exc as any).license === "string",
|
||||
`exceptions.${pkgName}.license should be a string`
|
||||
);
|
||||
assert.ok(
|
||||
typeof (exc as any).justification === "string",
|
||||
`exceptions.${pkgName}.justification should be a string`
|
||||
);
|
||||
assert.ok(
|
||||
typeof (exc as any).risk === "string",
|
||||
`exceptions.${pkgName}.risk should be a string`
|
||||
);
|
||||
assert.ok(
|
||||
(exc as any).justification.length > 10,
|
||||
`exceptions.${pkgName}.justification must be non-trivial (> 10 chars)`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("loadAllowlist: tls-client-node exception has risk=medium (Commons Clause)", () => {
|
||||
const allowlist = loadAllowlist();
|
||||
const exc = allowlist.exceptions["tls-client-node"] as any;
|
||||
assert.ok(exc, "tls-client-node exception must be registered");
|
||||
assert.equal(exc.risk, "medium", "tls-client-node is a medium-risk exception (Commons Clause)");
|
||||
});
|
||||
|
||||
test("loadAllowlist: LGPL packages have registered exceptions", () => {
|
||||
const allowlist = loadAllowlist();
|
||||
const lgplPkgs = ["@img/sharp-libvips-linux-x64", "@img/sharp-libvips-linuxmusl-x64"];
|
||||
for (const pkg of lgplPkgs) {
|
||||
assert.ok(
|
||||
allowlist.exceptions[pkg],
|
||||
`${pkg} (LGPL-3.0-or-later) must have a registered exception`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("loadAllowlist: MPL-2.0 packages have registered exceptions or allowed expressions", () => {
|
||||
const allowlist = loadAllowlist();
|
||||
const mplPkgs = ["lightningcss", "lightningcss-linux-x64-gnu", "lightningcss-linux-x64-musl"];
|
||||
for (const pkg of mplPkgs) {
|
||||
const hasException = Boolean(allowlist.exceptions[pkg]);
|
||||
const mplExpr = allowlist.allowedExpressions.some((e: string) => e.includes("MPL"));
|
||||
assert.ok(
|
||||
hasException || mplExpr,
|
||||
`${pkg} (MPL-2.0) must be in exceptions or have an allowed expression`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration: real allowlist correctly classifies known packages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("integration: classifyLicense passes MIT packages against real allowlist", () => {
|
||||
const allowlist = loadAllowlist();
|
||||
const result = classifyLicense("lodash@4.17.21", "MIT", allowlist);
|
||||
assert.equal(result.status, "allowed");
|
||||
});
|
||||
|
||||
test("integration: classifyLicense passes tls-client-node as exception against real allowlist", () => {
|
||||
const allowlist = loadAllowlist();
|
||||
const result = classifyLicense("tls-client-node@0.2.0", "Custom: LICENSE", allowlist);
|
||||
assert.equal(result.status, "exception");
|
||||
});
|
||||
|
||||
test("integration: classifyLicense denies GPL-3.0 against real allowlist", () => {
|
||||
const allowlist = loadAllowlist();
|
||||
const result = classifyLicense("hypothetical-gpl@1.0.0", "GPL-3.0", allowlist);
|
||||
assert.equal(result.status, "denied");
|
||||
});
|
||||
|
||||
test("integration: classifyLicense denies AGPL-3.0 against real allowlist", () => {
|
||||
const allowlist = loadAllowlist();
|
||||
const result = classifyLicense("hypothetical-agpl@1.0.0", "AGPL-3.0", allowlist);
|
||||
assert.equal(result.status, "denied");
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
// tests/unit/build/check-lockfile.test.ts
|
||||
// TDD tests for check-lockfile.mjs — lockfile policy gate (Task 7.7).
|
||||
//
|
||||
// Strategy: the lockfile-lint binary is an external CLI tool; we do not spawn it
|
||||
// in unit tests. Instead, we test the two exported pure functions:
|
||||
// - getLockfileLintConfig() — returns the policy configuration object
|
||||
// - buildLockfileLintArgs() — maps a config object to the argv array
|
||||
//
|
||||
// This validates the policy settings and the arg-assembly logic without requiring
|
||||
// a real package-lock.json or a network call.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
// @ts-expect-error — .mjs helper has no type declarations; runtime shape is known.
|
||||
import {
|
||||
getLockfileLintConfig,
|
||||
buildLockfileLintArgs,
|
||||
} from "../../../scripts/check/check-lockfile.mjs";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getLockfileLintConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("getLockfileLintConfig: returns an object with required keys", () => {
|
||||
const cfg = getLockfileLintConfig();
|
||||
assert.ok(typeof cfg === "object" && cfg !== null, "config should be an object");
|
||||
assert.ok("lockfilePath" in cfg, "should have lockfilePath");
|
||||
assert.ok("type" in cfg, "should have type");
|
||||
assert.ok("validateHttps" in cfg, "should have validateHttps");
|
||||
assert.ok("validateIntegrity" in cfg, "should have validateIntegrity");
|
||||
assert.ok("allowedHosts" in cfg, "should have allowedHosts");
|
||||
});
|
||||
|
||||
test("getLockfileLintConfig: lockfilePath points to package-lock.json", () => {
|
||||
const cfg = getLockfileLintConfig();
|
||||
assert.ok(
|
||||
cfg.lockfilePath.endsWith("package-lock.json"),
|
||||
`lockfilePath should end with package-lock.json, got: ${cfg.lockfilePath}`
|
||||
);
|
||||
});
|
||||
|
||||
test("getLockfileLintConfig: type is npm", () => {
|
||||
const cfg = getLockfileLintConfig();
|
||||
assert.equal(cfg.type, "npm");
|
||||
});
|
||||
|
||||
test("getLockfileLintConfig: validateHttps is true (HTTPS enforcement)", () => {
|
||||
const cfg = getLockfileLintConfig();
|
||||
assert.equal(cfg.validateHttps, true, "HTTPS enforcement must be enabled");
|
||||
});
|
||||
|
||||
test("getLockfileLintConfig: validateIntegrity is true (integrity enforcement)", () => {
|
||||
const cfg = getLockfileLintConfig();
|
||||
assert.equal(cfg.validateIntegrity, true, "integrity validation must be enabled");
|
||||
});
|
||||
|
||||
test("getLockfileLintConfig: allowedHosts includes npm (official registry)", () => {
|
||||
const cfg = getLockfileLintConfig();
|
||||
assert.ok(Array.isArray(cfg.allowedHosts), "allowedHosts should be an array");
|
||||
assert.ok(
|
||||
cfg.allowedHosts.includes("npm"),
|
||||
"npm must be in allowedHosts (covers registry.npmjs.org)"
|
||||
);
|
||||
});
|
||||
|
||||
test("getLockfileLintConfig: no http:// hosts in allowedHosts", () => {
|
||||
const cfg = getLockfileLintConfig();
|
||||
for (const host of cfg.allowedHosts) {
|
||||
assert.ok(
|
||||
!host.startsWith("http://"),
|
||||
`allowedHosts must not contain http:// URLs, found: ${host}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildLockfileLintArgs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("buildLockfileLintArgs: includes --path and --type", () => {
|
||||
const cfg = getLockfileLintConfig();
|
||||
const args = buildLockfileLintArgs(cfg);
|
||||
assert.ok(args.includes("--path"), "args should include --path");
|
||||
assert.ok(args.includes("--type"), "args should include --type");
|
||||
const pathIdx = args.indexOf("--path");
|
||||
assert.equal(args[pathIdx + 1], cfg.lockfilePath);
|
||||
const typeIdx = args.indexOf("--type");
|
||||
assert.equal(args[typeIdx + 1], cfg.type);
|
||||
});
|
||||
|
||||
test("buildLockfileLintArgs: includes --validate-https when validateHttps=true", () => {
|
||||
const args = buildLockfileLintArgs({
|
||||
lockfilePath: "/tmp/package-lock.json",
|
||||
type: "npm",
|
||||
validateHttps: true,
|
||||
validateIntegrity: false,
|
||||
allowedHosts: [],
|
||||
});
|
||||
assert.ok(args.includes("--validate-https"), "should include --validate-https");
|
||||
});
|
||||
|
||||
test("buildLockfileLintArgs: omits --validate-https when validateHttps=false", () => {
|
||||
const args = buildLockfileLintArgs({
|
||||
lockfilePath: "/tmp/package-lock.json",
|
||||
type: "npm",
|
||||
validateHttps: false,
|
||||
validateIntegrity: false,
|
||||
allowedHosts: [],
|
||||
});
|
||||
assert.ok(!args.includes("--validate-https"), "should not include --validate-https");
|
||||
});
|
||||
|
||||
test("buildLockfileLintArgs: includes --validate-integrity when validateIntegrity=true", () => {
|
||||
const args = buildLockfileLintArgs({
|
||||
lockfilePath: "/tmp/package-lock.json",
|
||||
type: "npm",
|
||||
validateHttps: false,
|
||||
validateIntegrity: true,
|
||||
allowedHosts: [],
|
||||
});
|
||||
assert.ok(args.includes("--validate-integrity"), "should include --validate-integrity");
|
||||
});
|
||||
|
||||
test("buildLockfileLintArgs: omits --validate-integrity when validateIntegrity=false", () => {
|
||||
const args = buildLockfileLintArgs({
|
||||
lockfilePath: "/tmp/package-lock.json",
|
||||
type: "npm",
|
||||
validateHttps: false,
|
||||
validateIntegrity: false,
|
||||
allowedHosts: [],
|
||||
});
|
||||
assert.ok(!args.includes("--validate-integrity"), "should not include --validate-integrity");
|
||||
});
|
||||
|
||||
test("buildLockfileLintArgs: includes --allowed-hosts and its values", () => {
|
||||
const args = buildLockfileLintArgs({
|
||||
lockfilePath: "/tmp/package-lock.json",
|
||||
type: "npm",
|
||||
validateHttps: false,
|
||||
validateIntegrity: false,
|
||||
allowedHosts: ["npm", "myprivatescope"],
|
||||
});
|
||||
assert.ok(args.includes("--allowed-hosts"), "should include --allowed-hosts");
|
||||
assert.ok(args.includes("npm"), "should include npm host");
|
||||
assert.ok(args.includes("myprivatescope"), "should include additional host");
|
||||
});
|
||||
|
||||
test("buildLockfileLintArgs: omits --allowed-hosts when array is empty", () => {
|
||||
const args = buildLockfileLintArgs({
|
||||
lockfilePath: "/tmp/package-lock.json",
|
||||
type: "npm",
|
||||
validateHttps: false,
|
||||
validateIntegrity: false,
|
||||
allowedHosts: [],
|
||||
});
|
||||
assert.ok(!args.includes("--allowed-hosts"), "should not include --allowed-hosts when empty");
|
||||
});
|
||||
|
||||
test("buildLockfileLintArgs: full config produces expected canonical args", () => {
|
||||
const cfg = getLockfileLintConfig();
|
||||
const args = buildLockfileLintArgs(cfg);
|
||||
// Must include all four enforcement flags
|
||||
assert.ok(args.includes("--validate-https"), "must enforce HTTPS");
|
||||
assert.ok(args.includes("--validate-integrity"), "must enforce integrity");
|
||||
assert.ok(args.includes("--allowed-hosts"), "must restrict hosts");
|
||||
assert.ok(args.includes("npm"), "npm must be an allowed host");
|
||||
});
|
||||
|
||||
test("buildLockfileLintArgs: --allowed-hosts values follow immediately after the flag", () => {
|
||||
const args = buildLockfileLintArgs({
|
||||
lockfilePath: "/tmp/package-lock.json",
|
||||
type: "npm",
|
||||
validateHttps: false,
|
||||
validateIntegrity: false,
|
||||
allowedHosts: ["npm", "verdaccio"],
|
||||
});
|
||||
const hostIdx = args.indexOf("--allowed-hosts");
|
||||
assert.ok(hostIdx !== -1, "--allowed-hosts should be present");
|
||||
assert.equal(args[hostIdx + 1], "npm");
|
||||
assert.equal(args[hostIdx + 2], "verdaccio");
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import os from "node:os";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
evaluateMutationRatchet,
|
||||
mutationScoreForFile,
|
||||
measureMutationScores,
|
||||
readBaselineMutationScores,
|
||||
MUTATION_RATCHET_EPS,
|
||||
} from "../../../scripts/check/check-mutation-ratchet.mjs";
|
||||
|
||||
// ── evaluateMutationRatchet: direction UP (score can only improve) ───────────
|
||||
test("mutation ratchet flags a drop (direction up)", () => {
|
||||
assert.equal(evaluateMutationRatchet(72.0, 75.0).regressed, true);
|
||||
assert.equal(evaluateMutationRatchet(76.0, 75.0).regressed, false);
|
||||
assert.equal(evaluateMutationRatchet(76.0, 75.0).improved, true);
|
||||
assert.equal(evaluateMutationRatchet(75.0, 75.0).regressed, false); // equal holds
|
||||
assert.equal(evaluateMutationRatchet(75.0, 75.0).improved, false);
|
||||
});
|
||||
|
||||
// ── eps anti-flake: a drop WITHIN eps holds; a drop BEYOND eps regresses ──────
|
||||
test("mutation ratchet tolerates sub-eps jitter but catches real drops", () => {
|
||||
assert.ok(MUTATION_RATCHET_EPS > 0);
|
||||
// drop of 0.5pt with default eps 1.0 -> within tolerance, not a regression
|
||||
assert.equal(evaluateMutationRatchet(74.5, 75.0).regressed, false);
|
||||
// drop just past eps -> regression
|
||||
assert.equal(evaluateMutationRatchet(75.0 - MUTATION_RATCHET_EPS - 0.01, 75.0).regressed, true);
|
||||
// explicit eps argument is honored (0 eps = strict)
|
||||
assert.equal(evaluateMutationRatchet(74.99, 75.0, 0).regressed, true);
|
||||
});
|
||||
|
||||
// ── mutationScoreForFile: covered score = detected/(detected+survived),
|
||||
// NoCoverage EXCLUDED (it is a coverage gap, not a test-quality signal). ─────
|
||||
test("mutationScoreForFile computes the covered score and excludes NoCoverage", () => {
|
||||
const fileData = {
|
||||
mutants: [
|
||||
{ status: "Killed" },
|
||||
{ status: "Killed" },
|
||||
{ status: "Killed" },
|
||||
{ status: "Timeout" }, // Timeout counts as detected
|
||||
{ status: "Survived" },
|
||||
{ status: "Survived" },
|
||||
{ status: "NoCoverage" }, // excluded from denominator
|
||||
{ status: "NoCoverage" },
|
||||
{ status: "Ignored" }, // excluded (not a valid mutant)
|
||||
],
|
||||
};
|
||||
// detected = 4 (3 Killed + 1 Timeout); denom = 6 (+2 Survived); NoCoverage/Ignored out.
|
||||
assert.ok(Math.abs(mutationScoreForFile(fileData) - (4 / 6) * 100) < 1e-9);
|
||||
});
|
||||
|
||||
test("mutationScoreForFile returns null when there are no covered mutants", () => {
|
||||
assert.equal(mutationScoreForFile({ mutants: [{ status: "NoCoverage" }] }), null);
|
||||
assert.equal(mutationScoreForFile({ mutants: [] }), null);
|
||||
});
|
||||
|
||||
// ── measureMutationScores: per-file scores from a report (and merges batches) ─
|
||||
test("measureMutationScores maps each mutated file to its score", () => {
|
||||
const report = {
|
||||
files: {
|
||||
"src/a.ts": { mutants: [{ status: "Killed" }, { status: "Survived" }] }, // 50
|
||||
"src/b.ts": { mutants: [{ status: "Killed" }, { status: "Killed" }] }, // 100
|
||||
"src/empty.ts": { mutants: [{ status: "NoCoverage" }] }, // null -> omitted
|
||||
},
|
||||
};
|
||||
const scores = measureMutationScores(report);
|
||||
assert.equal(scores["src/a.ts"], 50);
|
||||
assert.equal(scores["src/b.ts"], 100);
|
||||
assert.equal("src/empty.ts" in scores, false);
|
||||
});
|
||||
|
||||
test("measureMutationScores accepts several reports (per-batch) and unions them", () => {
|
||||
const c = { files: { "src/a.ts": { mutants: [{ status: "Killed" }, { status: "Survived" }] } } };
|
||||
const g = { files: { "src/b.ts": { mutants: [{ status: "Killed" }] } } };
|
||||
const scores = measureMutationScores([c, g]);
|
||||
assert.equal(scores["src/a.ts"], 50);
|
||||
assert.equal(scores["src/b.ts"], 100);
|
||||
});
|
||||
|
||||
// ── SAME file split across sibling batches (auth.ts -> a1:1-1109 + a2:1110-2218 by
|
||||
// mutation range): the file's mutants are DISJOINT per batch and must be UNIONED, not
|
||||
// overwritten — else the last batch wins and the score reflects only half the file. ──
|
||||
test("measureMutationScores unions same-file mutants across split batches (not overwrite)", () => {
|
||||
// a1 slice: 1 killed, 1 survived (would score 50 alone)
|
||||
const a1 = {
|
||||
files: { "src/sse/services/auth.ts": { mutants: [{ status: "Killed" }, { status: "Survived" }] } },
|
||||
};
|
||||
// a2 slice: 3 killed (would score 100 alone)
|
||||
const a2 = {
|
||||
files: {
|
||||
"src/sse/services/auth.ts": {
|
||||
mutants: [{ status: "Killed" }, { status: "Killed" }, { status: "Killed" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const scores = measureMutationScores([a1, a2]);
|
||||
// Combined: detected = 4 (1+3), survived = 1 -> 4/5 = 80. NOT 50 (a1) nor 100 (a2).
|
||||
assert.ok(Math.abs(scores["src/sse/services/auth.ts"] - 80) < 1e-9);
|
||||
});
|
||||
|
||||
// ── readBaselineMutationScores: graceful skip when the file/keys are absent ──
|
||||
test("readBaselineMutationScores returns {} when the baseline file is missing", () => {
|
||||
assert.deepEqual(readBaselineMutationScores("/no/such/baseline.json"), {});
|
||||
});
|
||||
|
||||
test("readBaselineMutationScores extracts mutationScore.<path> metric values", () => {
|
||||
// Write a tiny baseline to a temp file and read it back.
|
||||
const tmp = path.join(os.tmpdir(), `mut-baseline-${process.pid}.json`);
|
||||
fs.writeFileSync(
|
||||
tmp,
|
||||
JSON.stringify({
|
||||
metrics: {
|
||||
eslintWarnings: { value: 10, direction: "down" },
|
||||
"mutationScore.src/a.ts": { value: 70, direction: "up", dedicatedGate: true },
|
||||
"mutationScore.src/b.ts": { value: 80, direction: "up", dedicatedGate: true },
|
||||
},
|
||||
})
|
||||
);
|
||||
const base = readBaselineMutationScores(tmp);
|
||||
assert.deepEqual(base, { "src/a.ts": 70, "src/b.ts": 80 });
|
||||
fs.rmSync(tmp, { force: true });
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
moduleFragment,
|
||||
testImportsModule,
|
||||
findCoverageDrift,
|
||||
} from "../../../scripts/check/check-mutation-test-coverage.mjs";
|
||||
|
||||
test("moduleFragment returns the 3-segment suffix without extension", () => {
|
||||
assert.equal(
|
||||
moduleFragment("open-sse/handlers/chatCore/headers.ts"),
|
||||
"handlers/chatCore/headers"
|
||||
);
|
||||
assert.equal(moduleFragment("src/sse/services/auth.ts"), "sse/services/auth");
|
||||
// shallow paths just use what is available
|
||||
assert.equal(moduleFragment("a/b.ts"), "a/b");
|
||||
});
|
||||
|
||||
test("testImportsModule matches static, dynamic and require imports of the module path", () => {
|
||||
const frag = "handlers/chatCore/headers";
|
||||
// static import-from
|
||||
assert.equal(
|
||||
testImportsModule(`import { x } from "@omniroute/open-sse/handlers/chatCore/headers";`, frag),
|
||||
true
|
||||
);
|
||||
// dynamic await import, even split across lines
|
||||
assert.equal(
|
||||
testImportsModule(`const { y } = await import(\n "../../open-sse/handlers/chatCore/headers.ts"\n);`, frag),
|
||||
true
|
||||
);
|
||||
// require()
|
||||
assert.equal(
|
||||
testImportsModule(`const z = require("../../open-sse/handlers/chatCore/headers.ts");`, frag),
|
||||
true
|
||||
);
|
||||
// unrelated module is not matched
|
||||
assert.equal(
|
||||
testImportsModule(`import { a } from "@omniroute/open-sse/handlers/chatCore/idempotency";`, frag),
|
||||
false
|
||||
);
|
||||
// the fragment appearing only in a comment (not an import string) is NOT a match
|
||||
assert.equal(
|
||||
testImportsModule(`// see handlers/chatCore/headers for details\nconst a = 1;`, frag),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("findCoverageDrift flags covering unit tests absent from tap.testFiles", () => {
|
||||
const mutate = [
|
||||
"open-sse/handlers/chatCore/headers.ts",
|
||||
"open-sse/handlers/chatCore/idempotency.ts",
|
||||
"_a_comment_entry",
|
||||
];
|
||||
const tapTestFiles = ["tests/unit/chatcore-headers.test.ts"];
|
||||
const unitTests = [
|
||||
// covers headers, already in tap -> not drift
|
||||
{ path: "tests/unit/chatcore-headers.test.ts", content: `await import("../../open-sse/handlers/chatCore/headers.ts");` },
|
||||
// covers headers, NOT in tap -> drift
|
||||
{ path: "tests/unit/no-memory-header.test.ts", content: `const { isNoMemoryRequested } = await import("../../open-sse/handlers/chatCore/headers.ts");` },
|
||||
// covers idempotency, NOT in tap -> drift
|
||||
{ path: "tests/unit/idempo.test.ts", content: `import { x } from "@omniroute/open-sse/handlers/chatCore/idempotency";` },
|
||||
// covers nothing mutated -> ignored
|
||||
{ path: "tests/unit/unrelated.test.ts", content: `import { z } from "@/lib/foo";` },
|
||||
];
|
||||
const drift = findCoverageDrift({ mutate, tapTestFiles, unitTests });
|
||||
assert.deepEqual(drift["open-sse/handlers/chatCore/headers.ts"], [
|
||||
"tests/unit/no-memory-header.test.ts",
|
||||
]);
|
||||
assert.deepEqual(drift["open-sse/handlers/chatCore/idempotency.ts"], [
|
||||
"tests/unit/idempo.test.ts",
|
||||
]);
|
||||
// comment-only mutate entries are skipped
|
||||
assert.equal("_a_comment_entry" in drift, false);
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
// tests/unit/build/check-openapi-breaking.test.ts
|
||||
// TDD unit tests for scripts/check/check-openapi-breaking.mjs — Fase 8 B.4 oasdiff.
|
||||
//
|
||||
// Strategy:
|
||||
// • parseOasdiffBreaking() — pure parser, tested with synthetic oasdiff JSON
|
||||
// (the REAL shape emitted by `oasdiff breaking --format json`, verified at 1.19.1).
|
||||
// • binary-absent SKIP — spawn the gate with a PATH stripped of oasdiff and assert
|
||||
// it prints `openapiBreaking=SKIP reason=binary-absent` and exits 0 (advisory).
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
// @ts-expect-error — .mjs helper has no type declarations; runtime shape is known.
|
||||
import { parseOasdiffBreaking } from "../../../scripts/check/check-openapi-breaking.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, "../../..");
|
||||
const GATE = path.join(REPO_ROOT, "scripts/check/check-openapi-breaking.mjs");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures — REAL shape of `oasdiff breaking --format json` (oasdiff 1.19.1).
|
||||
// e.g. removing a path emits:
|
||||
// {"id":"api-path-removed-without-deprecation","text":"api path removed...",
|
||||
// "level":3,"operation":"GET","path":"/api/bar","section":"paths",...}
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeBreaking(
|
||||
overrides: {
|
||||
id?: string;
|
||||
text?: string;
|
||||
level?: number;
|
||||
operation?: string;
|
||||
path?: string;
|
||||
} = {}
|
||||
) {
|
||||
return {
|
||||
id: overrides.id ?? "api-path-removed-without-deprecation",
|
||||
text: overrides.text ?? "api path removed without deprecation",
|
||||
level: overrides.level ?? 3,
|
||||
operation: overrides.operation ?? "GET",
|
||||
path: overrides.path ?? "/api/foo",
|
||||
section: "paths",
|
||||
baseSource: { file: "/tmp/base.yaml", line: 12, column: 5 },
|
||||
fingerprint: "33e8aeb41d4a",
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseOasdiffBreaking — empty / invalid input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseOasdiffBreaking: null retorna count=0", () => {
|
||||
const r = parseOasdiffBreaking(null);
|
||||
assert.equal(r.count, 0);
|
||||
assert.deepEqual(r.byId, {});
|
||||
assert.deepEqual(r.byPath, {});
|
||||
assert.deepEqual(r.items, []);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: undefined retorna count=0", () => {
|
||||
const r = parseOasdiffBreaking(undefined as unknown as null);
|
||||
assert.equal(r.count, 0);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: array vazio (sem breaking change) retorna count=0", () => {
|
||||
const r = parseOasdiffBreaking([]);
|
||||
assert.equal(r.count, 0);
|
||||
assert.deepEqual(r.byId, {});
|
||||
assert.deepEqual(r.byPath, {});
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: objeto (não-array) retorna count=0", () => {
|
||||
const r = parseOasdiffBreaking({ id: "x" } as unknown as null);
|
||||
assert.equal(r.count, 0);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: string retorna count=0", () => {
|
||||
const r = parseOasdiffBreaking("breaking" as unknown as null);
|
||||
assert.equal(r.count, 0);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: número retorna count=0", () => {
|
||||
const r = parseOasdiffBreaking(42 as unknown as null);
|
||||
assert.equal(r.count, 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseOasdiffBreaking — counting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseOasdiffBreaking: 1 breaking change retorna count=1", () => {
|
||||
const r = parseOasdiffBreaking([makeBreaking()]);
|
||||
assert.equal(r.count, 1);
|
||||
assert.equal(r.items.length, 1);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: 3 breaking changes retorna count=3", () => {
|
||||
const r = parseOasdiffBreaking([
|
||||
makeBreaking({ id: "api-path-removed-without-deprecation", path: "/api/a" }),
|
||||
makeBreaking({ id: "request-parameter-became-required", path: "/api/b" }),
|
||||
makeBreaking({ id: "response-property-removed", path: "/api/c" }),
|
||||
]);
|
||||
assert.equal(r.count, 3);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: ignora entradas null/não-objeto no array", () => {
|
||||
const r = parseOasdiffBreaking([makeBreaking(), null, 5, makeBreaking({ path: "/api/x" })]);
|
||||
assert.equal(r.count, 2);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseOasdiffBreaking — grouping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseOasdiffBreaking: agrupa por id em byId", () => {
|
||||
const r = parseOasdiffBreaking([
|
||||
makeBreaking({ id: "api-path-removed-without-deprecation", path: "/api/a" }),
|
||||
makeBreaking({ id: "response-property-removed", path: "/api/b" }),
|
||||
makeBreaking({ id: "api-path-removed-without-deprecation", path: "/api/c" }),
|
||||
]);
|
||||
assert.equal(r.byId["api-path-removed-without-deprecation"], 2);
|
||||
assert.equal(r.byId["response-property-removed"], 1);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: agrupa por path em byPath", () => {
|
||||
const r = parseOasdiffBreaking([
|
||||
makeBreaking({ path: "/api/chat", operation: "POST" }),
|
||||
makeBreaking({ path: "/api/chat", operation: "GET" }),
|
||||
makeBreaking({ path: "/api/models" }),
|
||||
]);
|
||||
assert.equal(r.byPath["/api/chat"], 2);
|
||||
assert.equal(r.byPath["/api/models"], 1);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: id ausente usa 'unknown'", () => {
|
||||
const r = parseOasdiffBreaking([{ path: "/api/x", text: "weird" }]);
|
||||
assert.equal(r.count, 1);
|
||||
assert.equal(r.byId["unknown"], 1);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: path ausente usa 'unknown'", () => {
|
||||
const r = parseOasdiffBreaking([{ id: "some-rule", text: "weird" }]);
|
||||
assert.equal(r.count, 1);
|
||||
assert.equal(r.byPath["unknown"], 1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration — binary-absent SKIP (advisory, exit 0)
|
||||
//
|
||||
// Run the gate with a PATH that does NOT contain oasdiff. `findOasdiff()` then
|
||||
// returns null and the gate must SKIP gracefully with exit 0. We point HOME to a
|
||||
// temp dir too so a user-local ~/.local/bin/oasdiff cannot leak into the lookup.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("gate: SKIP graceful (exit 0) quando oasdiff ausente do PATH", () => {
|
||||
const res = spawnSync(process.execPath, [GATE, "--quiet"], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
env: {
|
||||
// Minimal PATH with only the dir holding the node binary — no oasdiff.
|
||||
PATH: path.dirname(process.execPath),
|
||||
HOME: "/nonexistent-home-for-oasdiff-test",
|
||||
BASE_REF: "origin/release/v3.8.26",
|
||||
},
|
||||
});
|
||||
assert.equal(res.status, 0, `expected exit 0, got ${res.status}; stderr: ${res.stderr}`);
|
||||
assert.match(
|
||||
res.stdout,
|
||||
/openapiBreaking=SKIP reason=binary-absent/,
|
||||
`expected binary-absent SKIP, got stdout: ${res.stdout}`
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
// tests/unit/build/check-pr-evidence.test.ts
|
||||
// TDD tests for the evaluatePrBody() pure function in check-pr-evidence.mjs.
|
||||
// Validates that the gate correctly detects outcome claims and evidence blocks.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { evaluatePrBody } from "../../../scripts/check/check-pr-evidence.mjs";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (a) Body with claim + evidence block => PASS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("evaluatePrBody: strong claim + fenced code block with test output => pass", () => {
|
||||
const body = `
|
||||
## Summary
|
||||
Fixed the null-pointer bug.
|
||||
|
||||
All tests pass.
|
||||
|
||||
## Output
|
||||
\`\`\`
|
||||
42 passing (3s)
|
||||
0 failing
|
||||
\`\`\`
|
||||
`;
|
||||
const { result } = evaluatePrBody(body);
|
||||
assert.equal(result, "pass");
|
||||
});
|
||||
|
||||
test("evaluatePrBody: strong claim + inline result code span => pass", () => {
|
||||
const body = "Typecheck is clean. Result: `42 passing`";
|
||||
const { result } = evaluatePrBody(body);
|
||||
assert.equal(result, "pass");
|
||||
});
|
||||
|
||||
test("evaluatePrBody: strong claim + Evidence section header with content => pass", () => {
|
||||
const body = `
|
||||
Fixed the regression.
|
||||
Validated locally.
|
||||
|
||||
## Evidence
|
||||
node --import tsx/esm --test tests/unit/foo.test.ts
|
||||
14 tests passed, 0 failed.
|
||||
`;
|
||||
const { result } = evaluatePrBody(body);
|
||||
assert.equal(result, "pass");
|
||||
});
|
||||
|
||||
test("evaluatePrBody: 'all green' claim + fenced block with PASS token => pass", () => {
|
||||
const body = `
|
||||
All tests are green.
|
||||
|
||||
\`\`\`
|
||||
PASS src/foo.test.ts
|
||||
Test Suites: 1 passed, 1 total
|
||||
Tests: 5 passed, 5 total
|
||||
\`\`\`
|
||||
`;
|
||||
const { result } = evaluatePrBody(body);
|
||||
assert.equal(result, "pass");
|
||||
});
|
||||
|
||||
test("evaluatePrBody: two weak triggers + fenced code block with output => pass", () => {
|
||||
const body = `
|
||||
Added a new endpoint for health checks.
|
||||
Resolves #123.
|
||||
|
||||
\`\`\`
|
||||
npm run test
|
||||
14 passing (1s)
|
||||
\`\`\`
|
||||
`;
|
||||
const { result } = evaluatePrBody(body);
|
||||
assert.equal(result, "pass");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (b) Body with claim + NO evidence block => FAIL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("evaluatePrBody: 'all tests pass' with no evidence => fail", () => {
|
||||
const body = "Fixed the bug. All tests pass and I added a new endpoint.";
|
||||
const { result, reason } = evaluatePrBody(body);
|
||||
assert.equal(result, "fail");
|
||||
assert.ok(reason.includes("Rule #18"), `reason should mention Rule #18, got: ${reason}`);
|
||||
});
|
||||
|
||||
test("evaluatePrBody: 'typecheck is clean' with no evidence => fail", () => {
|
||||
const body = "Refactored the module. Typecheck pass and lint is clean.";
|
||||
const { result } = evaluatePrBody(body);
|
||||
assert.equal(result, "fail");
|
||||
});
|
||||
|
||||
test("evaluatePrBody: 'tests passing' + fenced block with no output-like content => fail", () => {
|
||||
const body = `
|
||||
Tests are passing!
|
||||
|
||||
\`\`\`ts
|
||||
// just a code snippet, not output
|
||||
export function foo() { return 42; }
|
||||
\`\`\`
|
||||
`;
|
||||
const { result } = evaluatePrBody(body);
|
||||
assert.equal(result, "fail");
|
||||
});
|
||||
|
||||
test("evaluatePrBody: two weak triggers + no evidence => fail", () => {
|
||||
const body = "Fixed the issue. Resolves #456. Looks good to me.";
|
||||
const { result } = evaluatePrBody(body);
|
||||
assert.equal(result, "fail");
|
||||
});
|
||||
|
||||
test("evaluatePrBody: 'validated on VPS' with no evidence block => fail", () => {
|
||||
const body = "Validated on VPS. Everything works correctly.";
|
||||
const { result } = evaluatePrBody(body);
|
||||
assert.equal(result, "fail");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (c) Body with no claim terms => PASS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("evaluatePrBody: body without any claim terms => pass", () => {
|
||||
const body = `
|
||||
## Summary
|
||||
Update the README with new provider list.
|
||||
Adds documentation for the new streaming format.
|
||||
`;
|
||||
const { result } = evaluatePrBody(body);
|
||||
assert.equal(result, "pass");
|
||||
});
|
||||
|
||||
test("evaluatePrBody: body with only one weak trigger => pass (not enough to fire)", () => {
|
||||
const body = "Resolves #789 by updating the config file.";
|
||||
const { result } = evaluatePrBody(body);
|
||||
assert.equal(result, "pass");
|
||||
});
|
||||
|
||||
test("evaluatePrBody: body describing a pure docs change with no claims => pass", () => {
|
||||
const body = "Updates ARCHITECTURE.md to reflect the new combo routing design.";
|
||||
const { result } = evaluatePrBody(body);
|
||||
assert.equal(result, "pass");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (d) Empty/missing body => SKIP (or pass — no blocking)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("evaluatePrBody: empty string => skip", () => {
|
||||
const { result } = evaluatePrBody("");
|
||||
assert.equal(result, "skip");
|
||||
});
|
||||
|
||||
test("evaluatePrBody: whitespace-only string => skip", () => {
|
||||
const { result } = evaluatePrBody(" \n \t ");
|
||||
assert.equal(result, "skip");
|
||||
});
|
||||
|
||||
test("evaluatePrBody: undefined body => skip (treat as empty)", () => {
|
||||
// @ts-expect-error — testing defensive path
|
||||
const { result } = evaluatePrBody(undefined);
|
||||
assert.equal(result, "skip");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("evaluatePrBody: fenced block with exit-code output => pass (strong trigger present)", () => {
|
||||
const body = `
|
||||
Fixes the build. Zero errors after this change.
|
||||
|
||||
\`\`\`
|
||||
$ npm run typecheck
|
||||
exit code 0
|
||||
\`\`\`
|
||||
`;
|
||||
const { result } = evaluatePrBody(body);
|
||||
assert.equal(result, "pass");
|
||||
});
|
||||
|
||||
test("evaluatePrBody: evidence section header present but with minimal content => pass (strong trigger)", () => {
|
||||
// "Validated locally" is a strong trigger. Evidence section has 25+ chars.
|
||||
const body = `
|
||||
Validated locally on the production VPS.
|
||||
|
||||
## Validation
|
||||
Ran the full test suite and observed 0 failures from the terminal.
|
||||
`;
|
||||
const { result } = evaluatePrBody(body);
|
||||
assert.equal(result, "pass");
|
||||
});
|
||||
|
||||
test("evaluatePrBody: inline PASSED span counts as evidence", () => {
|
||||
const body = "All tests pass. Result: `PASSED 14 tests`";
|
||||
const { result } = evaluatePrBody(body);
|
||||
assert.equal(result, "pass");
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
// tests/unit/build/check-secrets.test.ts
|
||||
// TDD unit tests for scripts/check/check-secrets.mjs — Task 7.18 gitleaks.
|
||||
//
|
||||
// Strategy: test the exported pure function without spawning gitleaks.
|
||||
// All fixtures are synthetic gitleaks --report-format json outputs.
|
||||
// - parseGitleaksJson() — parses gitleaks findings array
|
||||
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 {
|
||||
parseGitleaksJson,
|
||||
evaluateSecretsRatchet,
|
||||
readBaselineSecretsValue,
|
||||
// @ts-expect-error — .mjs helper has no type declarations; runtime shape is known.
|
||||
} from "../../../scripts/check/check-secrets.mjs";
|
||||
|
||||
type RatchetVerdict = { regressed: boolean; improved: boolean };
|
||||
const evaluate = evaluateSecretsRatchet as (current: number, baseline: number) => RatchetVerdict;
|
||||
const readBaseline = readBaselineSecretsValue as (p?: string) => number | null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures — synthetic gitleaks --report-format json output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Helper to build a minimal gitleaks finding. */
|
||||
function makeFinding(
|
||||
overrides: {
|
||||
ruleId?: string;
|
||||
file?: string;
|
||||
description?: string;
|
||||
startLine?: number;
|
||||
secret?: string;
|
||||
} = {}
|
||||
) {
|
||||
return {
|
||||
Description: overrides.description ?? "GitHub Personal Access Token",
|
||||
StartLine: overrides.startLine ?? 42,
|
||||
EndLine: overrides.startLine ?? 42,
|
||||
StartColumn: 15,
|
||||
EndColumn: 50,
|
||||
Match: "REDACTED",
|
||||
Secret: overrides.secret ?? "ghp_REDACTED",
|
||||
File: overrides.file ?? "src/config/secrets.ts",
|
||||
SymlinkFile: "",
|
||||
Commit: "",
|
||||
Entropy: 4.5,
|
||||
Author: "Developer",
|
||||
Email: "dev@example.com",
|
||||
Date: "2026-01-01T00:00:00Z",
|
||||
Message: "add config",
|
||||
Tags: [],
|
||||
RuleID: overrides.ruleId ?? "github-pat",
|
||||
Fingerprint: "abc123",
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseGitleaksJson — input inválido / vazio
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseGitleaksJson: null retorna findingCount=0", () => {
|
||||
const result = parseGitleaksJson(null);
|
||||
assert.equal(result.findingCount, 0);
|
||||
assert.deepEqual(result.byRule, {});
|
||||
assert.deepEqual(result.byFile, {});
|
||||
});
|
||||
|
||||
test("parseGitleaksJson: undefined retorna findingCount=0", () => {
|
||||
const result = parseGitleaksJson(undefined as unknown as null);
|
||||
assert.equal(result.findingCount, 0);
|
||||
});
|
||||
|
||||
test("parseGitleaksJson: array vazio retorna findingCount=0", () => {
|
||||
const result = parseGitleaksJson([]);
|
||||
assert.equal(result.findingCount, 0);
|
||||
assert.deepEqual(result.byRule, {});
|
||||
assert.deepEqual(result.byFile, {});
|
||||
});
|
||||
|
||||
test("parseGitleaksJson: objeto (não-array) retorna findingCount=0", () => {
|
||||
const result = parseGitleaksJson({ RuleID: "github-pat" } as unknown as null);
|
||||
assert.equal(result.findingCount, 0);
|
||||
});
|
||||
|
||||
test("parseGitleaksJson: string retorna findingCount=0", () => {
|
||||
const result = parseGitleaksJson("findings" as unknown as null);
|
||||
assert.equal(result.findingCount, 0);
|
||||
});
|
||||
|
||||
test("parseGitleaksJson: número retorna findingCount=0", () => {
|
||||
const result = parseGitleaksJson(42 as unknown as null);
|
||||
assert.equal(result.findingCount, 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseGitleaksJson — contagem básica
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseGitleaksJson: 1 finding retorna findingCount=1", () => {
|
||||
const result = parseGitleaksJson([makeFinding()]);
|
||||
assert.equal(result.findingCount, 1);
|
||||
});
|
||||
|
||||
test("parseGitleaksJson: 3 findings retorna findingCount=3", () => {
|
||||
const findings = [
|
||||
makeFinding({ ruleId: "github-pat", file: "src/a.ts" }),
|
||||
makeFinding({ ruleId: "aws-access-key", file: "src/b.ts" }),
|
||||
makeFinding({ ruleId: "generic-api-key", file: "src/c.ts" }),
|
||||
];
|
||||
const result = parseGitleaksJson(findings);
|
||||
assert.equal(result.findingCount, 3);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseGitleaksJson — agrupamento por RuleID
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseGitleaksJson: agrupa por RuleID em byRule", () => {
|
||||
const findings = [
|
||||
makeFinding({ ruleId: "github-pat" }),
|
||||
makeFinding({ ruleId: "aws-access-key" }),
|
||||
makeFinding({ ruleId: "github-pat" }), // segundo github-pat
|
||||
];
|
||||
const result = parseGitleaksJson(findings);
|
||||
assert.equal(result.byRule["github-pat"], 2);
|
||||
assert.equal(result.byRule["aws-access-key"], 1);
|
||||
});
|
||||
|
||||
test("parseGitleaksJson: RuleID ausente usa 'unknown'", () => {
|
||||
const finding = {
|
||||
Description: "Some secret",
|
||||
StartLine: 1,
|
||||
File: "src/x.ts",
|
||||
// sem RuleID
|
||||
};
|
||||
const result = parseGitleaksJson([finding]);
|
||||
assert.equal(result.findingCount, 1);
|
||||
assert.equal(result.byRule["unknown"], 1);
|
||||
});
|
||||
|
||||
test("parseGitleaksJson: suporta campo ruleId (camelCase) como fallback", () => {
|
||||
const finding = {
|
||||
ruleId: "lowercase-rule",
|
||||
File: "src/x.ts",
|
||||
Description: "test",
|
||||
};
|
||||
const result = parseGitleaksJson([finding]);
|
||||
assert.equal(result.byRule["lowercase-rule"], 1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseGitleaksJson — agrupamento por arquivo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseGitleaksJson: agrupa por File em byFile", () => {
|
||||
const findings = [
|
||||
makeFinding({ file: "src/config.ts", ruleId: "github-pat" }),
|
||||
makeFinding({ file: "src/config.ts", ruleId: "aws-access-key" }), // mesmo arquivo
|
||||
makeFinding({ file: "tests/fixtures/token.ts", ruleId: "github-pat" }),
|
||||
];
|
||||
const result = parseGitleaksJson(findings);
|
||||
assert.equal(result.byFile["src/config.ts"], 2);
|
||||
assert.equal(result.byFile["tests/fixtures/token.ts"], 1);
|
||||
});
|
||||
|
||||
test("parseGitleaksJson: File ausente usa 'unknown' em byFile", () => {
|
||||
const finding = {
|
||||
RuleID: "github-pat",
|
||||
Description: "Token",
|
||||
StartLine: 1,
|
||||
// sem File
|
||||
};
|
||||
const result = parseGitleaksJson([finding]);
|
||||
assert.equal(result.byFile["unknown"], 1);
|
||||
});
|
||||
|
||||
test("parseGitleaksJson: suporta campo file (camelCase) como fallback", () => {
|
||||
const finding = {
|
||||
RuleID: "generic-api-key",
|
||||
file: "src/config.js",
|
||||
Description: "test",
|
||||
};
|
||||
const result = parseGitleaksJson([finding]);
|
||||
assert.equal(result.byFile["src/config.js"], 1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseGitleaksJson — entradas inválidas dentro do array
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseGitleaksJson: entradas null dentro do array são ignoradas", () => {
|
||||
const findings = [makeFinding(), null, makeFinding({ ruleId: "aws-access-key" })] as (ReturnType<
|
||||
typeof makeFinding
|
||||
> | null)[];
|
||||
const result = parseGitleaksJson(findings as unknown as ReturnType<typeof makeFinding>[]);
|
||||
assert.equal(result.findingCount, 2, "null entries should be skipped");
|
||||
});
|
||||
|
||||
test("parseGitleaksJson: entradas primitivas dentro do array são ignoradas", () => {
|
||||
const findings = [
|
||||
makeFinding(),
|
||||
"string-entry",
|
||||
42,
|
||||
makeFinding({ ruleId: "aws-access-key" }),
|
||||
] as unknown[];
|
||||
const result = parseGitleaksJson(findings as ReturnType<typeof makeFinding>[]);
|
||||
assert.equal(result.findingCount, 2, "primitive entries should be skipped");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseGitleaksJson — casos de borda do RuleID PascalCase
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseGitleaksJson: RuleID PascalCase como emitido pelo gitleaks", () => {
|
||||
// gitleaks emite RuleID com PascalCase nos campos
|
||||
const finding = {
|
||||
RuleID: "github-fine-grained-pat",
|
||||
File: "config/auth.yaml",
|
||||
Description: "Fine-grained PAT",
|
||||
StartLine: 3,
|
||||
};
|
||||
const result = parseGitleaksJson([finding]);
|
||||
assert.equal(result.byRule["github-fine-grained-pat"], 1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseGitleaksJson — invariantes estruturais
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseGitleaksJson: findingCount == soma de todos os byRule values", () => {
|
||||
const findings = [
|
||||
makeFinding({ ruleId: "a" }),
|
||||
makeFinding({ ruleId: "b" }),
|
||||
makeFinding({ ruleId: "a" }),
|
||||
makeFinding({ ruleId: "c" }),
|
||||
];
|
||||
const result = parseGitleaksJson(findings);
|
||||
const sumByRule = Object.values(result.byRule).reduce((s, n) => s + n, 0);
|
||||
assert.equal(result.findingCount, sumByRule, "findingCount must equal sum of byRule counts");
|
||||
});
|
||||
|
||||
test("parseGitleaksJson: findingCount == soma de todos os byFile values", () => {
|
||||
const findings = [
|
||||
makeFinding({ file: "src/a.ts" }),
|
||||
makeFinding({ file: "src/b.ts" }),
|
||||
makeFinding({ file: "src/a.ts" }),
|
||||
];
|
||||
const result = parseGitleaksJson(findings);
|
||||
const sumByFile = Object.values(result.byFile).reduce((s, n) => s + n, 0);
|
||||
assert.equal(result.findingCount, sumByFile, "findingCount must equal sum of byFile counts");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// evaluateSecretsRatchet — ratchet direction:down (Etapa 2: flip to blocking)
|
||||
// Regression when measured > baseline; baseline=3 → 4+ findings block, 3 passes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("evaluateSecretsRatchet: medida == baseline passa (3 vs 3)", () => {
|
||||
const r = evaluate(3, 3);
|
||||
assert.equal(r.regressed, false);
|
||||
assert.equal(r.improved, false);
|
||||
});
|
||||
|
||||
test("evaluateSecretsRatchet: uma a mais que o baseline é regressão (4 vs 3)", () => {
|
||||
const r = evaluate(4, 3);
|
||||
assert.equal(r.regressed, true, "a single new secret finding must block");
|
||||
assert.equal(r.improved, false);
|
||||
});
|
||||
|
||||
test("evaluateSecretsRatchet: menos que o baseline é melhoria", () => {
|
||||
const r = evaluate(1, 3);
|
||||
assert.equal(r.regressed, false);
|
||||
assert.equal(r.improved, true);
|
||||
});
|
||||
|
||||
test("evaluateSecretsRatchet: zero contra baseline não-zero é melhoria máxima", () => {
|
||||
const r = evaluate(0, 5);
|
||||
assert.equal(r.regressed, false);
|
||||
assert.equal(r.improved, true);
|
||||
});
|
||||
|
||||
test("evaluateSecretsRatchet: comparação inteira estrita — qualquer aumento regride", () => {
|
||||
assert.equal(evaluate(6, 5).regressed, true);
|
||||
assert.equal(evaluate(5, 5).regressed, false);
|
||||
assert.equal(evaluate(4, 5).regressed, false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// readBaselineSecretsValue — leitura tolerante do quality-baseline.json
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function withTmpBaseline(content: string | null, fn: (p: string) => void) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "secrets-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("readBaselineSecretsValue: lê metrics.secretFindings.value", () => {
|
||||
withTmpBaseline(JSON.stringify({ metrics: { secretFindings: { value: 3 } } }), (p) => {
|
||||
assert.equal(readBaseline(p), 3);
|
||||
});
|
||||
});
|
||||
|
||||
test("readBaselineSecretsValue: arquivo ausente retorna null (SKIP gracioso)", () => {
|
||||
assert.equal(readBaseline("/tmp/does-not-exist-99999/quality-baseline.json"), null);
|
||||
});
|
||||
|
||||
test("readBaselineSecretsValue: métrica ausente retorna null", () => {
|
||||
withTmpBaseline(JSON.stringify({ metrics: {} }), (p) => {
|
||||
assert.equal(readBaseline(p), null);
|
||||
});
|
||||
});
|
||||
|
||||
test("readBaselineSecretsValue: value não-numérico retorna null", () => {
|
||||
withTmpBaseline(JSON.stringify({ metrics: { secretFindings: { value: "3" } } }), (p) => {
|
||||
assert.equal(readBaseline(p), null);
|
||||
});
|
||||
});
|
||||
|
||||
test("readBaselineSecretsValue: JSON inválido retorna null (não lança)", () => {
|
||||
withTmpBaseline("{ not valid json", (p) => {
|
||||
assert.equal(readBaseline(p), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
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 { findRunnerMismatches } from "../../../scripts/check/check-test-runner-api.mjs";
|
||||
|
||||
function tmpRepo() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "runner-api-"));
|
||||
fs.mkdirSync(path.join(root, "tests/unit/autoCombo"), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
test("flags a vitest-only-dir test that imports node:test", () => {
|
||||
const root = tmpRepo();
|
||||
fs.writeFileSync(
|
||||
path.join(root, "tests/unit/autoCombo/bad.test.ts"),
|
||||
`import { describe, it } from "node:test";\ndescribe("x", () => it("y", () => {}));\n`
|
||||
);
|
||||
const bad = findRunnerMismatches(root);
|
||||
assert.equal(bad.length, 1);
|
||||
assert.match(bad[0].file, /autoCombo\/bad\.test\.ts$/);
|
||||
assert.match(bad[0].reason, /vitest-only/);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("accepts a vitest-only-dir test that imports vitest", () => {
|
||||
const root = tmpRepo();
|
||||
fs.writeFileSync(
|
||||
path.join(root, "tests/unit/autoCombo/good.test.ts"),
|
||||
`import { describe, it } from "vitest";\ndescribe("x", () => it("y", () => {}));\n`
|
||||
);
|
||||
assert.equal(findRunnerMismatches(root).length, 0);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// tests/unit/build/check-tracked-artifacts.test.ts
|
||||
// TDD test for check-tracked-artifacts.mjs gate.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { checkTrackedArtifacts } from "../../../scripts/check/check-tracked-artifacts.mjs";
|
||||
|
||||
test("checkTrackedArtifacts: empty list passes", () => {
|
||||
const result = checkTrackedArtifacts([]);
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
test("checkTrackedArtifacts: node_modules/ prefix is flagged", () => {
|
||||
const result = checkTrackedArtifacts(["node_modules/some-pkg/index.js"]);
|
||||
assert.equal(result.length, 1);
|
||||
assert.ok(result[0].includes("node_modules/some-pkg/index.js"));
|
||||
});
|
||||
|
||||
test("checkTrackedArtifacts: .next/ prefix is flagged", () => {
|
||||
const result = checkTrackedArtifacts([".next/static/chunks/main.js"]);
|
||||
assert.equal(result.length, 1);
|
||||
});
|
||||
|
||||
test("checkTrackedArtifacts: coverage/ prefix is flagged", () => {
|
||||
const result = checkTrackedArtifacts(["coverage/lcov.info"]);
|
||||
assert.equal(result.length, 1);
|
||||
});
|
||||
|
||||
test("checkTrackedArtifacts: quality-metrics.json is flagged", () => {
|
||||
const result = checkTrackedArtifacts(["quality-metrics.json"]);
|
||||
assert.equal(result.length, 1);
|
||||
});
|
||||
|
||||
test("checkTrackedArtifacts: config/quality/quality-metrics.json is flagged", () => {
|
||||
const result = checkTrackedArtifacts(["config/quality/quality-metrics.json"]);
|
||||
assert.equal(result.length, 1);
|
||||
});
|
||||
|
||||
test("checkTrackedArtifacts: symlink mode (120000) is flagged", () => {
|
||||
const result = checkTrackedArtifacts([], ["node_modules"]);
|
||||
assert.equal(result.length, 1);
|
||||
assert.ok(result[0].includes("node_modules"));
|
||||
});
|
||||
|
||||
test("checkTrackedArtifacts: normal source files pass", () => {
|
||||
const result = checkTrackedArtifacts([
|
||||
"src/app/page.tsx",
|
||||
"open-sse/handlers/chat.ts",
|
||||
"package.json",
|
||||
"tests/unit/some.test.ts",
|
||||
]);
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
test("checkTrackedArtifacts: multiple violations reported", () => {
|
||||
const result = checkTrackedArtifacts([
|
||||
"src/ok.ts",
|
||||
"node_modules/.bin/jscpd",
|
||||
".next/server/app/page.js",
|
||||
"coverage/lcov.info",
|
||||
]);
|
||||
assert.equal(result.length, 3);
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
// tests/unit/build/check-type-coverage.test.ts
|
||||
// Unit tests for the parseTypeCoverageOutput() helper in check-type-coverage.mjs.
|
||||
// Tests exercise the pure parsing logic against synthetic JSON strings — no child
|
||||
// process is spawned, so the suite is fast and hermetic.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseTypeCoverageOutput } from "../../../scripts/check/check-type-coverage.mjs";
|
||||
|
||||
test("parseTypeCoverageOutput: parses standard type-coverage JSON output", () => {
|
||||
const raw = JSON.stringify({
|
||||
succeeded: true,
|
||||
atLeastFailed: false,
|
||||
correctCount: 246617,
|
||||
percent: 91.66,
|
||||
percentString: "91.66",
|
||||
totalCount: 269047,
|
||||
});
|
||||
const pct = parseTypeCoverageOutput(raw);
|
||||
assert.strictEqual(pct, 91.66);
|
||||
});
|
||||
|
||||
test("parseTypeCoverageOutput: returns integer percent when exactly 100", () => {
|
||||
const raw = JSON.stringify({
|
||||
succeeded: true,
|
||||
atLeastFailed: false,
|
||||
correctCount: 1000,
|
||||
percent: 100,
|
||||
percentString: "100",
|
||||
totalCount: 1000,
|
||||
});
|
||||
const pct = parseTypeCoverageOutput(raw);
|
||||
assert.strictEqual(pct, 100);
|
||||
});
|
||||
|
||||
test("parseTypeCoverageOutput: returns 0 when everything is untyped", () => {
|
||||
const raw = JSON.stringify({
|
||||
succeeded: true,
|
||||
atLeastFailed: false,
|
||||
correctCount: 0,
|
||||
percent: 0,
|
||||
percentString: "0",
|
||||
totalCount: 500,
|
||||
});
|
||||
const pct = parseTypeCoverageOutput(raw);
|
||||
assert.strictEqual(pct, 0);
|
||||
});
|
||||
|
||||
test("parseTypeCoverageOutput: handles high-precision decimals", () => {
|
||||
const raw = JSON.stringify({
|
||||
succeeded: true,
|
||||
atLeastFailed: false,
|
||||
correctCount: 173155,
|
||||
percent: 96.98,
|
||||
percentString: "96.98",
|
||||
totalCount: 178539,
|
||||
});
|
||||
const pct = parseTypeCoverageOutput(raw);
|
||||
assert.strictEqual(pct, 96.98);
|
||||
});
|
||||
|
||||
test("parseTypeCoverageOutput: throws on invalid JSON", () => {
|
||||
assert.throws(
|
||||
() => parseTypeCoverageOutput("not-valid-json"),
|
||||
/Failed to parse JSON output/,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseTypeCoverageOutput: throws when percent field is missing", () => {
|
||||
const raw = JSON.stringify({ succeeded: true, correctCount: 100 });
|
||||
assert.throws(
|
||||
() => parseTypeCoverageOutput(raw),
|
||||
/missing numeric 'percent' field/,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseTypeCoverageOutput: throws when percent field is a string instead of number", () => {
|
||||
const raw = JSON.stringify({
|
||||
succeeded: true,
|
||||
percent: "91.66",
|
||||
percentString: "91.66",
|
||||
totalCount: 100,
|
||||
correctCount: 91,
|
||||
});
|
||||
assert.throws(
|
||||
() => parseTypeCoverageOutput(raw),
|
||||
/missing numeric 'percent' field/,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseTypeCoverageOutput: throws on empty string", () => {
|
||||
assert.throws(
|
||||
() => parseTypeCoverageOutput(""),
|
||||
/Failed to parse JSON output/,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseTypeCoverageOutput: ignores extra unknown fields", () => {
|
||||
const raw = JSON.stringify({
|
||||
succeeded: false,
|
||||
atLeastFailed: true,
|
||||
correctCount: 50,
|
||||
percent: 50.0,
|
||||
percentString: "50.00",
|
||||
totalCount: 100,
|
||||
extra: "ignored",
|
||||
});
|
||||
const pct = parseTypeCoverageOutput(raw);
|
||||
assert.strictEqual(pct, 50);
|
||||
});
|
||||
@@ -0,0 +1,407 @@
|
||||
// tests/unit/build/check-vuln-ratchet.test.ts
|
||||
// TDD unit tests for scripts/check/check-vuln-ratchet.mjs — Task 7.2 osv-scanner.
|
||||
//
|
||||
// Strategy: test the two exported pure functions without spawning osv-scanner
|
||||
// or touching the filesystem. All fixtures are synthetic.
|
||||
// - parseOsvJson() — parses osv-scanner --format json output
|
||||
// - extractSeverity() — extracts severity from a vulnerability entry
|
||||
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 {
|
||||
parseOsvJson,
|
||||
extractSeverity,
|
||||
evaluateVulnRatchet,
|
||||
readBaselineVulnValue,
|
||||
// @ts-expect-error — .mjs helper has no type declarations; runtime shape is known.
|
||||
} from "../../../scripts/check/check-vuln-ratchet.mjs";
|
||||
|
||||
type RatchetVerdict = { regressed: boolean; improved: boolean };
|
||||
const evaluate = evaluateVulnRatchet as (current: number, baseline: number) => RatchetVerdict;
|
||||
const readBaseline = readBaselineVulnValue as (p?: string) => number | null;
|
||||
|
||||
const SCRIPT_PATH = fileURLToPath(
|
||||
new URL("../../../scripts/check/check-vuln-ratchet.mjs", import.meta.url)
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures — JSON sintético com formato do osv-scanner --format json
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Resultado vazio (nenhuma vulnerabilidade encontrada). */
|
||||
function makeEmptyResult() {
|
||||
return { results: [] };
|
||||
}
|
||||
|
||||
/** Um pacote com 1 vulnerabilidade, sem groups. */
|
||||
function makeResultOnePkg(vulnCount: number) {
|
||||
const vulnerabilities = Array.from({ length: vulnCount }, (_, i) => ({
|
||||
id: `GHSA-fake-${i + 1}`,
|
||||
aliases: [],
|
||||
affected: [],
|
||||
}));
|
||||
return {
|
||||
results: [
|
||||
{
|
||||
packages: [
|
||||
{
|
||||
package: { name: "lodash", version: "4.17.0", ecosystem: "npm" },
|
||||
vulnerabilities,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Dois pacotes em dois resultados, sem groups. */
|
||||
function makeResultTwoPkgs() {
|
||||
return {
|
||||
results: [
|
||||
{
|
||||
packages: [
|
||||
{
|
||||
package: { name: "pkg-a", version: "1.0.0", ecosystem: "npm" },
|
||||
vulnerabilities: [
|
||||
{ id: "GHSA-aaa-1", aliases: [], affected: [] },
|
||||
{ id: "GHSA-aaa-2", aliases: [], affected: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
packages: [
|
||||
{
|
||||
package: { name: "pkg-b", version: "2.0.0", ecosystem: "npm" },
|
||||
vulnerabilities: [
|
||||
{ id: "GHSA-bbb-1", aliases: [], affected: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Pacote com groups para deduplicação. */
|
||||
function makeResultWithGroups() {
|
||||
return {
|
||||
results: [
|
||||
{
|
||||
packages: [
|
||||
{
|
||||
package: { name: "pkg-c", version: "3.0.0", ecosystem: "npm" },
|
||||
// 3 vulnerabilidades mas apenas 2 grupos (1 foi deduplicado)
|
||||
vulnerabilities: [
|
||||
{ id: "GHSA-ccc-1", aliases: [], affected: [] },
|
||||
{ id: "GHSA-ccc-2", aliases: [], affected: [] },
|
||||
{ id: "GHSA-ccc-3", aliases: [], affected: [] },
|
||||
],
|
||||
groups: [
|
||||
{ ids: ["GHSA-ccc-1"] },
|
||||
{ ids: ["GHSA-ccc-2", "GHSA-ccc-3"] }, // grupo deduplicado
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Pacote com severidade em database_specific.severity. */
|
||||
function makeResultWithDbSeverity() {
|
||||
return {
|
||||
results: [
|
||||
{
|
||||
packages: [
|
||||
{
|
||||
package: { name: "pkg-sev", version: "1.0.0", ecosystem: "npm" },
|
||||
vulnerabilities: [
|
||||
{
|
||||
id: "GHSA-sev-1",
|
||||
database_specific: { severity: "HIGH" },
|
||||
aliases: [],
|
||||
affected: [],
|
||||
},
|
||||
{
|
||||
id: "GHSA-sev-2",
|
||||
database_specific: { severity: "CRITICAL" },
|
||||
aliases: [],
|
||||
affected: [],
|
||||
},
|
||||
{
|
||||
id: "GHSA-sev-3",
|
||||
database_specific: { severity: "low" }, // lowercase
|
||||
aliases: [],
|
||||
affected: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseOsvJson — input inválido
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseOsvJson: null retorna vulnCount=0", () => {
|
||||
const result = parseOsvJson(null);
|
||||
assert.equal(result.vulnCount, 0);
|
||||
assert.deepEqual(result.bySeverity, {});
|
||||
});
|
||||
|
||||
test("parseOsvJson: undefined retorna vulnCount=0", () => {
|
||||
const result = parseOsvJson(undefined as unknown as null);
|
||||
assert.equal(result.vulnCount, 0);
|
||||
});
|
||||
|
||||
test("parseOsvJson: objeto sem results retorna vulnCount=0", () => {
|
||||
const result = parseOsvJson({ other: [] } as unknown as { results: never[] });
|
||||
assert.equal(result.vulnCount, 0);
|
||||
});
|
||||
|
||||
test("parseOsvJson: results não-array retorna vulnCount=0", () => {
|
||||
const result = parseOsvJson({ results: "invalid" } as unknown as { results: never[] });
|
||||
assert.equal(result.vulnCount, 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseOsvJson — resultado vazio
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseOsvJson: results vazio retorna vulnCount=0", () => {
|
||||
const result = parseOsvJson(makeEmptyResult());
|
||||
assert.equal(result.vulnCount, 0);
|
||||
assert.deepEqual(result.bySeverity, {});
|
||||
});
|
||||
|
||||
test("parseOsvJson: result sem packages retorna vulnCount=0", () => {
|
||||
const result = parseOsvJson({ results: [{ other: "data" }] } as unknown as { results: { packages: never[] }[] });
|
||||
assert.equal(result.vulnCount, 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseOsvJson — contagem básica
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseOsvJson: 1 pacote com 1 vuln retorna vulnCount=1", () => {
|
||||
const result = parseOsvJson(makeResultOnePkg(1));
|
||||
assert.equal(result.vulnCount, 1);
|
||||
});
|
||||
|
||||
test("parseOsvJson: 1 pacote com 3 vulns retorna vulnCount=3", () => {
|
||||
const result = parseOsvJson(makeResultOnePkg(3));
|
||||
assert.equal(result.vulnCount, 3);
|
||||
});
|
||||
|
||||
test("parseOsvJson: 2 pacotes em 2 results retorna vulnCount=3 (2+1)", () => {
|
||||
const result = parseOsvJson(makeResultTwoPkgs());
|
||||
assert.equal(result.vulnCount, 3);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseOsvJson — deduplicação via groups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseOsvJson: usa groups.length quando groups tem menos entradas que vulnerabilities", () => {
|
||||
// 3 vulns, 2 grupos => vulnCount=2 (deduplicado)
|
||||
const result = parseOsvJson(makeResultWithGroups());
|
||||
assert.equal(result.vulnCount, 2);
|
||||
});
|
||||
|
||||
test("parseOsvJson: usa vulnerabilities.length quando groups está ausente", () => {
|
||||
// Sem groups => conta vulnerabilities diretamente
|
||||
const result = parseOsvJson(makeResultOnePkg(5));
|
||||
assert.equal(result.vulnCount, 5);
|
||||
});
|
||||
|
||||
test("parseOsvJson: groups vazio não causa deduplicação (usa vulnerabilities)", () => {
|
||||
const json = {
|
||||
results: [
|
||||
{
|
||||
packages: [
|
||||
{
|
||||
package: { name: "pkg-d", version: "1.0.0", ecosystem: "npm" },
|
||||
vulnerabilities: [
|
||||
{ id: "GHSA-d-1", aliases: [], affected: [] },
|
||||
{ id: "GHSA-d-2", aliases: [], affected: [] },
|
||||
],
|
||||
groups: [], // vazio — não deve deduplicar
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = parseOsvJson(json);
|
||||
assert.equal(result.vulnCount, 2);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseOsvJson — severidade
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseOsvJson: coleta severidade de database_specific.severity", () => {
|
||||
const result = parseOsvJson(makeResultWithDbSeverity());
|
||||
assert.equal(result.vulnCount, 3);
|
||||
assert.ok("HIGH" in result.bySeverity, "deve ter HIGH");
|
||||
assert.ok("CRITICAL" in result.bySeverity, "deve ter CRITICAL");
|
||||
assert.ok("LOW" in result.bySeverity, "deve ter LOW (normalizado para uppercase)");
|
||||
});
|
||||
|
||||
test("parseOsvJson: vuln sem severidade conta como UNKNOWN", () => {
|
||||
const json = makeResultOnePkg(1);
|
||||
// Vuln sem database_specific.severity e sem severity array
|
||||
const result = parseOsvJson(json);
|
||||
assert.ok("UNKNOWN" in result.bySeverity, "vuln sem severidade deve ser UNKNOWN");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractSeverity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("extractSeverity: null retorna UNKNOWN", () => {
|
||||
assert.equal(extractSeverity(null), "UNKNOWN");
|
||||
});
|
||||
|
||||
test("extractSeverity: objeto vazio retorna UNKNOWN", () => {
|
||||
assert.equal(extractSeverity({}), "UNKNOWN");
|
||||
});
|
||||
|
||||
test("extractSeverity: usa database_specific.severity quando presente", () => {
|
||||
const vuln = { database_specific: { severity: "HIGH" } };
|
||||
assert.equal(extractSeverity(vuln), "HIGH");
|
||||
});
|
||||
|
||||
test("extractSeverity: normaliza database_specific.severity para uppercase", () => {
|
||||
const vuln = { database_specific: { severity: "critical" } };
|
||||
assert.equal(extractSeverity(vuln), "CRITICAL");
|
||||
});
|
||||
|
||||
test("extractSeverity: usa severity[0].type quando database_specific ausente", () => {
|
||||
const vuln = { severity: [{ type: "CVSS_V3", score: "CVSS:3.1/AV:N/AC:L" }] };
|
||||
assert.equal(extractSeverity(vuln), "CVSS_V3");
|
||||
});
|
||||
|
||||
test("extractSeverity: database_specific.severity tem precedência sobre severity[]", () => {
|
||||
const vuln = {
|
||||
database_specific: { severity: "HIGH" },
|
||||
severity: [{ type: "CVSS_V2", score: "AV:N/AC:L/Au:N/C:P/I:P/A:P" }],
|
||||
};
|
||||
assert.equal(extractSeverity(vuln), "HIGH");
|
||||
});
|
||||
|
||||
test("extractSeverity: severity array vazio retorna UNKNOWN", () => {
|
||||
const vuln = { severity: [] };
|
||||
assert.equal(extractSeverity(vuln), "UNKNOWN");
|
||||
});
|
||||
|
||||
test("extractSeverity: severity[0] sem type retorna UNKNOWN", () => {
|
||||
const vuln = { severity: [{ score: "AV:N/AC:L" }] };
|
||||
assert.equal(extractSeverity(vuln), "UNKNOWN");
|
||||
});
|
||||
|
||||
test("extractSeverity: database_specific.severity vazia retorna UNKNOWN", () => {
|
||||
const vuln = { database_specific: { severity: "" } };
|
||||
assert.equal(extractSeverity(vuln), "UNKNOWN");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// evaluateVulnRatchet — ratchet direction:down (cycle-end: flip to blocking)
|
||||
// Regression when measured > baseline; baseline=10 → 11+ blocks, 10 passes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("evaluateVulnRatchet: medida == baseline passa (10 vs 10)", () => {
|
||||
const r = evaluate(10, 10);
|
||||
assert.equal(r.regressed, false);
|
||||
assert.equal(r.improved, false);
|
||||
});
|
||||
|
||||
test("evaluateVulnRatchet: uma a mais que o baseline é regressão (11 vs 10)", () => {
|
||||
const r = evaluate(11, 10);
|
||||
assert.equal(r.regressed, true, "a single new vulnerability must block");
|
||||
assert.equal(r.improved, false);
|
||||
});
|
||||
|
||||
test("evaluateVulnRatchet: menos que o baseline é melhoria", () => {
|
||||
const r = evaluate(5, 10);
|
||||
assert.equal(r.regressed, false);
|
||||
assert.equal(r.improved, true);
|
||||
});
|
||||
|
||||
test("evaluateVulnRatchet: zero contra baseline não-zero é melhoria máxima", () => {
|
||||
const r = evaluate(0, 13);
|
||||
assert.equal(r.regressed, false);
|
||||
assert.equal(r.improved, true);
|
||||
});
|
||||
|
||||
test("evaluateVulnRatchet: comparação inteira estrita — qualquer aumento regride", () => {
|
||||
assert.equal(evaluate(11, 10).regressed, true);
|
||||
assert.equal(evaluate(10, 10).regressed, false);
|
||||
assert.equal(evaluate(9, 10).regressed, false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// readBaselineVulnValue — leitura tolerante do quality-baseline.json
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function withTmpBaseline(content: string | null, fn: (p: string) => void) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vuln-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("readBaselineVulnValue: lê metrics.vulnCount.value", () => {
|
||||
withTmpBaseline(JSON.stringify({ metrics: { vulnCount: { value: 10 } } }), (p) => {
|
||||
assert.equal(readBaseline(p), 10);
|
||||
});
|
||||
});
|
||||
|
||||
test("readBaselineVulnValue: arquivo ausente retorna null (SKIP gracioso)", () => {
|
||||
assert.equal(readBaseline("/tmp/does-not-exist-99999/quality-baseline.json"), null);
|
||||
});
|
||||
|
||||
test("readBaselineVulnValue: métrica ausente retorna null", () => {
|
||||
withTmpBaseline(JSON.stringify({ metrics: {} }), (p) => {
|
||||
assert.equal(readBaseline(p), null);
|
||||
});
|
||||
});
|
||||
|
||||
test("readBaselineVulnValue: value não-numérico retorna null", () => {
|
||||
withTmpBaseline(JSON.stringify({ metrics: { vulnCount: { value: "10" } } }), (p) => {
|
||||
assert.equal(readBaseline(p), null);
|
||||
});
|
||||
});
|
||||
|
||||
test("readBaselineVulnValue: JSON inválido retorna null (não lança)", () => {
|
||||
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 osv-scanner is unresolvable.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("--ratchet com osv-scanner ausente (PATH vazio) faz SKIP e sai 0", () => {
|
||||
const res = spawnSync(process.execPath, [SCRIPT_PATH, "--ratchet", "--quiet"], {
|
||||
encoding: "utf8",
|
||||
// Empty PATH → `which`/`osv-scanner` unresolvable → findOsvScanner() 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, /vulnCount=SKIP reason=binary-absent/);
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
// 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;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import {
|
||||
classifyTestFiles,
|
||||
aggregateRadiography,
|
||||
classifyFromCounts,
|
||||
redundancyCandidates,
|
||||
} from "../../../scripts/quality/mutation-radiography.mjs";
|
||||
|
||||
// ── classifyTestFiles: the plan's canonical fixture ──────────────────────────
|
||||
// 2 testFiles that kill mutants + 1 that kills nothing.
|
||||
// m1 killed ONLY by A -> A gets a unique kill
|
||||
// m2 killed by A AND B -> both get a shared kill
|
||||
// m3 survived -> nobody
|
||||
// Universe = [A, B, C]; C never appears in any killedBy -> empty.
|
||||
test("classifies unique / redundant / empty test files from killedBy (file-name killedBy)", () => {
|
||||
const report = {
|
||||
files: {
|
||||
"open-sse/utils/error.ts": {
|
||||
mutants: [
|
||||
{ id: "m1", status: "Killed", killedBy: ["A.test.ts"] },
|
||||
{ id: "m2", status: "Killed", killedBy: ["A.test.ts", "B.test.ts"] },
|
||||
{ id: "m3", status: "Survived", killedBy: [] },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const allTestFiles = ["A.test.ts", "B.test.ts", "C.test.ts"];
|
||||
const r = classifyTestFiles(report, allTestFiles);
|
||||
assert.equal(r["A.test.ts"].class, "unique"); // mata m1 sozinho
|
||||
assert.equal(r["A.test.ts"].uniqueKills, 1);
|
||||
assert.equal(r["A.test.ts"].sharedKills, 1);
|
||||
assert.equal(r["B.test.ts"].class, "redundant"); // só m2 (compartilhado)
|
||||
assert.equal(r["B.test.ts"].uniqueKills, 0);
|
||||
assert.equal(r["C.test.ts"].class, "empty"); // não mata nada
|
||||
});
|
||||
|
||||
// ── id resolution: real Stryker tap-runner reports use numeric test ids in
|
||||
// killedBy and a testFiles{} section mapping id -> file name. ────────────────
|
||||
test("resolves numeric killedBy ids to file names via the testFiles section", () => {
|
||||
const report = {
|
||||
testFiles: {
|
||||
"tests/unit/x.test.ts": { tests: [{ id: "0", name: "tests/unit/x.test.ts" }] },
|
||||
"tests/unit/y.test.ts": { tests: [{ id: "1", name: "tests/unit/y.test.ts" }] },
|
||||
"tests/unit/z.test.ts": { tests: [{ id: "2", name: "tests/unit/z.test.ts" }] },
|
||||
},
|
||||
files: {
|
||||
"src/m.ts": {
|
||||
mutants: [
|
||||
{ id: "m1", status: "Killed", killedBy: ["0"] }, // x alone -> unique
|
||||
{ id: "m2", status: "Killed", killedBy: ["0", "1"] }, // x + y shared
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
// allTestFiles defaults to the testFiles keys when omitted.
|
||||
const r = classifyTestFiles(report);
|
||||
assert.equal(r["tests/unit/x.test.ts"].class, "unique");
|
||||
assert.equal(r["tests/unit/y.test.ts"].class, "redundant");
|
||||
assert.equal(r["tests/unit/z.test.ts"].class, "empty");
|
||||
});
|
||||
|
||||
// ── overlapping (🟡): kills ≥1 unique but the majority of its kills are shared.
|
||||
test("classifies overlapping when shared kills outnumber unique kills", () => {
|
||||
const report = {
|
||||
files: {
|
||||
"src/m.ts": {
|
||||
mutants: [
|
||||
{ id: "m1", status: "Killed", killedBy: ["D"] }, // D unique
|
||||
{ id: "m2", status: "Killed", killedBy: ["D", "E"] }, // D shared
|
||||
{ id: "m3", status: "Killed", killedBy: ["D", "E", "F"] }, // D shared
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const r = classifyTestFiles(report, ["D", "E", "F"]);
|
||||
assert.equal(r["D"].uniqueKills, 1);
|
||||
assert.equal(r["D"].sharedKills, 2);
|
||||
assert.equal(r["D"].class, "overlapping"); // 2 shared > 1 unique
|
||||
assert.equal(r["E"].class, "redundant");
|
||||
assert.equal(r["F"].class, "redundant");
|
||||
});
|
||||
|
||||
// ── classifyFromCounts: the pure threshold helper. ───────────────────────────
|
||||
test("classifyFromCounts applies the threshold rules", () => {
|
||||
assert.equal(classifyFromCounts(0, 0), "empty");
|
||||
assert.equal(classifyFromCounts(0, 3), "redundant");
|
||||
assert.equal(classifyFromCounts(2, 1), "unique"); // shared not > unique
|
||||
assert.equal(classifyFromCounts(1, 1), "unique"); // tie -> unique
|
||||
assert.equal(classifyFromCounts(1, 5), "overlapping"); // shared > unique
|
||||
});
|
||||
|
||||
// ── redundancyCandidates (R1): the prune-candidate list = 🔴 empty ∪ 🟠 redundant.
|
||||
// A file is a candidate iff it has ZERO unique kills (kills nothing, OR every mutant it
|
||||
// kills is also killed by another file). Files with ≥1 unique kill (🟢/🟡) are NEVER
|
||||
// candidates. Under a disableBail run killedBy is COMPLETE, so this list is accurate. ──
|
||||
test("redundancyCandidates returns empty ∪ redundant files; never a file with a unique kill", () => {
|
||||
const report = {
|
||||
testFiles: {
|
||||
"tests/unit/x.test.ts": { tests: [{ id: "0", name: "tests/unit/x.test.ts" }] },
|
||||
"tests/unit/y.test.ts": { tests: [{ id: "1", name: "tests/unit/y.test.ts" }] },
|
||||
"tests/unit/z.test.ts": { tests: [{ id: "2", name: "tests/unit/z.test.ts" }] },
|
||||
},
|
||||
files: {
|
||||
"src/m.ts": {
|
||||
mutants: [
|
||||
{ id: "m1", status: "Killed", killedBy: ["0"] }, // x alone -> unique (KEEP)
|
||||
{ id: "m2", status: "Killed", killedBy: ["0", "1"] }, // x+y; y only ever shared
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const r = redundancyCandidates([report]);
|
||||
assert.deepEqual(r.empty, ["tests/unit/z.test.ts"]); // kills nothing
|
||||
assert.deepEqual(r.redundant, ["tests/unit/y.test.ts"]); // kills only shared mutants
|
||||
assert.deepEqual(r.candidates, ["tests/unit/y.test.ts", "tests/unit/z.test.ts"]);
|
||||
assert.ok(!r.candidates.includes("tests/unit/x.test.ts")); // has a unique kill -> safe
|
||||
});
|
||||
|
||||
// ── aggregateRadiography: merge per-batch reports at the FILE level (ids are
|
||||
// per-run, so each report is classified independently then summed). A file can
|
||||
// be empty in one batch but unique in another -> unique overall. ─────────────
|
||||
test("aggregateRadiography sums per-file kills across batches and reclassifies", () => {
|
||||
const batchC = {
|
||||
files: {
|
||||
"src/routeGuard.ts": {
|
||||
mutants: [{ id: "c1", status: "Killed", killedBy: ["A"] }], // A unique here
|
||||
},
|
||||
},
|
||||
};
|
||||
const batchG = {
|
||||
files: {
|
||||
"src/chatCore/x.ts": {
|
||||
mutants: [
|
||||
{ id: "g1", status: "Killed", killedBy: ["A", "B"] }, // A shared, B shared
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
// B only ever shares; A is unique in C and shared in G -> A unique overall.
|
||||
const agg = aggregateRadiography([batchC, batchG], ["A", "B", "C"]);
|
||||
assert.equal(agg["A"].uniqueKills, 1);
|
||||
assert.equal(agg["A"].sharedKills, 1);
|
||||
assert.equal(agg["A"].class, "unique");
|
||||
assert.equal(agg["B"].class, "redundant");
|
||||
assert.equal(agg["C"].class, "empty");
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Regression guard for #3883: the @omniroute/opencode-plugin must ship an
|
||||
// ESM-only bundle. OpenCode's Bun-based plugin loader resolves the package
|
||||
// `main`/`exports` and applies CJS-to-ESM interop on a dual CJS bundle, which
|
||||
// turns `mod.default` into the whole exports namespace, fails V1 plugin
|
||||
// detection, and makes the loader throw "Plugin export is not a function".
|
||||
// Shipping ESM-only (with a `./runtime` subpath) keeps `mod.default` the V1
|
||||
// plugin object so the loader registers it. Do NOT re-introduce a CJS bundle.
|
||||
|
||||
const PKG_URL = new URL("../../../@omniroute/opencode-plugin/package.json", import.meta.url);
|
||||
const TSUP_URL = new URL("../../../@omniroute/opencode-plugin/tsup.config.ts", import.meta.url);
|
||||
|
||||
function readJson(url: URL): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(fileURLToPath(url), "utf8"));
|
||||
}
|
||||
|
||||
test("opencode-plugin package.json ships ESM-only (no CJS bundle)", () => {
|
||||
const pkg = readJson(PKG_URL);
|
||||
|
||||
// main must point at the ESM build, never the .cjs bundle.
|
||||
assert.equal(pkg.main, "./dist/index.js", "main must be the ESM build");
|
||||
assert.equal(
|
||||
typeof pkg.main === "string" && pkg.main.endsWith(".cjs"),
|
||||
false,
|
||||
"main must not be a .cjs bundle"
|
||||
);
|
||||
// The legacy dual-bundle `module` field should be gone.
|
||||
assert.equal("module" in pkg, false, "the dual-bundle `module` field must be removed");
|
||||
|
||||
const exports = pkg.exports as Record<string, unknown> | undefined;
|
||||
assert.ok(exports && typeof exports === "object", "exports map must exist");
|
||||
|
||||
const root = exports["."] as Record<string, unknown>;
|
||||
assert.ok(root && typeof root === "object", 'exports["."] must exist');
|
||||
assert.equal(root.import, "./dist/index.js", 'exports["."].import must be the ESM build');
|
||||
// A `require` condition would re-introduce the CJS path the loader chokes on.
|
||||
assert.equal("require" in root, false, 'exports["."] must not declare a `require` condition');
|
||||
|
||||
// The `./runtime` subpath used by the OpenCode loader must resolve to ESM.
|
||||
const runtime = exports["./runtime"] as Record<string, unknown>;
|
||||
assert.ok(runtime && typeof runtime === "object", 'exports["./runtime"] must exist');
|
||||
assert.equal(runtime.import, "./dist/index.js", 'exports["./runtime"].import must be ESM');
|
||||
});
|
||||
|
||||
test("opencode-plugin tsup config builds ESM-only with cjsInterop disabled", () => {
|
||||
const tsup = readFileSync(fileURLToPath(TSUP_URL), "utf8");
|
||||
|
||||
const formatMatch = tsup.match(/format:\s*\[([^\]]*)\]/);
|
||||
assert.ok(formatMatch, "tsup config must declare a format array");
|
||||
const formats = formatMatch[1]
|
||||
.split(",")
|
||||
.map((s) => s.trim().replace(/['"]/g, ""))
|
||||
.filter(Boolean);
|
||||
assert.deepEqual(formats, ["esm"], "tsup must build esm only (no cjs)");
|
||||
|
||||
assert.equal(
|
||||
/cjsInterop:\s*true/.test(tsup),
|
||||
false,
|
||||
"cjsInterop must not be true for an ESM-only build"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
function readJson<T = Record<string, unknown>>(relPath: string): T {
|
||||
return JSON.parse(readFileSync(join(repoRoot, relPath), "utf8")) as T;
|
||||
}
|
||||
|
||||
test("@huggingface/transformers is optional so onnxruntime CUDA install failures cannot abort OmniRoute install", () => {
|
||||
const pkg = readJson<{
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
}>("package.json");
|
||||
|
||||
assert.equal(
|
||||
pkg.dependencies?.["@huggingface/transformers"],
|
||||
undefined,
|
||||
"transformers must not be a regular dependency because it pulls onnxruntime-node install scripts"
|
||||
);
|
||||
assert.equal(pkg.optionalDependencies?.["@huggingface/transformers"], "3.5.2");
|
||||
});
|
||||
|
||||
test("package-lock marks transformers and its onnxruntime runtime as optional", () => {
|
||||
const lock = readJson<{
|
||||
packages: Record<string, { optional?: boolean; dependencies?: Record<string, string>; optionalDependencies?: Record<string, string> }>;
|
||||
}>("package-lock.json");
|
||||
|
||||
assert.equal(
|
||||
lock.packages[""]?.dependencies?.["@huggingface/transformers"],
|
||||
undefined,
|
||||
"root lock dependencies must not keep transformers as mandatory"
|
||||
);
|
||||
assert.equal(lock.packages[""]?.optionalDependencies?.["@huggingface/transformers"], "3.5.2");
|
||||
|
||||
for (const packagePath of [
|
||||
"node_modules/@huggingface/transformers",
|
||||
"node_modules/onnxruntime-node",
|
||||
"node_modules/onnxruntime-common",
|
||||
]) {
|
||||
assert.equal(lock.packages[packagePath]?.optional, true, `${packagePath} should be optional`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
// tests/unit/build/should-promote-latest-5301.test.ts
|
||||
// Regression guard for #5301 — Docker Hub `:latest` tag not re-pointed on release.
|
||||
//
|
||||
// The docker-publish workflow decides whether the just-built VERSION should also
|
||||
// move `:latest` by extracting the highest STABLE semver from the candidate set.
|
||||
// The bug: on a `release: released` event the freshly-created git tag is often
|
||||
// not yet visible to `git fetch --tags`, so a candidate set built purely from
|
||||
// `git tag -l` resolved HIGHEST to the *previous* version and skipped promotion,
|
||||
// leaving `latest` one release behind. The fix folds VERSION into the candidate
|
||||
// set so the decision is independent of tag-sync timing.
|
||||
//
|
||||
// Strategy: spawn the real extracted helper (scripts/ci/should-promote-latest.sh)
|
||||
// with fixture tag lists on stdin — this guards the actual shell code the
|
||||
// workflow calls, not a parallel reimplementation. Hermetic: no git, no network.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SCRIPT = path.resolve(here, "../../../scripts/ci/should-promote-latest.sh");
|
||||
|
||||
/** Run the helper with `version` as argv[1] and `tags` (joined by \n) on stdin. */
|
||||
function shouldPromote(version: string, tags: string[]): string {
|
||||
return execFileSync("bash", [SCRIPT, version], {
|
||||
input: tags.join("\n") + (tags.length ? "\n" : ""),
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
}
|
||||
|
||||
test("#5301 race: new tag not yet synced → still promotes latest", () => {
|
||||
// VERSION just released, but `git tag -l` only shows the previous versions
|
||||
// (the new tag hasn't propagated). The old logic returned false here.
|
||||
assert.equal(shouldPromote("3.8.39", ["3.8.38", "3.8.37"]), "true");
|
||||
});
|
||||
|
||||
test("new tag already visible → promotes", () => {
|
||||
assert.equal(shouldPromote("3.8.39", ["3.8.39", "3.8.38"]), "true");
|
||||
});
|
||||
|
||||
test("first release (no existing tags) → promotes", () => {
|
||||
assert.equal(shouldPromote("3.8.39", []), "true");
|
||||
});
|
||||
|
||||
test("patch on an older line while a higher minor exists → does NOT promote", () => {
|
||||
// A 3.8.x patch published after 3.9.0 already shipped must not grab :latest.
|
||||
assert.equal(shouldPromote("3.8.39", ["3.9.0", "3.8.39", "3.8.38"]), "false");
|
||||
});
|
||||
|
||||
test("pre-release candidate tags are ignored when picking the highest", () => {
|
||||
// 3.8.40-rc.1 must not count as higher than the stable 3.8.39.
|
||||
assert.equal(shouldPromote("3.8.39", ["3.8.40-rc.1", "3.8.38"]), "true");
|
||||
});
|
||||
|
||||
test("a pre-release VERSION never promotes latest", () => {
|
||||
assert.equal(shouldPromote("3.8.40-rc.1", ["3.8.39"]), "false");
|
||||
});
|
||||
|
||||
test("numeric (not lexical) semver ordering", () => {
|
||||
// Lexical sort would rank 3.9.0 > 3.10.0; semver -V must rank 3.10.0 highest.
|
||||
assert.equal(shouldPromote("3.10.0", ["3.9.0", "3.2.8"]), "true");
|
||||
assert.equal(shouldPromote("3.9.0", ["3.10.0", "3.9.0"]), "false");
|
||||
});
|
||||
|
||||
test("candidate tags with a leading `v` are normalized", () => {
|
||||
assert.equal(shouldPromote("3.8.39", ["v3.8.38", "v3.8.37"]), "true");
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
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 { syncChangelogSection } from "../../../scripts/release/sync-changelog-i18n.mjs";
|
||||
|
||||
function repo() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "cl-i18n-"));
|
||||
fs.writeFileSync(
|
||||
path.join(root, "CHANGELOG.md"),
|
||||
"# Changelog\n\n## [Unreleased]\n\n---\n\n## [9.9.9] — 2026-01-01\n\n- **feat:** big new thing\n- **fix:** another\n\n---\n\n## [9.9.8] — 2025-12-01\n\n- old\n"
|
||||
);
|
||||
for (const loc of ["fr", "de"]) {
|
||||
fs.mkdirSync(path.join(root, "docs/i18n", loc), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, "docs/i18n", loc, "CHANGELOG.md"),
|
||||
"# Changelog\n\n---\n\n## [9.9.9] — TBD\n\n_stub_\n\n---\n\n## [9.9.8] — 2025-12-01\n\n- old\n"
|
||||
);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
test("replaces the version section in every mirror with the root section", () => {
|
||||
const root = repo();
|
||||
const n = syncChangelogSection(root, "9.9.9", "9.9.8");
|
||||
assert.equal(n, 2);
|
||||
const fr = fs.readFileSync(path.join(root, "docs/i18n/fr/CHANGELOG.md"), "utf8");
|
||||
assert.match(fr, /big new thing/);
|
||||
assert.doesNotMatch(fr, /_stub_/);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("inserts the section when a mirror lacks it", () => {
|
||||
const root = repo();
|
||||
// remove the 9.9.9 section from the fr mirror entirely
|
||||
fs.writeFileSync(
|
||||
path.join(root, "docs/i18n/fr/CHANGELOG.md"),
|
||||
"# Changelog\n\n---\n\n## [9.9.8] — 2025-12-01\n\n- old\n"
|
||||
);
|
||||
const n = syncChangelogSection(root, "9.9.9", "9.9.8");
|
||||
assert.equal(n, 2);
|
||||
const fr = fs.readFileSync(path.join(root, "docs/i18n/fr/CHANGELOG.md"), "utf8");
|
||||
assert.match(fr, /## \[9\.9\.9\]/);
|
||||
assert.match(fr, /big new thing/);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
Reference in New Issue
Block a user