test: lock V8 coverage counter semantics
This commit is contained in:
@@ -62,7 +62,7 @@ await writeRiskCoverageArtifactAtomic({
|
||||
relativePath: artifactPath,
|
||||
inputPaths: [policyInput.relativePath, summaryInput.relativePath],
|
||||
value: {
|
||||
schemaVersion: 2,
|
||||
schemaVersion: 3,
|
||||
policy: policyInput.relativePath,
|
||||
summary: summaryInput.relativePath,
|
||||
...result,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { checkV8CoverageCounterSemantics } from "./lib/v8-coverage-counter-semantics.ts";
|
||||
|
||||
try {
|
||||
const result = await checkV8CoverageCounterSemantics();
|
||||
process.stdout.write(
|
||||
`V8 coverage counter semantics: PASS (${result.counterBearingModules.length} counter-bearing, ${result.counterlessModules.length} counterless)\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`V8 coverage counter semantics failed: ${message}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const coverageMetrics = [
|
||||
"lines",
|
||||
"statements",
|
||||
"functions",
|
||||
"branches",
|
||||
] as const;
|
||||
const runtimeModule = "src/runtime.ts";
|
||||
const counterlessModules = 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",
|
||||
] as const);
|
||||
const expectedModules = Object.freeze(
|
||||
[runtimeModule, ...counterlessModules].sort(),
|
||||
);
|
||||
const childOutputLimit = 1_800;
|
||||
|
||||
type ChildOutput = Readonly<{ stdout: string; stderr: string }>;
|
||||
type ChildInput = Readonly<{
|
||||
repositoryRoot: string;
|
||||
ownedRoot: string;
|
||||
configPath: string;
|
||||
}>;
|
||||
type CheckerOptions = Readonly<{
|
||||
repositoryRoot?: string;
|
||||
createOwnedRoot?: () => Promise<string>;
|
||||
runVitest?: (input: ChildInput) => Promise<ChildOutput>;
|
||||
}>;
|
||||
|
||||
export type V8CoverageCounterSemantics = Readonly<{
|
||||
counterBearingModules: readonly string[];
|
||||
counterlessModules: readonly string[];
|
||||
}>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return isRecord(error) && error.code === code;
|
||||
}
|
||||
|
||||
function normalizeSummaryPath(value: string, fixtureRoot: string): string {
|
||||
const relative = path.isAbsolute(value) ? path.relative(fixtureRoot, value) : value;
|
||||
const normalized = relative.split(path.sep).join("/");
|
||||
if (
|
||||
normalized.length === 0 ||
|
||||
normalized === ".." ||
|
||||
normalized.startsWith("../") ||
|
||||
path.posix.isAbsolute(normalized)
|
||||
) {
|
||||
throw new TypeError(`V8 coverage row is outside the owned fixture: ${value}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function coverageCounter(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Readonly<{ total: number; covered: number; skipped: number; pct: number }> {
|
||||
if (!isRecord(value)) throw new TypeError(`${label} must be an object`);
|
||||
const { total, covered, skipped, pct } = value;
|
||||
if (
|
||||
typeof total !== "number" ||
|
||||
!Number.isSafeInteger(total) ||
|
||||
typeof covered !== "number" ||
|
||||
!Number.isSafeInteger(covered) ||
|
||||
typeof skipped !== "number" ||
|
||||
!Number.isSafeInteger(skipped) ||
|
||||
typeof pct !== "number" ||
|
||||
!Number.isFinite(pct) ||
|
||||
total < 0 ||
|
||||
covered < 0 ||
|
||||
skipped < 0 ||
|
||||
covered + skipped > total
|
||||
) {
|
||||
throw new TypeError(`${label} contains invalid coverage counters`);
|
||||
}
|
||||
const expectedPercentage =
|
||||
total === 0 ? 100 : Math.floor((covered / total) * 10_000) / 100;
|
||||
if (pct !== expectedPercentage) {
|
||||
throw new TypeError(`${label} contains invalid coverage counters`);
|
||||
}
|
||||
return { total, covered, skipped, pct };
|
||||
}
|
||||
|
||||
function coverageRow(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Readonly<Record<(typeof coverageMetrics)[number], ReturnType<typeof coverageCounter>>> {
|
||||
if (!isRecord(value)) throw new TypeError(`${label} must be an object`);
|
||||
return Object.fromEntries(
|
||||
coverageMetrics.map((metric) => [
|
||||
metric,
|
||||
coverageCounter(value[metric], `${label}.${metric}`),
|
||||
]),
|
||||
) as Readonly<
|
||||
Record<(typeof coverageMetrics)[number], ReturnType<typeof coverageCounter>>
|
||||
>;
|
||||
}
|
||||
|
||||
export function assertV8CoverageCounterSemantics(
|
||||
value: unknown,
|
||||
fixtureRoot: string,
|
||||
): V8CoverageCounterSemantics {
|
||||
if (!isRecord(value)) throw new TypeError("V8 coverage summary must be an object");
|
||||
coverageRow(value.total, "V8 coverage total");
|
||||
const rows = new Map<string, ReturnType<typeof coverageRow>>();
|
||||
for (const [producerPath, producerRow] of Object.entries(value)) {
|
||||
if (producerPath === "total") continue;
|
||||
const normalizedPath = normalizeSummaryPath(producerPath, fixtureRoot);
|
||||
if (rows.has(normalizedPath)) {
|
||||
throw new TypeError(`V8 coverage row is duplicated: ${normalizedPath}`);
|
||||
}
|
||||
rows.set(
|
||||
normalizedPath,
|
||||
coverageRow(producerRow, `V8 coverage row ${normalizedPath}`),
|
||||
);
|
||||
}
|
||||
const actualModules = [...rows.keys()].sort();
|
||||
const missing = expectedModules.filter((modulePath) => !rows.has(modulePath));
|
||||
const additional = actualModules.filter(
|
||||
(modulePath) => !expectedModules.includes(modulePath),
|
||||
);
|
||||
if (missing.length > 0 || additional.length > 0) {
|
||||
throw new Error(
|
||||
`V8 coverage row set mismatch; missing: ${missing.join(", ") || "none"}; additional: ${additional.join(", ") || "none"}`,
|
||||
);
|
||||
}
|
||||
const runtimeRow = rows.get(runtimeModule)!;
|
||||
if (!coverageMetrics.some((metric) => runtimeRow[metric].total > 0)) {
|
||||
throw new Error(`V8 runtime module is not counter-bearing: ${runtimeModule}`);
|
||||
}
|
||||
for (const modulePath of counterlessModules) {
|
||||
const row = rows.get(modulePath)!;
|
||||
const exactAllZero = coverageMetrics.every((metric) => {
|
||||
const counter = row[metric];
|
||||
return (
|
||||
counter.total === 0 &&
|
||||
counter.covered === 0 &&
|
||||
counter.skipped === 0 &&
|
||||
counter.pct === 100
|
||||
);
|
||||
});
|
||||
if (!exactAllZero) {
|
||||
throw new Error(
|
||||
`V8 counterless module must have exact all-zero counters: ${modulePath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
counterBearingModules: Object.freeze([runtimeModule]),
|
||||
counterlessModules: Object.freeze([...counterlessModules]),
|
||||
});
|
||||
}
|
||||
|
||||
function boundedChildOutput(value: unknown): string {
|
||||
const output =
|
||||
typeof value === "string"
|
||||
? value
|
||||
: value instanceof Uint8Array
|
||||
? new TextDecoder().decode(value)
|
||||
: "";
|
||||
if (output.length <= childOutputLimit) return output;
|
||||
return `${output.slice(0, childOutputLimit)}\n[truncated ${output.length - childOutputLimit} characters]`;
|
||||
}
|
||||
|
||||
async function defaultRunVitest(input: ChildInput): Promise<ChildOutput> {
|
||||
const result = await execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(input.repositoryRoot, "node_modules/vitest/vitest.mjs"),
|
||||
"run",
|
||||
"--config",
|
||||
input.configPath,
|
||||
"--coverage",
|
||||
"--reporter=dot",
|
||||
"--no-color",
|
||||
],
|
||||
{
|
||||
cwd: input.ownedRoot,
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
maxBuffer: 256 * 1024,
|
||||
},
|
||||
);
|
||||
return { stdout: result.stdout, stderr: result.stderr };
|
||||
}
|
||||
|
||||
function assertOwnedTemporaryRoot(value: string): string {
|
||||
const temporaryRoot = path.resolve(tmpdir());
|
||||
const ownedRoot = path.resolve(value);
|
||||
const relative = path.relative(temporaryRoot, ownedRoot);
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative === ".." ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new TypeError(`V8 coverage fixture root is not an owned temp path: ${ownedRoot}`);
|
||||
}
|
||||
return ownedRoot;
|
||||
}
|
||||
|
||||
export async function checkV8CoverageCounterSemantics(
|
||||
options: CheckerOptions = {},
|
||||
): Promise<V8CoverageCounterSemantics> {
|
||||
const repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd());
|
||||
const createOwnedRoot =
|
||||
options.createOwnedRoot ??
|
||||
(() => mkdtemp(path.join(tmpdir(), "v8-counter-semantics-")));
|
||||
const runVitest = options.runVitest ?? defaultRunVitest;
|
||||
const ownedRoot = assertOwnedTemporaryRoot(await createOwnedRoot());
|
||||
const fixtureSource = path.join(
|
||||
repositoryRoot,
|
||||
"tests/fixtures/v8-coverage-counter-semantics",
|
||||
);
|
||||
const configPath = path.join(ownedRoot, "vitest.config.mjs");
|
||||
const reportsDirectory = path.join(ownedRoot, "coverage");
|
||||
try {
|
||||
await cp(fixtureSource, ownedRoot, { recursive: true });
|
||||
await writeFile(
|
||||
configPath,
|
||||
`export default ${JSON.stringify(
|
||||
{
|
||||
root: ownedRoot,
|
||||
test: {
|
||||
globals: true,
|
||||
include: ["counter-semantics.fixture.ts"],
|
||||
setupFiles: [],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reportsDirectory,
|
||||
reporter: ["json-summary"],
|
||||
include: ["src/**/*.ts"],
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)};\n`,
|
||||
"utf8",
|
||||
);
|
||||
try {
|
||||
await runVitest({ repositoryRoot, ownedRoot, configPath });
|
||||
} catch (error) {
|
||||
const record = isRecord(error) ? error : {};
|
||||
throw new Error(
|
||||
`V8 coverage counter semantics child Vitest failed\nstdout:\n${boundedChildOutput(record.stdout)}\nstderr:\n${boundedChildOutput(record.stderr)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const summaryPath = path.join(reportsDirectory, "coverage-summary.json");
|
||||
let summaryText: string;
|
||||
try {
|
||||
summaryText = await readFile(summaryPath, "utf8");
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, "ENOENT")) {
|
||||
throw new Error("V8 coverage counter semantics coverage summary is missing", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
let summary: unknown;
|
||||
try {
|
||||
summary = JSON.parse(summaryText) as unknown;
|
||||
} catch (error) {
|
||||
throw new TypeError("V8 coverage counter semantics summary is invalid JSON", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
return assertV8CoverageCounterSemantics(summary, ownedRoot);
|
||||
} finally {
|
||||
await rm(ownedRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user