Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
94 changes: 94 additions & 0 deletions scripts/run-ts-coverage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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);
Expand Down
49 changes: 48 additions & 1 deletion scripts/run-ts-coverage.test.mjs
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
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";

import {
aggregateCoverage,
aggregateWorkspaceLines,
countSourceLines,
formatCoverageSummaryJson,
formatCoverageSummaryMarkdown,
listSourceFiles,
parseLcov,
writeCoverageSummary,
} from "./run-ts-coverage.mjs";

describe("parseLcov", () => {
Expand Down Expand Up @@ -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 });
}
});
});