test: lock V8 coverage counter semantics
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { constants } from "node:fs";
|
||||
import {
|
||||
mkdir,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
|
||||
const roots: string[] = [];
|
||||
const now = Date.parse("2026-08-02T00:00:00.000Z");
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function counter(total = 1, covered = total, skipped = 0) {
|
||||
return {
|
||||
@@ -174,6 +177,86 @@ describe("repository-aware risk coverage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes artifact schema version 3 with exact counter-bearing fields", async () => {
|
||||
const repositoryRoot = await mkdtemp(path.join(tmpdir(), "risk-coverage-cli-"));
|
||||
roots.push(repositoryRoot);
|
||||
const rawPolicy = JSON.parse(
|
||||
await readFile("config/testing/risk-coverage.json", "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
const modulePaths = rawPolicy.highRiskPaths as string[];
|
||||
const cliPolicy = {
|
||||
...rawPolicy,
|
||||
repositoryBaseline: modulePaths.length,
|
||||
};
|
||||
await Promise.all([
|
||||
mkdir(path.join(repositoryRoot, "config/testing"), { recursive: true }),
|
||||
mkdir(path.join(repositoryRoot, "artifacts/tests/coverage"), {
|
||||
recursive: true,
|
||||
}),
|
||||
...modulePaths.map(async (modulePath) => {
|
||||
const absolutePath = path.join(repositoryRoot, modulePath);
|
||||
await mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await writeFile(absolutePath, "export const covered = true;\n");
|
||||
}),
|
||||
]);
|
||||
const summary = Object.fromEntries([
|
||||
["total", metrics(modulePaths.length)],
|
||||
...modulePaths.map((modulePath) => [modulePath, fullMetrics]),
|
||||
]);
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(repositoryRoot, "config/testing/policy.json"),
|
||||
`${JSON.stringify(cliPolicy)}\n`,
|
||||
),
|
||||
writeFile(
|
||||
path.join(repositoryRoot, "artifacts/tests/coverage/summary.json"),
|
||||
`${JSON.stringify(summary)}\n`,
|
||||
),
|
||||
]);
|
||||
|
||||
await execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
"scripts/check-risk-coverage.ts",
|
||||
"--repository-root",
|
||||
repositoryRoot,
|
||||
"--policy",
|
||||
"config/testing/policy.json",
|
||||
"--summary",
|
||||
"artifacts/tests/coverage/summary.json",
|
||||
"--artifact",
|
||||
"artifacts/quality/risk-coverage.json",
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
maxBuffer: 256 * 1024,
|
||||
},
|
||||
);
|
||||
const artifact = JSON.parse(
|
||||
await readFile(
|
||||
path.join(repositoryRoot, "artifacts/quality/risk-coverage.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as Record<string, unknown>;
|
||||
|
||||
expect(rawPolicy["schemaVersion"]).toBe(2);
|
||||
expect(artifact).toMatchObject({
|
||||
schemaVersion: 3,
|
||||
policy: "config/testing/policy.json",
|
||||
summary: "artifacts/tests/coverage/summary.json",
|
||||
counterBearingTotal: modulePaths.length,
|
||||
instrumentedCounterBearingTotal: modulePaths.length,
|
||||
counterlessTotal: 0,
|
||||
counterlessModules: [],
|
||||
});
|
||||
expect(artifact).not.toHaveProperty("executableTotal");
|
||||
expect(artifact).not.toHaveProperty("instrumentedExecutableTotal");
|
||||
expect(artifact).not.toHaveProperty("nonExecutableTotal");
|
||||
expect(artifact).not.toHaveProperty("nonExecutableModules");
|
||||
});
|
||||
|
||||
it("rejects arbitrary coverage paths instead of silently allowing inflation", () => {
|
||||
const parsedPolicy = parseRiskCoveragePolicy(
|
||||
policy({ repositoryBaseline: 1, generatedPaths: [] }),
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import {
|
||||
access,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
assertV8CoverageCounterSemantics,
|
||||
checkV8CoverageCounterSemantics,
|
||||
} from "../../scripts/lib/v8-coverage-counter-semantics.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
const execFileAsync = promisify(execFile);
|
||||
const zeroCounter = Object.freeze({
|
||||
total: 0,
|
||||
covered: 0,
|
||||
skipped: 0,
|
||||
pct: 100,
|
||||
});
|
||||
const zeroMetrics = Object.freeze({
|
||||
lines: zeroCounter,
|
||||
statements: zeroCounter,
|
||||
functions: zeroCounter,
|
||||
branches: zeroCounter,
|
||||
});
|
||||
const counterlessPaths = Object.freeze([
|
||||
"src/import-empty.ts",
|
||||
"src/import-side-effect.ts",
|
||||
"src/import-type-empty.ts",
|
||||
"src/import-value.ts",
|
||||
"src/reexport-named.ts",
|
||||
"src/reexport-star.ts",
|
||||
"src/type-only.ts",
|
||||
]);
|
||||
|
||||
function fixtureSummary(root: string): Record<string, unknown> {
|
||||
return Object.fromEntries([
|
||||
[
|
||||
"total",
|
||||
{
|
||||
lines: { total: 1, covered: 1, skipped: 0, pct: 100 },
|
||||
statements: { total: 1, covered: 1, skipped: 0, pct: 100 },
|
||||
functions: zeroCounter,
|
||||
branches: zeroCounter,
|
||||
},
|
||||
],
|
||||
[
|
||||
path.join(root, "src/runtime.ts"),
|
||||
{
|
||||
lines: { total: 1, covered: 1, skipped: 0, pct: 100 },
|
||||
statements: { total: 1, covered: 1, skipped: 0, pct: 100 },
|
||||
functions: zeroCounter,
|
||||
branches: zeroCounter,
|
||||
},
|
||||
],
|
||||
...counterlessPaths.map((modulePath) => [path.join(root, modulePath), zeroMetrics]),
|
||||
]);
|
||||
}
|
||||
|
||||
async function captureFailure(operation: Promise<unknown>): Promise<Error> {
|
||||
try {
|
||||
await operation;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return error;
|
||||
throw error;
|
||||
}
|
||||
throw new Error("expected operation to fail");
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("V8 coverage counter semantics", () => {
|
||||
it("accepts one counter-bearing row and seven exact counterless rows", () => {
|
||||
const root = "/owned-fixture";
|
||||
|
||||
expect(assertV8CoverageCounterSemantics(fixtureSummary(root), root)).toEqual({
|
||||
counterBearingModules: ["src/runtime.ts"],
|
||||
counterlessModules: counterlessPaths,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a missing or additional producer row", () => {
|
||||
const root = "/owned-fixture";
|
||||
const missing = fixtureSummary(root);
|
||||
delete missing[path.join(root, "src/import-empty.ts")];
|
||||
expect(() => assertV8CoverageCounterSemantics(missing, root)).toThrow(
|
||||
/row set.*missing.*import-empty/u,
|
||||
);
|
||||
|
||||
const additional = fixtureSummary(root);
|
||||
additional[path.join(root, "src/unexpected.ts")] = zeroMetrics;
|
||||
expect(() => assertV8CoverageCounterSemantics(additional, root)).toThrow(
|
||||
/row set.*additional.*unexpected/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects counterless nonzero and runtime all-zero drift", () => {
|
||||
const root = "/owned-fixture";
|
||||
const counterlessDrift = fixtureSummary(root);
|
||||
counterlessDrift[path.join(root, "src/import-value.ts")] = {
|
||||
...zeroMetrics,
|
||||
lines: { total: 1, covered: 1, skipped: 0, pct: 100 },
|
||||
};
|
||||
expect(() =>
|
||||
assertV8CoverageCounterSemantics(counterlessDrift, root),
|
||||
).toThrow(/counterless.*exact all-zero.*import-value/u);
|
||||
|
||||
const runtimeDrift = fixtureSummary(root);
|
||||
runtimeDrift[path.join(root, "src/runtime.ts")] = zeroMetrics;
|
||||
expect(() => assertV8CoverageCounterSemantics(runtimeDrift, root)).toThrow(
|
||||
/runtime.*counter-bearing/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects producer percentages that do not exactly match their counts", () => {
|
||||
const root = "/owned-fixture";
|
||||
const totalDrift = fixtureSummary(root);
|
||||
totalDrift["total"] = {
|
||||
...totalDrift["total"] as Record<string, unknown>,
|
||||
lines: { total: 1, covered: 1, skipped: 0, pct: -500 },
|
||||
};
|
||||
expect(() => assertV8CoverageCounterSemantics(totalDrift, root)).toThrow(
|
||||
/total\.lines.*invalid coverage counters/u,
|
||||
);
|
||||
|
||||
const runtimeDrift = fixtureSummary(root);
|
||||
runtimeDrift[path.join(root, "src/runtime.ts")] = {
|
||||
...runtimeDrift[path.join(root, "src/runtime.ts")] as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
lines: { total: 1, covered: 1, skipped: 0, pct: 99.99 },
|
||||
};
|
||||
expect(() => assertV8CoverageCounterSemantics(runtimeDrift, root)).toThrow(
|
||||
/runtime.*lines.*invalid coverage counters/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds child diagnostics and cleans its owned root on child exit", async () => {
|
||||
const ownedRoot = await mkdtemp(path.join(tmpdir(), "v8-counter-exit-"));
|
||||
roots.push(ownedRoot);
|
||||
const childFailure = Object.assign(new Error("child exit 1"), {
|
||||
stdout: "o".repeat(20_000),
|
||||
stderr: "e".repeat(20_000),
|
||||
});
|
||||
|
||||
const failure = await captureFailure(
|
||||
checkV8CoverageCounterSemantics({
|
||||
repositoryRoot: process.cwd(),
|
||||
createOwnedRoot: async () => ownedRoot,
|
||||
runVitest: async () => Promise.reject(childFailure),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(failure.message).toMatch(/child Vitest failed/u);
|
||||
expect(failure.message).toMatch(/truncated/u);
|
||||
expect(failure.message.length).toBeLessThan(5_000);
|
||||
await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("fails closed on a missing summary and cleans its owned root", async () => {
|
||||
const ownedRoot = await mkdtemp(path.join(tmpdir(), "v8-counter-summary-"));
|
||||
roots.push(ownedRoot);
|
||||
|
||||
await expect(
|
||||
checkV8CoverageCounterSemantics({
|
||||
repositoryRoot: process.cwd(),
|
||||
createOwnedRoot: async () => ownedRoot,
|
||||
runVitest: async () => ({ stdout: "", stderr: "" }),
|
||||
}),
|
||||
).rejects.toThrow(/coverage summary is missing/u);
|
||||
await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("keeps the child fixture outside main Vitest discovery", async () => {
|
||||
const outputRoot = await mkdtemp(path.join(tmpdir(), "v8-counter-list-"));
|
||||
roots.push(outputRoot);
|
||||
const outputPath = path.join(outputRoot, "listed.json");
|
||||
await execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(process.cwd(), "node_modules/vitest/vitest.mjs"),
|
||||
"list",
|
||||
"tests/fixtures/v8-coverage-counter-semantics",
|
||||
"--config",
|
||||
path.join(process.cwd(), "vitest.config.ts"),
|
||||
"--filesOnly",
|
||||
"--passWithNoTests",
|
||||
"--json",
|
||||
outputPath,
|
||||
"--no-color",
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
maxBuffer: 256 * 1024,
|
||||
},
|
||||
);
|
||||
|
||||
expect(JSON.parse(await readFile(outputPath, "utf8"))).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user