diff --git a/docs/CI.md b/docs/CI.md index 4a3af75..9fd9152 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -33,7 +33,8 @@ TypeScript packages and services that ship unit tests. **uncovered**. 4. The script prints per-workspace covered/total line counts and a **weighted** aggregate: `sum(covered) / sum(total)` across all configured workspaces. -5. CI fails when the weighted aggregate drops below `lineThresholdPercent`. +5. The runner writes a compact coverage summary artifact (`coverage/coverage-summary.md` and `coverage/coverage-summary.json`) and appends the Markdown table to `GITHUB_STEP_SUMMARY` when present. +6. CI fails when the weighted aggregate drops below `lineThresholdPercent`. Runner unit tests live in `scripts/run-ts-coverage.test.mjs` (`node --test scripts/run-ts-coverage.test.mjs`). diff --git a/scripts/run-ts-coverage.mjs b/scripts/run-ts-coverage.mjs index 06ebfe6..a3ea87c 100644 --- a/scripts/run-ts-coverage.mjs +++ b/scripts/run-ts-coverage.mjs @@ -15,12 +15,15 @@ const diagnostics = createLogger("scripts.run-ts-coverage"); */ import { + appendFileSync, existsSync, + mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, + writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; @@ -199,6 +202,94 @@ export function aggregateCoverage(rows) { return { covered, total, percent }; } +/** + * Build a compact JSON coverage summary payload. + * @param {WorkspaceCoverage[]} rows + * @param {LineTotals} aggregate + * @param {number} lineThresholdPercent + */ +export function formatCoverageSummaryJson(rows, aggregate, lineThresholdPercent) { + return { + passed: aggregate.percent >= lineThresholdPercent, + lineThresholdPercent, + aggregate: { + covered: aggregate.covered, + total: aggregate.total, + percent: Number(aggregate.percent.toFixed(2)), + }, + workspaces: rows.map((row) => ({ + workspace: row.workspace, + covered: row.covered, + total: row.total, + percent: Number(row.percent.toFixed(2)), + })), + }; +} + +/** + * Build a compact Markdown coverage summary table for CI artifacts and job summaries. + * @param {WorkspaceCoverage[]} rows + * @param {LineTotals} aggregate + * @param {number} lineThresholdPercent + * @returns {string} + */ +export function formatCoverageSummaryMarkdown(rows, aggregate, lineThresholdPercent) { + const status = aggregate.percent >= lineThresholdPercent ? "PASS" : "FAIL"; + const lines = [ + "## TypeScript Coverage Summary", + "", + `- **Status**: ${status}`, + `- **Weighted line coverage**: ${aggregate.percent.toFixed(2)}% (${aggregate.covered}/${aggregate.total})`, + `- **Minimum threshold**: ${lineThresholdPercent.toFixed(2)}%`, + "", + "| Workspace | Line % | Covered / Total |", + "| --- | ---: | ---: |", + ]; + for (const row of rows) { + lines.push( + `| \`${row.workspace}\` | ${row.percent.toFixed(2)}% | ${row.covered}/${row.total} |`, + ); + } + lines.push( + `| **Aggregate (weighted)** | **${aggregate.percent.toFixed(2)}%** | **${aggregate.covered}/${aggregate.total}** |`, + "", + ); + return lines.join("\n"); +} + +/** + * Write compact coverage summary files (coverage-summary.md and coverage-summary.json) + * and append to GITHUB_STEP_SUMMARY when configured. + * @param {WorkspaceCoverage[]} rows + * @param {LineTotals} aggregate + * @param {number} lineThresholdPercent + * @param {{ outDir?: string, stepSummaryPath?: string }} [options] + */ +export function writeCoverageSummary( + rows, + aggregate, + lineThresholdPercent, + options = {}, +) { + const outDir = options.outDir ?? resolve(ROOT, process.env.COVERAGE_SUMMARY_DIR ?? "coverage"); + const stepSummaryPath = options.stepSummaryPath ?? process.env.GITHUB_STEP_SUMMARY; + mkdirSync(outDir, { recursive: true }); + + const markdown = formatCoverageSummaryMarkdown(rows, aggregate, lineThresholdPercent); + const payload = formatCoverageSummaryJson(rows, aggregate, lineThresholdPercent); + + const mdPath = join(outDir, "coverage-summary.md"); + const jsonPath = join(outDir, "coverage-summary.json"); + writeFileSync(mdPath, markdown, "utf8"); + writeFileSync(jsonPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + + if (stepSummaryPath) { + appendFileSync(stepSummaryPath, `${markdown}\n`, "utf8"); + } + + return { mdPath, jsonPath, markdown, payload }; +} + function sourceRootsForWorkspace(cwd) { const src = resolve(cwd, "src"); if (existsSync(src) && statSync(src).isDirectory()) { @@ -296,6 +387,9 @@ function main() { diagnostics.info("progress-5", `${"aggregate (weighted)".padEnd(32)} ${aggregate.percent.toFixed(2)}% (${aggregate.covered}/${aggregate.total})`); diagnostics.info("threshold", `threshold ${lineThresholdPercent.toFixed(2)}%`); + const summary = writeCoverageSummary(rows, aggregate, lineThresholdPercent); + diagnostics.info("coverage-summary-artifact", `Wrote compact coverage summary to ${relative(ROOT, summary.mdPath)} and ${relative(ROOT, summary.jsonPath)}`); + if (aggregate.percent < lineThresholdPercent) { diagnostics.error("weighted-line-coverage", `\n❌ Weighted line coverage ${aggregate.percent.toFixed(2)}% is below threshold ${lineThresholdPercent}%.`); process.exit(1); diff --git a/scripts/run-ts-coverage.test.mjs b/scripts/run-ts-coverage.test.mjs index 326d416..c865895 100644 --- a/scripts/run-ts-coverage.test.mjs +++ b/scripts/run-ts-coverage.test.mjs @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -8,8 +8,11 @@ import { aggregateCoverage, aggregateWorkspaceLines, countSourceLines, + formatCoverageSummaryJson, + formatCoverageSummaryMarkdown, listSourceFiles, parseLcov, + writeCoverageSummary, } from "./run-ts-coverage.mjs"; describe("parseLcov", () => { @@ -104,3 +107,47 @@ describe("node version support", () => { assert.ok(major >= 22, `expected Node >=22, got ${process.version}`); }); }); + +describe("compact coverage summary artifact", () => { + const sampleRows = [ + { workspace: "packages/sdk", covered: 80, total: 100, percent: 80 }, + { workspace: "services/keeper", covered: 70, total: 100, percent: 70 }, + ]; + const sampleAggregate = aggregateCoverage(sampleRows); + + it("formats compact Markdown and JSON summaries with weighted totals", () => { + const md = formatCoverageSummaryMarkdown(sampleRows, sampleAggregate, 70); + assert.match(md, /## TypeScript Coverage Summary/); + assert.match(md, /- \*\*Status\*\*: PASS/); + assert.match(md, /\| `packages\/sdk` \| 80\.00% \| 80\/100 \|/); + assert.match(md, /\| \*\*Aggregate \(weighted\)\*\* \| \*\*75\.00%\*\* \| \*\*150\/200\*\* \|/); + + const json = formatCoverageSummaryJson(sampleRows, sampleAggregate, 70); + assert.equal(json.passed, true); + assert.equal(json.lineThresholdPercent, 70); + assert.deepEqual(json.aggregate, { covered: 150, total: 200, percent: 75 }); + assert.equal(json.workspaces.length, 2); + }); + + it("writes coverage-summary.md, coverage-summary.json, and GITHUB_STEP_SUMMARY", () => { + const tmp = mkdtempSync(join(tmpdir(), "cov-summary-")); + try { + const outDir = join(tmp, "coverage"); + const stepSummaryPath = join(tmp, "step-summary.md"); + const result = writeCoverageSummary(sampleRows, sampleAggregate, 70, { + outDir, + stepSummaryPath, + }); + + const diskMd = readFileSync(result.mdPath, "utf8"); + const diskJson = JSON.parse(readFileSync(result.jsonPath, "utf8")); + const stepMd = readFileSync(stepSummaryPath, "utf8"); + + assert.match(diskMd, /TypeScript Coverage Summary/); + assert.match(stepMd, /TypeScript Coverage Summary/); + assert.equal(diskJson.aggregate.percent, 75); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); +});