diff --git a/scripts/rees-coverage.d.mts b/scripts/rees-coverage.d.mts new file mode 100644 index 0000000000..be8d3a98ac --- /dev/null +++ b/scripts/rees-coverage.d.mts @@ -0,0 +1,6 @@ +export type NormalizeLcovSfPathsOptions = { + readFile?: (path: string) => string; + writeFile?: (path: string, content: string) => void; +}; + +export function normalizeLcovSfPaths(lcovPath: string, options?: NormalizeLcovSfPathsOptions): void; diff --git a/scripts/rees-coverage.mjs b/scripts/rees-coverage.mjs index 21fd0dcbf2..0b527967a8 100644 --- a/scripts/rees-coverage.mjs +++ b/scripts/rees-coverage.mjs @@ -6,10 +6,21 @@ import { readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, relative } from "node:path"; import { fileURLToPath } from "node:url"; -const root = join(fileURLToPath(new URL(".", import.meta.url)), ".."); -const c8Bin = join(root, "review-enrichment", "node_modules", "c8", "bin", "c8.js"); -const reportDir = join(root, "review-enrichment", "coverage"); -const testRoot = join(root, "review-enrichment", "test"); +/** Normalize c8's SF: paths to forward slashes for Codecov. Swallows only a missing report + * (ENOENT on read) — CI's "Verify REES coverage report exists" step fails closed downstream. + * Any other read/write error propagates so a real lcov post-process failure is not masked. */ +export function normalizeLcovSfPaths(lcovPath, { readFile = readFileSync, writeFile = writeFileSync } = {}) { + try { + const raw = readFile(lcovPath, "utf8"); + writeFile( + lcovPath, + raw.replace(/^SF:(.*)$/gm, (_match, path) => `SF:${String(path).replace(/\\/g, "/")}`), + ); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return; + throw error; + } +} function collectTests(dir, out = []) { for (const ent of readdirSync(dir, { withFileTypes: true })) { @@ -20,40 +31,40 @@ function collectTests(dir, out = []) { return out; } -const tests = collectTests(testRoot).map((path) => relative(root, path).split("\\").join("/")); -if (tests.length === 0) { - console.error("rees-coverage: no review-enrichment/test/**/*.test.ts files found"); - process.exit(1); -} +function main() { + const root = join(fileURLToPath(new URL(".", import.meta.url)), ".."); + const c8Bin = join(root, "review-enrichment", "node_modules", "c8", "bin", "c8.js"); + const reportDir = join(root, "review-enrichment", "coverage"); + const testRoot = join(root, "review-enrichment", "test"); -const result = spawnSync( - process.execPath, - [ - c8Bin, - "--reporter=lcov", - "--reporter=text-summary", - `--report-dir=${reportDir}`, - "--include=review-enrichment/dist/**/*.js", - "--exclude=**/*.d.ts", - "--all", + const tests = collectTests(testRoot).map((path) => relative(root, path).split("\\").join("/")); + if (tests.length === 0) { + console.error("rees-coverage: no review-enrichment/test/**/*.test.ts files found"); + process.exit(1); + } + + const result = spawnSync( process.execPath, - "--test", - "--experimental-strip-types", - ...tests, - ], - { cwd: root, stdio: "inherit", env: process.env }, -); - -// Codecov expects forward-slash SF: paths; c8 on Windows emits backslashes. -const lcovPath = join(reportDir, "lcov.info"); -try { - const raw = readFileSync(lcovPath, "utf8"); - writeFileSync( - lcovPath, - raw.replace(/^SF:(.*)$/gm, (_match, path) => `SF:${String(path).replace(/\\/g, "/")}`), + [ + c8Bin, + "--reporter=lcov", + "--reporter=text-summary", + `--report-dir=${reportDir}`, + "--include=review-enrichment/dist/**/*.js", + "--exclude=**/*.d.ts", + "--all", + process.execPath, + "--test", + "--experimental-strip-types", + ...tests, + ], + { cwd: root, stdio: "inherit", env: process.env }, ); -} catch { - // CI's "Verify REES coverage report exists" step fails closed if the report is missing. + + // Codecov expects forward-slash SF: paths; c8 on Windows emits backslashes. + normalizeLcovSfPaths(join(reportDir, "lcov.info")); + + process.exit(result.status === null ? 1 : result.status); } -process.exit(result.status === null ? 1 : result.status); +if (process.argv[1] === fileURLToPath(import.meta.url)) main(); diff --git a/test/unit/rees-coverage-script.test.ts b/test/unit/rees-coverage-script.test.ts new file mode 100644 index 0000000000..fca31047a0 --- /dev/null +++ b/test/unit/rees-coverage-script.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; +import { normalizeLcovSfPaths } from "../../scripts/rees-coverage.mjs"; + +describe("rees-coverage script", () => { + describe("normalizeLcovSfPaths", () => { + it("swallows ENOENT when the lcov report does not exist yet", () => { + const readFile = vi.fn(() => { + const err = new Error("ENOENT") as NodeJS.ErrnoException; + err.code = "ENOENT"; + throw err; + }); + const writeFile = vi.fn(); + + expect(() => normalizeLcovSfPaths("/tmp/missing/lcov.info", { readFile, writeFile })).not.toThrow(); + expect(readFile).toHaveBeenCalledOnce(); + expect(writeFile).not.toHaveBeenCalled(); + }); + + it("re-throws a write failure instead of masking it as a missing report", () => { + const writeErr = new Error("EACCES: permission denied") as NodeJS.ErrnoException; + writeErr.code = "EACCES"; + const readFile = vi.fn(() => "SF:review-enrichment\\src\\foo.ts\nend_of_record\n"); + const writeFile = vi.fn(() => { + throw writeErr; + }); + + expect(() => normalizeLcovSfPaths("/tmp/lcov.info", { readFile, writeFile })).toThrow(writeErr); + expect(writeFile).toHaveBeenCalledOnce(); + }); + + it("normalizes backslashes in SF: paths to forward slashes", () => { + let written = ""; + const readFile = vi.fn(() => "SF:review-enrichment\\src\\foo.ts\nend_of_record\n"); + const writeFile = vi.fn((_path: string, content: string) => { + written = content; + }); + + normalizeLcovSfPaths("/tmp/lcov.info", { readFile, writeFile }); + + expect(written).toBe("SF:review-enrichment/src/foo.ts\nend_of_record\n"); + }); + }); +});