Skip to content
Merged
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
7 changes: 6 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ async function run(): Promise<void> {
// Process each tool
const toolReports = [];
let anyDecrease = false;
let baseSha: string | undefined;

for (const input of inputs) {
core.info(`Processing ${input.tool} coverage from ${input.path}`);
Expand All @@ -134,6 +135,10 @@ async function run(): Promise<void> {
core.warning(`Could not restore base artifact for ${input.tool}`);
}

if (baseArtifact && !baseSha) {
baseSha = baseArtifact.commitSha;
}

const report = buildToolReport(input.tool, headFiles, baseArtifact, warnings);
toolReports.push(report);

Expand Down Expand Up @@ -163,7 +168,7 @@ async function run(): Promise<void> {

// Render markdown
const { owner, repo } = github.context.repo;
const commitInfo = showCommitLink ? { sha: commitSha, owner, repo } : undefined;
const commitInfo = showCommitLink ? { sha: commitSha, baseSha, owner, repo } : undefined;
const markdown = renderReport(fullReport, marker, colorize, commitInfo);

// Set outputs
Expand Down
137 changes: 133 additions & 4 deletions src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {

export interface CommitInfo {
sha: string;
baseSha?: string;
owner: string;
repo: string;
}
Expand Down Expand Up @@ -59,6 +60,112 @@ function renderToolSection(report: ToolCoverageReport, colorize: boolean): strin
return lines.join("\n");
}

function renderCoverageDiff(report: CoverageReport): string | null {
const o = report.overall;
if (o.basePercent === null) return null;

const baseCovered = report.tools.reduce((s, t) => s + (t.summary.baseCoveredLines ?? 0), 0);
const baseTotal = report.tools.reduce((s, t) => s + (t.summary.baseTotalLines ?? 0), 0);
const headFiles = report.tools.reduce(
(s, t) => s + t.files.filter((f) => f.totalLines > 0).length,
0,
);
const baseFiles = report.tools.reduce(
(s, t) => s + t.files.filter((f) => (f.baseTotalLines ?? 0) > 0).length,
0,
);

function fmtDiff(head: number, base: number): string {
const d = head - base;
if (d === 0) return "";
return d > 0 ? `+${d}` : String(d);
}

function fmtPctDiff(d: number | null): string {
if (d === null || d === 0) return "";
return `${d > 0 ? "+" : ""}${d.toFixed(2)}%`;
}

const hitsDelta = o.coveredLines - baseCovered;

type RowData = { prefix: string; label: string; base: string; head: string; diff: string; };
const rows: (RowData | "sep")[] = [
{
prefix: " ",
label: "Coverage",
base: `${o.basePercent.toFixed(2)}%`,
head: `${o.percent.toFixed(2)}%`,
diff: fmtPctDiff(o.delta),
},
"sep",
{
prefix: " ",
label: "Files",
base: String(baseFiles),
head: String(headFiles),
diff: fmtDiff(headFiles, baseFiles),
},
{
prefix: " ",
label: "Lines",
base: String(baseTotal),
head: String(o.totalLines),
diff: fmtDiff(o.totalLines, baseTotal),
},
"sep",
{
prefix: hitsDelta > 0 ? "+" : hitsDelta < 0 ? "-" : " ",
label: "Hits",
base: String(baseCovered),
head: String(o.coveredLines),
diff: fmtDiff(o.coveredLines, baseCovered),
},
];

const actual = rows.filter((r): r is RowData => r !== "sep");
const lw = Math.max(...actual.map((r) => r.label.length));
const bw = Math.max(4, ...actual.map((r) => r.base.length));
const hw = Math.max(4, ...actual.map((r) => r.head.length));
const dw = Math.max(3, ...actual.map((r) => r.diff.length));

function fmtRow(r: RowData): string {
return `${r.prefix} ${r.label.padEnd(lw)} ${r.base.padStart(bw)} ${
r.head.padStart(hw)
} ${r.diff.padStart(dw)}`;
}

const rowLen = fmtRow(actual[0]).length;
const w = Math.max(rowLen + 4, 40);

const sep = "=".repeat(w);

const title = "Coverage Diff";
const innerW = w - 4;
const tPad = innerW - title.length;
const tl = Math.floor(tPad / 2);
const tr = tPad - tl;

const colInner = ` ${" ".repeat(lw)} ${"base".padStart(bw)} ${"head".padStart(hw)} ${
" +/-".padStart(dw)
}`;

const lines: string[] = [
`@@${" ".repeat(tl)}${title}${" ".repeat(tr)}@@`,
`##${colInner.padEnd(innerW)}##`,
];

for (const row of rows) {
if (row === "sep") {
lines.push(sep);
} else {
lines.push(fmtRow(row));
}
}
lines.push(sep);

return lines.join("\n");
}

export function renderReport(
report: CoverageReport,
marker: string,
Expand All @@ -70,11 +177,33 @@ export function renderReport(
parts.push(marker);
parts.push("## Coverage Report\n");

if (commitInfo) {
const short = commitInfo.sha.slice(0, 7);
const url =
// Project coverage summary line
const pct = report.overall.percent.toFixed(2);
if (commitInfo?.baseSha) {
const headShort = commitInfo.sha.slice(0, 7);
const headUrl =
`https://github.com/${commitInfo.owner}/${commitInfo.repo}/commit/${commitInfo.sha}`;
const baseShort = commitInfo.baseSha.slice(0, 7);
const baseUrl =
`https://github.com/${commitInfo.owner}/${commitInfo.repo}/commit/${commitInfo.baseSha}`;
parts.push(
`Project coverage is ${pct}%. Comparing base ([\`${baseShort}\`](${baseUrl})) to head ([\`${headShort}\`](${headUrl})).\n`,
);
} else if (commitInfo) {
const headShort = commitInfo.sha.slice(0, 7);
const headUrl =
`https://github.com/${commitInfo.owner}/${commitInfo.repo}/commit/${commitInfo.sha}`;
parts.push(`[Commit ${short}](${url})\n`);
parts.push(`Project coverage is ${pct}%. Commit [\`${headShort}\`](${headUrl}).\n`);
} else {
parts.push(`Project coverage is ${pct}%.\n`);
}

// Coverage Diff table (only when base data is available)
const diffTable = renderCoverageDiff(report);
if (diffTable) {
parts.push("```diff");
parts.push(diffTable);
parts.push("```\n");
}

for (const tool of report.tools) {
Expand Down
135 changes: 130 additions & 5 deletions src/render_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,20 @@ describe("renderReport", () => {

expect(md).toContain("<!-- test-marker -->");
expect(md).toContain("## Coverage Report");
expect(md).toContain("Project coverage is 90.00%.");
expect(md).toContain("src/index.ts");
expect(md).toContain("src/utils.ts");
expect(md).toContain("[+]"); // positive delta
expect(md).toContain("Bun Coverage: 90.00%");
expect(md).toContain("xseman/coverage");
// Coverage Diff table
expect(md).toContain("Coverage Diff");
expect(md).toContain("```diff");
expect(md).toContain("@@");
expect(md).toContain("Coverage");
expect(md).toContain("Files");
expect(md).toContain("Lines");
expect(md).toContain("Hits");
// Single tool — no separate "Total Coverage" line
expect(md).not.toContain("**Total Coverage:");
});
Expand All @@ -57,6 +66,9 @@ describe("renderReport", () => {
expect(md).not.toContain("[+]");
expect(md).not.toContain("[-]");
expect(md).toContain("Go Coverage: 50.00%");
expect(md).toContain("Project coverage is 50.00%.");
// No base — no diff table
expect(md).not.toContain("Coverage Diff");
});

test("renders warning rows", () => {
Expand Down Expand Up @@ -91,31 +103,144 @@ describe("renderReport", () => {
expect(md).toContain("**Total Coverage: 65.00%**");
});

test("renders commit link when commitInfo is provided", () => {
test("renders project coverage with base and head commit links", () => {
const head: FileCoverage[] = [
{ file: "src/index.ts", coveredLines: 8, totalLines: 10, percent: 80 },
];
const toolReport = buildToolReport("bun", head, null, []);
const base: CoverageArtifact = {
tool: "bun",
files: [{ file: "src/index.ts", coveredLines: 7, totalLines: 10, percent: 70 }],
commitSha: "base123",
branch: "main",
timestamp: "2025-01-01T00:00:00Z",
};
const toolReport = buildToolReport("bun", head, base, []);
const fullReport = buildFullReport([toolReport]);
const commitInfo: CommitInfo = {
sha: "abc1234567890",
baseSha: "def9876543210",
owner: "myorg",
repo: "myrepo",
};
const md = renderReport(fullReport, "<!-- m -->", true, commitInfo);

expect(md).toContain("[Commit abc1234]");
expect(md).toContain("Project coverage is 80.00%.");
expect(md).toContain("Comparing base ([`def9876`]");
expect(md).toContain("to head ([`abc1234`]");
expect(md).toContain("https://github.com/myorg/myrepo/commit/def9876543210");
expect(md).toContain("https://github.com/myorg/myrepo/commit/abc1234567890");
});

test("omits commit link when commitInfo is not provided", () => {
test("renders commit link without base when baseSha is absent", () => {
const head: FileCoverage[] = [
{ file: "src/index.ts", coveredLines: 8, totalLines: 10, percent: 80 },
];
const toolReport = buildToolReport("bun", head, null, []);
const fullReport = buildFullReport([toolReport]);
const commitInfo: CommitInfo = {
sha: "abc1234567890",
owner: "myorg",
repo: "myrepo",
};
const md = renderReport(fullReport, "<!-- m -->", true, commitInfo);

expect(md).toContain("Project coverage is 80.00%.");
expect(md).toContain("Commit [`abc1234`]");
expect(md).toContain("https://github.com/myorg/myrepo/commit/abc1234567890");
expect(md).not.toContain("Comparing base");
});

test("renders project coverage without commit info", () => {
const head: FileCoverage[] = [
{ file: "a.ts", coveredLines: 5, totalLines: 10, percent: 50 },
];
const toolReport = buildToolReport("bun", head, null, []);
const fullReport = buildFullReport([toolReport]);
const md = renderReport(fullReport, "<!-- m -->", true);

expect(md).toContain("Project coverage is 50.00%.");
expect(md).not.toContain("Commit");
expect(md).not.toContain("Comparing");
});

test("coverage diff table shows correct values", () => {
const head: FileCoverage[] = [
{ file: "src/index.ts", coveredLines: 8, totalLines: 10, percent: 80 },
{ file: "src/utils.ts", coveredLines: 10, totalLines: 10, percent: 100 },
];
const base: CoverageArtifact = {
tool: "bun",
files: [
{ file: "src/index.ts", coveredLines: 7, totalLines: 10, percent: 70 },
{ file: "src/utils.ts", coveredLines: 9, totalLines: 10, percent: 90 },
],
commitSha: "abc",
branch: "main",
timestamp: "2025-01-01T00:00:00Z",
};
const toolReport = buildToolReport("bun", head, base, []);
const fullReport = buildFullReport([toolReport]);
const md = renderReport(fullReport, "<!-- m -->", true);

// Diff table header
expect(md).toContain("@@");
expect(md).toContain("Coverage Diff");
expect(md).toContain("base");
expect(md).toContain("head");
expect(md).toContain("+/-");
// Data values
expect(md).toContain("80.00%"); // base coverage
expect(md).toContain("90.00%"); // head coverage
expect(md).toContain("+10.00%"); // coverage delta
// File/line/hit counts
expect(md).toContain("Hits");
expect(md).toContain("16"); // baseCovered
expect(md).toContain("18"); // headCovered
expect(md).toContain("+2"); // hit delta
});

test("coverage diff table uses + prefix on hits when coverage improves", () => {
const head: FileCoverage[] = [
{ file: "a.ts", coveredLines: 9, totalLines: 10, percent: 90 },
];

const base: CoverageArtifact = {
tool: "bun",
files: [{ file: "a.ts", coveredLines: 7, totalLines: 10, percent: 70 }],
commitSha: "abc",
branch: "main",
timestamp: "2025-01-01T00:00:00Z",
};

const toolReport = buildToolReport("bun", head, base, []);
const fullReport = buildFullReport([toolReport]);
const md = renderReport(fullReport, "<!-- m -->", true);

// Hits row should start with "+" for improvement
const diffBlock = md.split("```diff")[1].split("```")[0];
const hitsLine = diffBlock.split("\n").find((l) => l.includes("Hits"))!;
expect(hitsLine.startsWith("+")).toBe(true);
});

test("coverage diff table uses - prefix on hits when coverage decreases", () => {
const head: FileCoverage[] = [
{ file: "a.ts", coveredLines: 5, totalLines: 10, percent: 50 },
];

const base: CoverageArtifact = {
tool: "bun",
files: [{ file: "a.ts", coveredLines: 7, totalLines: 10, percent: 70 }],
commitSha: "abc",
branch: "main",
timestamp: "2025-01-01T00:00:00Z",
};

const toolReport = buildToolReport("bun", head, base, []);
const fullReport = buildFullReport([toolReport]);
const md = renderReport(fullReport, "<!-- m -->", true);

expect(md).not.toContain("[Commit");
const diffBlock = md.split("```diff")[1].split("```")[0];
const hitsLine = diffBlock.split("\n").find((l) => l.includes("Hits"))!;
expect(hitsLine.startsWith("-")).toBe(true);
});
});