import { join } from "node:path"; import { assert, assertStringIncludes } from "@std/assert"; import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { spy } from "@std/testing/mock"; import { type CoverageFailure, findCoverageFailures, printCoverageReport, printFailureSummary, } from "#scripts/coverage-check.ts"; import { projectRoot } from "#scripts/project-root.ts"; import { withEnvironment } from "#scripts/test-environment.ts"; import { expectIncludesAll } from "#test-utils/assertions.ts"; import { withTempDir } from "#test-utils/files.ts"; /** Wrap raw lcov lines into one record. */ const recordOf = (lines: string[]): string => `${lines.join("\n")}\nend_of_record\n`; const linesOf = (source: string, data: string[]): string => recordOf([`SF:${source}`, ...data]); const failingRecord = (source: string): string => linesOf(source, [ "FN:1,main", "FNDA:5,main", "FNF:1", "FNH:1", "BRDA:3,0,0,1", "BRDA:5,0,0,0", "BRDA:5,2,0,0", "BRDA:6,1,0,-", "BRDA:7,0,0,1", "BRF:5", "BRH:2", "DA:1,9", "DA:2,0", "DA:4,3", "DA:12,0", "LF:4", "LH:2", ]); const cleanRecord = (source: string): string => linesOf(source, ["DA:1,7", "LF:1", "LH:1", "BRDA:1,0,0,1", "BRF:1", "BRH:1"]); const runWithCapturedErrors = async ( run: () => Promise, ): Promise<{ errors: string[]; result: Result }> => { const errors = spy(console, "error"); try { const result = await run(); return { errors: errors.calls.map((call) => String(call.args[0])), result, }; } finally { errors.restore(); } }; describe("findCoverageFailures", () => { test("returns null when no record names a source file", () => { expect(findCoverageFailures("end_of_record\nDA:1,0\nend_of_record\n")).toBe( null, ); }); test("reports uncovered lines and branches with their line numbers", () => { const failures = findCoverageFailures(failingRecord("/tmp/probe.ts"))!; expect(failures.length).toBe(1); const failure: CoverageFailure = failures[0]!; expect(failure.file).toBe("/tmp/probe.ts"); expect(failure.lines).toEqual({ covered: 2, total: 4, uncovered: [2, 12], }); expect(failure.branches).toEqual({ covered: 2, total: 5, // Line 5 is named by two zero branches and must appear once. uncovered: [5, 6], }); }); test("keeps a relative SF path as the file name", () => { const failures = findCoverageFailures( linesOf("src/rel.ts", ["DA:1,0", "LF:1", "LH:0"]), )!; expect(failures[0]?.file).toBe("src/rel.ts"); expect(failures[0]?.sourceFile).toBe("src/rel.ts"); }); test("drops files that the gate excludes by name", () => { const excluded = failingRecord( `${projectRoot}/scripts/compact-test-reporter.ts`, ); expect(findCoverageFailures(excluded + cleanRecord("src/ok.ts"))).toEqual( [], ); }); test("reports a lines-only failure when branch totals are healthy", () => { const failures = findCoverageFailures( linesOf("/tmp/lines-only.ts", [ "DA:1,4", "DA:2,0", "LF:2", "LH:1", "BRF:1", "BRH:1", ]), )!; expect(failures[0]?.lines?.uncovered).toEqual([2]); expect(failures[0]?.branches).toBe(undefined); }); test("counts fewer hits than found lines as gaps with no line numbers", () => { const failures = findCoverageFailures( linesOf("/tmp/unknown-lines.ts", [ "DA:1,9", "DA:4,8", "LF:3", "LH:2", "BRF:1", "BRH:1", ]), )!; expect(failures[0]?.lines).toEqual({ covered: 2, total: 3, uncovered: [] }); }); test("reports a branches-only failure when line totals are missing", () => { const failures = findCoverageFailures( linesOf("/tmp/branches-only.ts", ["BRDA:5,0,0,0", "BRF:2", "BRH:1"]), )!; expect(failures[0]?.branches).toEqual({ covered: 1, total: 2, uncovered: [5], }); expect(failures[0]?.lines).toBe(undefined); }); test("reports nothing for records whose metrics are complete", () => { const failures = findCoverageFailures( cleanRecord("src/clean.ts") + failingRecord("/x.ts"), )!; expect(failures.length).toBe(1); expect(failures[0]?.file).toBe("/x.ts"); }); }); describe("printFailureSummary", () => { test("prints source lines around each uncovered line", async () => { await withTempDir(async (dir) => { const source = join(dir, "sourced.txt"); await Deno.writeTextFile( source, Array.from({ length: 12 }, (_, i) => `row number ${i + 1}`).join("\n"), ); const failure: CoverageFailure = { branches: { covered: 1, total: 2, uncovered: [10] }, file: "sourced.txt", lines: { covered: 9, total: 12, uncovered: [2, 10] }, sourceFile: source, }; const { errors } = await runWithCapturedErrors(() => printFailureSummary(failure), ); const text = errors.join("\n"); expectIncludesAll(text, [ "missing 2, 10", "missing 10", "| row number 2", "| row number 10", // The jump from the lines around 2 to the lines around 10 is shown. " ...", ]); }); }); test("prints no snippet block when the source file is missing", async () => { const failure: CoverageFailure = { branches: { covered: 0, total: 1, uncovered: [1] }, file: "gone.ts", sourceFile: "/nonexistent/gone.ts", }; const { errors } = await runWithCapturedErrors(() => printFailureSummary(failure), ); const text = errors.join("\n"); assertStringIncludes(text, "missing 1"); assert(!text.includes("snippets")); }); test("prints snippets for a failure that only has branches", async () => { await withTempDir(async (dir) => { const source = join(dir, "branchy.txt"); await Deno.writeTextFile(source, "alpha\nbeta\ngamma\n"); const failure: CoverageFailure = { branches: { covered: 0, total: 1, uncovered: [2] }, file: "branchy.txt", sourceFile: source, }; const { errors } = await runWithCapturedErrors(() => printFailureSummary(failure), ); assertStringIncludes(errors.join("\n"), "| beta"); }); }); test("prints the unknown range when no line is dead", async () => { await withTempDir(async (dir) => { const source = join(dir, "unknown.txt"); await Deno.writeTextFile(source, "only one line here\n"); const failure: CoverageFailure = { file: "unknown.txt", lines: { covered: 2, total: 3, uncovered: [] }, sourceFile: source, }; const { errors } = await runWithCapturedErrors(() => printFailureSummary(failure), ); assertStringIncludes(errors.join("\n"), "missing unknown"); }); }); test("caps the visible snippet lines and counts the rest", async () => { await withTempDir(async (dir) => { const source = join(dir, "long.txt"); await Deno.writeTextFile( source, `${Array.from({ length: 40 }, (_, i) => `line ${i + 1}`).join("\n")}\n`, ); const failure: CoverageFailure = { file: "long.txt", // Uncovered lines beyond the first line and past the file end. lines: { covered: 10, total: 40, uncovered: [2, ...Array.from({ length: 27 }, (_, i) => i + 3), 55], }, sourceFile: source, }; const { errors } = await runWithCapturedErrors(() => printFailureSummary(failure), ); const text = errors.join("\n"); expectIncludesAll(text, ["| line 1", "more source lines"]); }); }); }); describe("printCoverageReport", () => { const withLinesFailing = (): CoverageFailure[] => findCoverageFailures( linesOf("/tmp/x.ts", ["DA:1,0", "LF:1", "LH:0", "BRF:0", "BRH:0"]), )!; test("returns 0 and names the clean state for no failures", async () => { const logged = spy(console, "log"); try { expect(await printCoverageReport([])).toBe(0); } finally { logged.restore(); } assertStringIncludes( logged.calls[0]?.args[0], "All files have 100% line and branch coverage", ); }); test("returns 1 for missing coverage data", async () => { expect(await printCoverageReport(null)).toBe(1); }); test("returns 1 for failures and prints the rules", async () => { const { errors, result } = await runWithCapturedErrors(() => printCoverageReport(withLinesFailing()), ); expect(result).toBe(1); assertStringIncludes(errors.join("\n"), "Test quality rules"); }); test("prints no GitHub annotations outside a CI run", async () => { const { errors } = await withEnvironment( { GITHUB_ACTIONS: undefined }, () => runWithCapturedErrors(() => printCoverageReport(withLinesFailing())), ); assert(!errors.join("\n").includes("::error")); }); test("prints GitHub annotations in a CI run and caps them", async () => { const entries = Array.from( { length: 101 }, (_, i) => `BRDA:${i + 1},0,0,0`, ); const failure = findCoverageFailures( linesOf("/tmp/many.ts", [ "DA:1,0", "LF:1", "LH:0", ...entries, "BRF:101", "BRH:0", ]) + linesOf("/tmp/branch-only.ts", ["BRDA:2,0,0,0", "BRF:1", "BRH:0"]), ); const { errors } = await withEnvironment({ GITHUB_ACTIONS: "1" }, () => runWithCapturedErrors(() => printCoverageReport(failure!)), ); const text = errors.join("\n"); expectIncludesAll(text, [ "::error file=", "additional coverage annotations omitted", ]); }); });