From 8f409abf2c826df0aa8d846ca9dc0f9f768b6fbd Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Thu, 5 Mar 2026 20:27:06 +0100 Subject: [PATCH] feat: add node LCOV support and generated integration fixtures --- .github/workflows/release.yml | 20 +- README.md | 13 +- action.yml | 4 +- src/fixtures/bun-base.lcov | 268 ------------- src/fixtures/bun-head.lcov | 307 --------------- src/fixtures/bun/base.test.ts | 34 ++ src/fixtures/bun/calc.ts | 28 ++ src/fixtures/bun/head.test.ts | 46 +++ src/fixtures/bun/strutil.ts | 24 ++ src/fixtures/generate.ts | 87 +++++ src/fixtures/go-base.out | 35 -- src/fixtures/go-head.out | 33 -- src/fixtures/goproject/base_test.go | 34 ++ src/fixtures/goproject/calc.go | 29 ++ src/fixtures/goproject/go.mod | 3 + src/fixtures/goproject/head_test.go | 42 ++ src/fixtures/goproject/strutil.go | 32 ++ src/fixtures/node/base.test.js | 32 ++ src/fixtures/node/calc.js | 28 ++ src/fixtures/node/head.test.js | 44 +++ src/fixtures/node/strutil.js | 25 ++ src/index.ts | 5 +- src/integration_test.ts | 571 ++++++++++++++-------------- 23 files changed, 794 insertions(+), 950 deletions(-) delete mode 100644 src/fixtures/bun-base.lcov delete mode 100644 src/fixtures/bun-head.lcov create mode 100644 src/fixtures/bun/base.test.ts create mode 100644 src/fixtures/bun/calc.ts create mode 100644 src/fixtures/bun/head.test.ts create mode 100644 src/fixtures/bun/strutil.ts create mode 100644 src/fixtures/generate.ts delete mode 100644 src/fixtures/go-base.out delete mode 100644 src/fixtures/go-head.out create mode 100644 src/fixtures/goproject/base_test.go create mode 100644 src/fixtures/goproject/calc.go create mode 100644 src/fixtures/goproject/go.mod create mode 100644 src/fixtures/goproject/head_test.go create mode 100644 src/fixtures/goproject/strutil.go create mode 100644 src/fixtures/node/base.test.js create mode 100644 src/fixtures/node/calc.js create mode 100644 src/fixtures/node/head.test.js create mode 100644 src/fixtures/node/strutil.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7dbe5c8..7423d8f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,16 +46,16 @@ jobs: - name: Commit built artifact run: | - if git diff --quiet lib/index.js; then - echo "No changes to lib/index.js" - exit 0 - fi - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add lib/index.js - git commit -m "build: update lib/index.js" - git push origin master + if git diff --quiet lib/index.js; then + echo "No changes to lib/index.js" + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add lib/index.js + git commit -m "build: update lib/index.js" + git push origin master release-please: name: Release Please diff --git a/README.md b/README.md index 901fc6e..3dd5115 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ multiple languages, and shows meaningful per-file diffs without external depende ## Features -- Supports any LCOV-producing tool (Bun, Jest, c8, nyc, Istanbul, PHPUnit, …) and Go coverage +- Supports any LCOV-producing tool (Bun, Node.js, Jest, c8, nyc, Istanbul, PHPUnit, …) and Go coverage - Shows per-file coverage deltas against base branch - Single sticky PR comment (updates existing, no spam) - Uses `@actions/cache` for cross-run comparison @@ -118,6 +118,7 @@ by prefix match to find the latest base-branch snapshot. | Tool | Format | Example | | --------- | ---------------- | ------------------------- | | `bun` | LCOV | `bun:coverage/lcov.info` | +| `node` | LCOV | `node:coverage/lcov.info` | | `lcov` | LCOV (generic) | `lcov:coverage/lcov.info` | | `go` | Go cover profile | `go:coverage.out` | | `gocover` | Go cover profile | `gocover:coverage.out` | @@ -136,6 +137,12 @@ by prefix match to find the latest base-branch snapshot. # Bun (produces LCOV) bun test --coverage --coverage-reporter=lcov +# Node.js (produces LCOV) +node --test \ + --experimental-test-coverage \ + --test-reporter=lcov --test-reporter-destination=coverage/lcov.info \ + --test-reporter=spec --test-reporter-destination=stdout + # Go go test -coverprofile=coverage.out ./... ``` @@ -151,6 +158,8 @@ bun run build # bundle to lib/index.mjs ## Related -- [actions/cache](https://github.com/actions/cache) +- [@actions/cache](https://github.com/actions/cache) +- [@actions/core](https://github.com/actions/core) +- [@actions/github](https://github.com/actions/github) - [Bun test coverage](https://bun.sh/docs/cli/test#coverage) - [Go test coverage](https://go.dev/blog/cover) diff --git a/action.yml b/action.yml index 8b72a3f..f157fd9 100644 --- a/action.yml +++ b/action.yml @@ -1,5 +1,5 @@ name: "Coverage" -description: "Parse Bun (LCOV) and Go coverage, compute per-file deltas, and post a sticky PR comment with monospace coverage output." +description: "Parse LCOV (Bun, Node.js, Jest, etc.) and Go coverage, compute per-file deltas, and post a sticky PR comment with monospace coverage output." author: "xseman" branding: @@ -10,7 +10,7 @@ inputs: coverage-artifact-paths: description: > Newline- or comma-separated list of coverage artifact paths. - Each entry is `:`, e.g. `bun:coverage/lcov.info` or `go:coverage.out`. + Each entry is `:`, e.g. `bun:coverage/lcov.info`, `node:coverage/lcov.info`, or `go:coverage.out`. required: true pull-request-number: description: > diff --git a/src/fixtures/bun-base.lcov b/src/fixtures/bun-base.lcov deleted file mode 100644 index 45fbf0e..0000000 --- a/src/fixtures/bun-base.lcov +++ /dev/null @@ -1,268 +0,0 @@ -TN: -SF:src/components/Button.tsx -FN:1,(anonymous_0) -FN:5,Button -FN:15,handleClick -FNDA:1,(anonymous_0) -FNDA:8,Button -FNDA:4,handleClick -FNF:3 -FNH:3 -DA:1,1 -DA:2,1 -DA:3,1 -DA:4,1 -DA:5,8 -DA:6,8 -DA:7,8 -DA:8,8 -DA:9,8 -DA:10,0 -DA:11,0 -DA:12,8 -DA:13,8 -DA:14,8 -DA:15,4 -DA:16,4 -DA:17,4 -DA:18,4 -DA:19,4 -DA:20,0 -DA:21,0 -DA:22,0 -DA:23,4 -DA:24,4 -LH:16 -LF:24 -end_of_record -TN: -SF:src/utils/format.ts -FN:1,formatCurrency -FN:10,formatDate -FN:20,formatPercent -FNDA:3,formatCurrency -FNDA:2,formatDate -FNDA:5,formatPercent -FNF:3 -FNH:3 -DA:1,3 -DA:2,3 -DA:3,3 -DA:4,3 -DA:5,3 -DA:6,3 -DA:7,3 -DA:8,0 -DA:9,3 -DA:10,2 -DA:11,2 -DA:12,2 -DA:13,2 -DA:14,2 -DA:15,0 -DA:16,2 -DA:17,2 -DA:18,2 -DA:19,2 -DA:20,5 -DA:21,5 -DA:22,5 -DA:23,5 -DA:24,5 -DA:25,5 -DA:26,5 -DA:27,5 -DA:28,5 -DA:29,5 -LH:25 -LF:29 -end_of_record -TN: -SF:src/api/client.ts -FN:1,createClient -FN:12,fetchData -FN:30,postData -FNDA:5,createClient -FNDA:4,fetchData -FNDA:2,postData -FNF:3 -FNH:3 -DA:1,5 -DA:2,5 -DA:3,5 -DA:4,5 -DA:5,5 -DA:6,5 -DA:7,5 -DA:8,5 -DA:9,5 -DA:10,5 -DA:11,5 -DA:12,4 -DA:13,4 -DA:14,4 -DA:15,4 -DA:16,4 -DA:17,0 -DA:18,0 -DA:19,4 -DA:20,4 -DA:21,4 -DA:22,4 -DA:23,4 -DA:24,4 -DA:25,4 -DA:26,4 -DA:27,4 -DA:28,4 -DA:29,4 -DA:30,2 -DA:31,2 -DA:32,2 -DA:33,2 -DA:34,2 -DA:35,2 -DA:36,0 -DA:37,0 -DA:38,0 -DA:39,2 -DA:40,2 -LH:34 -LF:40 -end_of_record -TN: -SF:src/hooks/useAuth.ts -FN:1,useAuth -FN:20,login -FN:35,logout -FNDA:10,useAuth -FNDA:6,login -FNDA:3,logout -FNF:3 -FNH:3 -DA:1,10 -DA:2,10 -DA:3,10 -DA:4,10 -DA:5,10 -DA:6,10 -DA:7,10 -DA:8,10 -DA:9,10 -DA:10,10 -DA:11,10 -DA:12,10 -DA:13,10 -DA:14,10 -DA:15,10 -DA:16,10 -DA:17,10 -DA:18,10 -DA:19,10 -DA:20,6 -DA:21,6 -DA:22,6 -DA:23,6 -DA:24,6 -DA:25,6 -DA:26,6 -DA:27,6 -DA:28,6 -DA:29,6 -DA:30,6 -DA:31,6 -DA:32,6 -DA:33,6 -DA:34,6 -DA:35,3 -DA:36,3 -DA:37,3 -DA:38,3 -DA:39,3 -DA:40,3 -DA:41,3 -DA:42,3 -DA:43,3 -DA:44,3 -LH:44 -LF:44 -end_of_record -TN: -SF:src/store/index.ts -FN:1,createStore -FN:15,dispatch -FN:30,subscribe -FN:40,getState -FNDA:15,createStore -FNDA:40,dispatch -FNDA:5,subscribe -FNDA:80,getState -FNF:4 -FNH:4 -DA:1,15 -DA:2,15 -DA:3,15 -DA:4,15 -DA:5,15 -DA:6,15 -DA:7,15 -DA:8,15 -DA:9,15 -DA:10,15 -DA:11,15 -DA:12,15 -DA:13,15 -DA:14,15 -DA:15,40 -DA:16,40 -DA:17,40 -DA:18,40 -DA:19,40 -DA:20,40 -DA:21,40 -DA:22,40 -DA:23,40 -DA:24,40 -DA:25,40 -DA:26,40 -DA:27,40 -DA:28,40 -DA:29,40 -DA:30,5 -DA:31,5 -DA:32,5 -DA:33,5 -DA:34,5 -DA:35,5 -DA:36,5 -DA:37,5 -DA:38,5 -DA:39,5 -DA:40,80 -DA:41,80 -DA:42,80 -DA:43,80 -DA:44,80 -DA:45,80 -LH:45 -LF:45 -end_of_record -TN: -SF:src/legacy/deprecated.ts -FN:1,oldHelper -FNDA:1,oldHelper -FNF:1 -FNH:1 -DA:1,1 -DA:2,1 -DA:3,1 -DA:4,1 -DA:5,0 -DA:6,0 -DA:7,0 -DA:8,0 -DA:9,0 -DA:10,1 -LH:5 -LF:10 -end_of_record diff --git a/src/fixtures/bun-head.lcov b/src/fixtures/bun-head.lcov deleted file mode 100644 index b633070..0000000 --- a/src/fixtures/bun-head.lcov +++ /dev/null @@ -1,307 +0,0 @@ -TN: -SF:src/components/Button.tsx -FN:1,(anonymous_0) -FN:5,Button -FN:15,handleClick -FN:25,handleHover -FNDA:1,(anonymous_0) -FNDA:12,Button -FNDA:8,handleClick -FNDA:0,handleHover -FNF:4 -FNH:3 -DA:1,1 -DA:2,1 -DA:3,1 -DA:4,1 -DA:5,12 -DA:6,12 -DA:7,12 -DA:8,12 -DA:9,12 -DA:10,0 -DA:11,0 -DA:12,12 -DA:13,12 -DA:14,12 -DA:15,8 -DA:16,8 -DA:17,8 -DA:18,8 -DA:19,8 -DA:20,0 -DA:21,0 -DA:22,0 -DA:23,8 -DA:24,8 -DA:25,0 -DA:26,0 -DA:27,0 -DA:28,0 -DA:29,0 -DA:30,0 -LH:18 -LF:30 -end_of_record -TN: -SF:src/utils/format.ts -FN:1,formatCurrency -FN:10,formatDate -FN:20,formatPercent -FN:30,truncateString -FNDA:5,formatCurrency -FNDA:3,formatDate -FNDA:7,formatPercent -FNDA:2,truncateString -FNF:4 -FNH:4 -DA:1,5 -DA:2,5 -DA:3,5 -DA:4,5 -DA:5,5 -DA:6,5 -DA:7,5 -DA:8,0 -DA:9,5 -DA:10,3 -DA:11,3 -DA:12,3 -DA:13,3 -DA:14,3 -DA:15,0 -DA:16,3 -DA:17,3 -DA:18,3 -DA:19,3 -DA:20,7 -DA:21,7 -DA:22,7 -DA:23,7 -DA:24,7 -DA:25,7 -DA:26,7 -DA:27,7 -DA:28,7 -DA:29,7 -DA:30,2 -DA:31,2 -DA:32,2 -DA:33,0 -DA:34,0 -DA:35,2 -LH:30 -LF:35 -end_of_record -TN: -SF:src/api/client.ts -FN:1,createClient -FN:12,fetchData -FN:30,postData -FN:50,deleteData -FN:65,handleError -FNDA:10,createClient -FNDA:8,fetchData -FNDA:6,postData -FNDA:0,deleteData -FNDA:4,handleError -FNF:5 -FNH:4 -DA:1,10 -DA:2,10 -DA:3,10 -DA:4,10 -DA:5,10 -DA:6,10 -DA:7,10 -DA:8,10 -DA:9,10 -DA:10,10 -DA:11,10 -DA:12,8 -DA:13,8 -DA:14,8 -DA:15,8 -DA:16,8 -DA:17,0 -DA:18,0 -DA:19,8 -DA:20,8 -DA:21,8 -DA:22,8 -DA:23,8 -DA:24,8 -DA:25,8 -DA:26,8 -DA:27,8 -DA:28,8 -DA:29,8 -DA:30,6 -DA:31,6 -DA:32,6 -DA:33,6 -DA:34,6 -DA:35,6 -DA:36,0 -DA:37,0 -DA:38,0 -DA:39,6 -DA:40,6 -DA:41,6 -DA:42,6 -DA:43,6 -DA:44,6 -DA:45,6 -DA:46,6 -DA:47,6 -DA:48,6 -DA:49,6 -DA:50,0 -DA:51,0 -DA:52,0 -DA:53,0 -DA:54,0 -DA:55,0 -DA:56,0 -DA:57,0 -DA:58,0 -DA:59,0 -DA:60,0 -DA:61,0 -DA:62,0 -DA:63,0 -DA:64,0 -DA:65,4 -DA:66,4 -DA:67,4 -DA:68,4 -DA:69,0 -DA:70,4 -LH:48 -LF:70 -end_of_record -TN: -SF:src/hooks/useAuth.ts -FN:1,useAuth -FN:20,login -FN:35,logout -FN:45,refreshToken -FNDA:15,useAuth -FNDA:10,login -FNDA:5,logout -FNDA:3,refreshToken -FNF:4 -FNH:4 -DA:1,15 -DA:2,15 -DA:3,15 -DA:4,15 -DA:5,15 -DA:6,15 -DA:7,15 -DA:8,15 -DA:9,15 -DA:10,15 -DA:11,15 -DA:12,15 -DA:13,15 -DA:14,15 -DA:15,15 -DA:16,15 -DA:17,15 -DA:18,15 -DA:19,15 -DA:20,10 -DA:21,10 -DA:22,10 -DA:23,10 -DA:24,10 -DA:25,10 -DA:26,10 -DA:27,10 -DA:28,10 -DA:29,10 -DA:30,10 -DA:31,10 -DA:32,10 -DA:33,10 -DA:34,10 -DA:35,5 -DA:36,5 -DA:37,5 -DA:38,5 -DA:39,5 -DA:40,5 -DA:41,5 -DA:42,5 -DA:43,5 -DA:44,5 -DA:45,3 -DA:46,3 -DA:47,3 -DA:48,3 -DA:49,0 -DA:50,0 -LH:48 -LF:50 -end_of_record -TN: -SF:src/store/index.ts -FN:1,createStore -FN:15,dispatch -FN:30,subscribe -FN:40,getState -FNDA:20,createStore -FNDA:50,dispatch -FNDA:8,subscribe -FNDA:100,getState -FNF:4 -FNH:4 -DA:1,20 -DA:2,20 -DA:3,20 -DA:4,20 -DA:5,20 -DA:6,20 -DA:7,20 -DA:8,20 -DA:9,20 -DA:10,20 -DA:11,20 -DA:12,20 -DA:13,20 -DA:14,20 -DA:15,50 -DA:16,50 -DA:17,50 -DA:18,50 -DA:19,50 -DA:20,50 -DA:21,50 -DA:22,50 -DA:23,50 -DA:24,50 -DA:25,50 -DA:26,50 -DA:27,50 -DA:28,50 -DA:29,50 -DA:30,8 -DA:31,8 -DA:32,8 -DA:33,8 -DA:34,8 -DA:35,8 -DA:36,8 -DA:37,8 -DA:38,8 -DA:39,8 -DA:40,100 -DA:41,100 -DA:42,100 -DA:43,100 -DA:44,100 -DA:45,100 -LH:45 -LF:45 -end_of_record diff --git a/src/fixtures/bun/base.test.ts b/src/fixtures/bun/base.test.ts new file mode 100644 index 0000000..a74a437 --- /dev/null +++ b/src/fixtures/bun/base.test.ts @@ -0,0 +1,34 @@ +import { + expect, + test, +} from "bun:test"; + +import { + add, + divide, +} from "./calc"; +import { + padLeft, + reverse, +} from "./strutil"; + +// Base tests: covers add, divide, reverse, padLeft +// Does NOT cover: subtract, multiply, modulo, capitalize, truncate + +test("add", () => { + expect(add(1, 2)).toBe(3); +}); + +test("divide", () => { + expect(divide(10, 2)).toBe(5); + expect(() => divide(1, 0)).toThrow("division by zero"); +}); + +test("reverse", () => { + expect(reverse("abc")).toBe("cba"); +}); + +test("padLeft", () => { + expect(padLeft("hi", 5, "0")).toBe("000hi"); + expect(padLeft("hello", 3, "0")).toBe("hello"); +}); diff --git a/src/fixtures/bun/calc.ts b/src/fixtures/bun/calc.ts new file mode 100644 index 0000000..61bb9bd --- /dev/null +++ b/src/fixtures/bun/calc.ts @@ -0,0 +1,28 @@ +export function add(a: number, b: number): number { + return a + b; +} + +export function subtract(a: number, b: number): number { + return a - b; +} + +export function multiply(a: number, b: number): number { + if (a === 0 || b === 0) { + return 0; + } + return a * b; +} + +export function divide(a: number, b: number): number { + if (b === 0) { + throw new Error("division by zero"); + } + return a / b; +} + +export function modulo(a: number, b: number): number { + if (b === 0) { + throw new Error("modulo by zero"); + } + return a % b; +} diff --git a/src/fixtures/bun/head.test.ts b/src/fixtures/bun/head.test.ts new file mode 100644 index 0000000..74b5a42 --- /dev/null +++ b/src/fixtures/bun/head.test.ts @@ -0,0 +1,46 @@ +import { + expect, + test, +} from "bun:test"; + +import { + add, + multiply, + subtract, +} from "./calc"; +import { + capitalize, + reverse, + truncate, +} from "./strutil"; + +// Head tests: covers add, subtract, multiply, reverse, capitalize, truncate +// Does NOT cover: divide, modulo, padLeft + +test("add", () => { + expect(add(1, 2)).toBe(3); + expect(add(-1, 1)).toBe(0); +}); + +test("subtract", () => { + expect(subtract(5, 3)).toBe(2); +}); + +test("multiply", () => { + expect(multiply(3, 4)).toBe(12); + expect(multiply(0, 5)).toBe(0); +}); + +test("reverse", () => { + expect(reverse("abc")).toBe("cba"); +}); + +test("capitalize", () => { + expect(capitalize("hello")).toBe("Hello"); + expect(capitalize("")).toBe(""); +}); + +test("truncate", () => { + expect(truncate("hello world", 5)).toBe("hello..."); + expect(truncate("hi", 10)).toBe("hi"); +}); diff --git a/src/fixtures/bun/strutil.ts b/src/fixtures/bun/strutil.ts new file mode 100644 index 0000000..71ddbbb --- /dev/null +++ b/src/fixtures/bun/strutil.ts @@ -0,0 +1,24 @@ +export function reverse(s: string): string { + return s.split("").reverse().join(""); +} + +export function capitalize(s: string): string { + if (s.length === 0) { + return s; + } + return s[0].toUpperCase() + s.slice(1); +} + +export function truncate(s: string, maxLen: number): string { + if (s.length <= maxLen) { + return s; + } + return s.slice(0, maxLen) + "..."; +} + +export function padLeft(s: string, len: number, ch: string): string { + if (s.length >= len) { + return s; + } + return ch.repeat(len - s.length) + s; +} diff --git a/src/fixtures/generate.ts b/src/fixtures/generate.ts new file mode 100644 index 0000000..2a51dd3 --- /dev/null +++ b/src/fixtures/generate.ts @@ -0,0 +1,87 @@ +/** + * On-the-fly fixture generation — spawns real `bun test`, `node --test`, and + * `go test` to produce authentic coverage artifacts for integration tests. + * + * Each generator returns the raw string content (LCOV or Go cover profile) + * identical to what the action would read from disk. + */ + +import { $ } from "bun"; +import { + mkdtempSync, + readFileSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const FIXTURES = import.meta.dir; + +/** Run a callback with a temporary directory, cleaned up afterward. */ +async function withTmpDir( + prefix: string, + fn: (dir: string) => Promise, +): Promise { + const dir = mkdtempSync(join(tmpdir(), prefix)); + try { + return await fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** Tool-specific coverage generators. Each takes a test-file or pattern arg. */ +const generators = { + bun: (testFile: string) => + withTmpDir("bun-cov-", async (tmp) => { + await $`bun test ${testFile} --coverage --coverage-reporter=lcov --coverage-dir=${tmp}` + .cwd(join(FIXTURES, "bun")) + .quiet(); + return readFileSync(join(tmp, "lcov.info"), "utf-8"); + }), + + node: (testFile: string) => + $`node --test --experimental-test-coverage --test-reporter=lcov --test-coverage-exclude='**/*.test.js' ${testFile}` + .cwd(join(FIXTURES, "node")) + .text(), + + go: (testPattern: string) => + withTmpDir("go-cov-", async (tmp) => { + const out = join(tmp, "coverage.out"); + await $`go test -run ${testPattern} -coverprofile=${out} ./...` + .cwd(join(FIXTURES, "goproject")) + .quiet(); + return readFileSync(out, "utf-8"); + }), +} as const; + +/** Head and base arguments for each tool. */ +const variants = { + bun: { head: "head.test.ts", base: "base.test.ts" }, + node: { head: "head.test.js", base: "base.test.js" }, + go: { + head: "^Test(Add|Subtract|Multiply|Reverse|Capitalize|Truncate)$", + base: "^Test(AddBase|DivideBase|ReverseBase|PadLeftBase)$", + }, +} as const; + +export type ToolName = keyof typeof generators; +export type GeneratedFixtures = Record; + +/** + * Generate all fixture coverage data by running real tools. + * Tools run in parallel; head and base variants run in parallel per tool. + * Call once in `beforeAll` — results are deterministic per test run. + */ +export async function generateFixtures(): Promise { + const entries = await Promise.all( + (Object.keys(generators) as ToolName[]).map(async (tool) => { + const gen = generators[tool]; + const v = variants[tool]; + const [head, base] = await Promise.all([gen(v.head), gen(v.base)]); + return [tool, { head, base }] as const; + }), + ); + + return Object.fromEntries(entries) as GeneratedFixtures; +} diff --git a/src/fixtures/go-base.out b/src/fixtures/go-base.out deleted file mode 100644 index 8e0a39f..0000000 --- a/src/fixtures/go-base.out +++ /dev/null @@ -1,35 +0,0 @@ -mode: set -github.com/myorg/myapp/cmd/server/main.go:15.13,20.2 5 1 -github.com/myorg/myapp/cmd/server/main.go:22.32,30.2 8 1 -github.com/myorg/myapp/cmd/server/main.go:32.29,40.2 8 1 -github.com/myorg/myapp/internal/auth/jwt.go:10.40,18.2 8 1 -github.com/myorg/myapp/internal/auth/jwt.go:20.42,28.2 8 1 -github.com/myorg/myapp/internal/auth/jwt.go:30.39,38.2 8 0 -github.com/myorg/myapp/internal/auth/jwt.go:40.35,48.2 8 0 -github.com/myorg/myapp/internal/auth/jwt.go:50.41,55.2 5 0 -github.com/myorg/myapp/internal/auth/middleware.go:8.48,15.2 7 1 -github.com/myorg/myapp/internal/auth/middleware.go:17.44,25.2 8 1 -github.com/myorg/myapp/internal/auth/middleware.go:27.47,35.2 8 0 -github.com/myorg/myapp/internal/auth/middleware.go:37.40,42.2 5 0 -github.com/myorg/myapp/internal/db/postgres.go:12.34,20.2 8 1 -github.com/myorg/myapp/internal/db/postgres.go:22.38,35.2 13 1 -github.com/myorg/myapp/internal/db/postgres.go:37.41,50.2 13 0 -github.com/myorg/myapp/internal/db/postgres.go:52.36,58.2 6 0 -github.com/myorg/myapp/internal/db/postgres.go:60.37,68.2 8 0 -github.com/myorg/myapp/internal/handler/user.go:10.45,20.2 10 1 -github.com/myorg/myapp/internal/handler/user.go:22.47,32.2 10 1 -github.com/myorg/myapp/internal/handler/user.go:34.50,44.2 10 0 -github.com/myorg/myapp/internal/handler/user.go:46.50,56.2 10 0 -github.com/myorg/myapp/internal/handler/user.go:58.47,68.2 10 0 -github.com/myorg/myapp/internal/handler/health.go:5.38,10.2 5 1 -github.com/myorg/myapp/internal/handler/health.go:12.40,18.2 6 1 -github.com/myorg/myapp/internal/model/user.go:8.30,12.2 4 1 -github.com/myorg/myapp/internal/model/user.go:14.35,20.2 6 1 -github.com/myorg/myapp/internal/model/user.go:22.33,28.2 6 1 -github.com/myorg/myapp/internal/model/user.go:30.28,35.2 5 1 -github.com/myorg/myapp/pkg/config/config.go:10.28,20.2 10 1 -github.com/myorg/myapp/pkg/config/config.go:22.30,28.2 6 1 -github.com/myorg/myapp/pkg/config/config.go:30.35,40.2 10 0 -github.com/myorg/myapp/pkg/config/config.go:42.32,50.2 8 0 -github.com/myorg/myapp/internal/deprecated/old.go:5.20,15.2 10 1 -github.com/myorg/myapp/internal/deprecated/old.go:17.25,22.2 5 0 diff --git a/src/fixtures/go-head.out b/src/fixtures/go-head.out deleted file mode 100644 index cc6019b..0000000 --- a/src/fixtures/go-head.out +++ /dev/null @@ -1,33 +0,0 @@ -mode: set -github.com/myorg/myapp/cmd/server/main.go:15.13,20.2 5 1 -github.com/myorg/myapp/cmd/server/main.go:22.32,30.2 8 1 -github.com/myorg/myapp/cmd/server/main.go:32.29,40.2 8 0 -github.com/myorg/myapp/internal/auth/jwt.go:10.40,18.2 8 1 -github.com/myorg/myapp/internal/auth/jwt.go:20.42,28.2 8 1 -github.com/myorg/myapp/internal/auth/jwt.go:30.39,38.2 8 1 -github.com/myorg/myapp/internal/auth/jwt.go:40.35,48.2 8 0 -github.com/myorg/myapp/internal/auth/jwt.go:50.41,55.2 5 1 -github.com/myorg/myapp/internal/auth/middleware.go:8.48,15.2 7 1 -github.com/myorg/myapp/internal/auth/middleware.go:17.44,25.2 8 1 -github.com/myorg/myapp/internal/auth/middleware.go:27.47,35.2 8 1 -github.com/myorg/myapp/internal/auth/middleware.go:37.40,42.2 5 0 -github.com/myorg/myapp/internal/db/postgres.go:12.34,20.2 8 1 -github.com/myorg/myapp/internal/db/postgres.go:22.38,35.2 13 1 -github.com/myorg/myapp/internal/db/postgres.go:37.41,50.2 13 1 -github.com/myorg/myapp/internal/db/postgres.go:52.36,58.2 6 0 -github.com/myorg/myapp/internal/db/postgres.go:60.37,68.2 8 0 -github.com/myorg/myapp/internal/handler/user.go:10.45,20.2 10 1 -github.com/myorg/myapp/internal/handler/user.go:22.47,32.2 10 1 -github.com/myorg/myapp/internal/handler/user.go:34.50,44.2 10 1 -github.com/myorg/myapp/internal/handler/user.go:46.50,56.2 10 0 -github.com/myorg/myapp/internal/handler/user.go:58.47,68.2 10 0 -github.com/myorg/myapp/internal/handler/health.go:5.38,10.2 5 1 -github.com/myorg/myapp/internal/handler/health.go:12.40,18.2 6 1 -github.com/myorg/myapp/internal/model/user.go:8.30,12.2 4 1 -github.com/myorg/myapp/internal/model/user.go:14.35,20.2 6 1 -github.com/myorg/myapp/internal/model/user.go:22.33,28.2 6 1 -github.com/myorg/myapp/internal/model/user.go:30.28,35.2 5 1 -github.com/myorg/myapp/pkg/config/config.go:10.28,20.2 10 1 -github.com/myorg/myapp/pkg/config/config.go:22.30,28.2 6 1 -github.com/myorg/myapp/pkg/config/config.go:30.35,40.2 10 1 -github.com/myorg/myapp/pkg/config/config.go:42.32,50.2 8 0 diff --git a/src/fixtures/goproject/base_test.go b/src/fixtures/goproject/base_test.go new file mode 100644 index 0000000..6c91bc4 --- /dev/null +++ b/src/fixtures/goproject/base_test.go @@ -0,0 +1,34 @@ +package fixture + +import "testing" + +// Base tests: covers Add, Divide, Reverse, PadLeft +// Does NOT cover: Subtract, Multiply, Modulo, Capitalize, Truncate + +func TestAddBase(t *testing.T) { + if Add(1, 2) != 3 { + t.Fatal("expected 3") + } +} + +func TestDivideBase(t *testing.T) { + result, err := Divide(10, 2) + if err != nil { + t.Fatal(err) + } + if result != 5 { + t.Fatal("expected 5") + } +} + +func TestReverseBase(t *testing.T) { + if Reverse("abc") != "cba" { + t.Fatal("expected cba") + } +} + +func TestPadLeftBase(t *testing.T) { + if PadLeft("hi", 5, '*') != "***hi" { + t.Fatal("expected ***hi") + } +} diff --git a/src/fixtures/goproject/calc.go b/src/fixtures/goproject/calc.go new file mode 100644 index 0000000..d0dd66b --- /dev/null +++ b/src/fixtures/goproject/calc.go @@ -0,0 +1,29 @@ +package fixture + +import "errors" + +func Add(a, b int) int { + return a + b +} + +func Subtract(a, b int) int { + return a - b +} + +func Multiply(a, b int) int { + return a * b +} + +func Divide(a, b int) (int, error) { + if b == 0 { + return 0, errors.New("division by zero") + } + return a / b, nil +} + +func Modulo(a, b int) (int, error) { + if b == 0 { + return 0, errors.New("modulo by zero") + } + return a % b, nil +} diff --git a/src/fixtures/goproject/go.mod b/src/fixtures/goproject/go.mod new file mode 100644 index 0000000..aa19a8b --- /dev/null +++ b/src/fixtures/goproject/go.mod @@ -0,0 +1,3 @@ +module example.com/fixture + +go 1.22 diff --git a/src/fixtures/goproject/head_test.go b/src/fixtures/goproject/head_test.go new file mode 100644 index 0000000..1adefbc --- /dev/null +++ b/src/fixtures/goproject/head_test.go @@ -0,0 +1,42 @@ +package fixture + +import "testing" + +// Head tests: covers Add, Subtract, Multiply, Reverse, Capitalize, Truncate +// Does NOT cover: Divide, Modulo, PadLeft + +func TestAdd(t *testing.T) { + if Add(1, 2) != 3 { + t.Fatal("expected 3") + } +} + +func TestSubtract(t *testing.T) { + if Subtract(5, 3) != 2 { + t.Fatal("expected 2") + } +} + +func TestMultiply(t *testing.T) { + if Multiply(3, 4) != 12 { + t.Fatal("expected 12") + } +} + +func TestReverse(t *testing.T) { + if Reverse("hello") != "olleh" { + t.Fatal("expected olleh") + } +} + +func TestCapitalize(t *testing.T) { + if Capitalize("hello") != "Hello" { + t.Fatal("expected Hello") + } +} + +func TestTruncate(t *testing.T) { + if Truncate("hello world", 5) != "hello..." { + t.Fatal("expected hello...") + } +} diff --git a/src/fixtures/goproject/strutil.go b/src/fixtures/goproject/strutil.go new file mode 100644 index 0000000..8b10d22 --- /dev/null +++ b/src/fixtures/goproject/strutil.go @@ -0,0 +1,32 @@ +package fixture + +import "strings" + +func Reverse(s string) string { + runes := []rune(s) + for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { + runes[i], runes[j] = runes[j], runes[i] + } + return string(runes) +} + +func Capitalize(s string) string { + if len(s) == 0 { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +func Truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} + +func PadLeft(s string, width int, pad rune) string { + for len(s) < width { + s = string(pad) + s + } + return s +} diff --git a/src/fixtures/node/base.test.js b/src/fixtures/node/base.test.js new file mode 100644 index 0000000..d600af3 --- /dev/null +++ b/src/fixtures/node/base.test.js @@ -0,0 +1,32 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { + add, + divide, +} from "./calc.js"; +import { + padLeft, + reverse, +} from "./strutil.js"; + +// Base tests: covers add, divide, reverse, padLeft +// Does NOT cover: subtract, multiply, modulo, capitalize, truncate + +test("add", () => { + assert.equal(add(10, 20), 30); +}); + +test("divide", () => { + assert.equal(divide(10, 2), 5); + assert.throws(() => divide(5, 0), /division by zero/); +}); + +test("reverse", () => { + assert.equal(reverse("xyz"), "zyx"); +}); + +test("padLeft", () => { + assert.equal(padLeft("42", 5, "0"), "00042"); + assert.equal(padLeft("test", 2), "test"); +}); diff --git a/src/fixtures/node/calc.js b/src/fixtures/node/calc.js new file mode 100644 index 0000000..c4ed45e --- /dev/null +++ b/src/fixtures/node/calc.js @@ -0,0 +1,28 @@ +export function add(a, b) { + return a + b; +} + +export function subtract(a, b) { + return a - b; +} + +export function multiply(a, b) { + if (a === 0 || b === 0) { + return 0; + } + return a * b; +} + +export function divide(a, b) { + if (b === 0) { + throw new Error("division by zero"); + } + return a / b; +} + +export function modulo(a, b) { + if (b === 0) { + throw new Error("modulo by zero"); + } + return a % b; +} diff --git a/src/fixtures/node/head.test.js b/src/fixtures/node/head.test.js new file mode 100644 index 0000000..21ecb0a --- /dev/null +++ b/src/fixtures/node/head.test.js @@ -0,0 +1,44 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { + add, + multiply, + subtract, +} from "./calc.js"; +import { + capitalize, + reverse, + truncate, +} from "./strutil.js"; + +// Head tests: covers add, subtract, multiply, reverse, capitalize, truncate +// Does NOT cover: divide, modulo, padLeft + +test("add", () => { + assert.equal(add(1, 2), 3); + assert.equal(add(-1, 1), 0); +}); + +test("subtract", () => { + assert.equal(subtract(5, 3), 2); +}); + +test("multiply", () => { + assert.equal(multiply(3, 4), 12); + assert.equal(multiply(0, 5), 0); +}); + +test("reverse", () => { + assert.equal(reverse("abc"), "cba"); +}); + +test("capitalize", () => { + assert.equal(capitalize("hello"), "Hello"); + assert.equal(capitalize(""), ""); +}); + +test("truncate", () => { + assert.equal(truncate("hello world", 5), "hello..."); + assert.equal(truncate("hi", 10), "hi"); +}); diff --git a/src/fixtures/node/strutil.js b/src/fixtures/node/strutil.js new file mode 100644 index 0000000..6b85bfc --- /dev/null +++ b/src/fixtures/node/strutil.js @@ -0,0 +1,25 @@ +export function reverse(str) { + return str.split("").reverse().join(""); +} + +export function capitalize(str) { + if (str.length === 0) { + return str; + } + return str.charAt(0).toUpperCase() + str.slice(1); +} + +export function truncate(str, maxLen) { + if (str.length <= maxLen) { + return str; + } + return str.substring(0, maxLen) + "..."; +} + +export function padLeft(str, totalLen, padChar = " ") { + if (str.length >= totalLen) { + return str; + } + const padLen = totalLen - str.length; + return padChar.repeat(padLen) + str; +} diff --git a/src/index.ts b/src/index.ts index d11f5e6..25be8aa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -70,14 +70,15 @@ function parseFile(tool: string, filePath: string): { files: FileCoverage[]; war try { switch (tool) { - case "bun": case "lcov": + case "bun": + case "node": return { files: parseLcov(content), warnings }; case "go": case "gocover": return { files: parseGoCover(content), warnings }; default: - warnings.push(`Unknown tool "${tool}". Supported: bun, lcov, go, gocover.`); + warnings.push(`Unknown tool "${tool}". Supported: bun, lcov, node, go, gocover.`); return { files: [], warnings }; } } catch (err: unknown) { diff --git a/src/integration_test.ts b/src/integration_test.ts index d5129f1..48d251d 100644 --- a/src/integration_test.ts +++ b/src/integration_test.ts @@ -1,9 +1,11 @@ /** - * Integration tests — feed realistic Bun (LCOV) and Go coverage tool outputs - * through the full pipeline: parse → diff → build report → render markdown. + * Integration tests — spawn real `bun test`, `node --test`, and `go test` to + * produce authentic coverage artifacts, then feed them through the full pipeline: + * parse → diff → build report → render markdown. * - * Fixture files live in src/fixtures/ and mimic real `bun test --coverage - * --coverage-reporter=lcov` and `go test -coverprofile=…` output. + * Fixture source lives in src/fixtures/bun/, src/fixtures/node/, and + * src/fixtures/goproject/. Coverage data is generated on-the-fly via + * src/fixtures/generate.ts. */ import { beforeAll, @@ -11,139 +13,107 @@ import { expect, test, } from "bun:test"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; import { buildFullReport, buildToolReport, computeFileDiffs, -} from "./diff"; -import { parseGoCover } from "./go"; -import { parseLcov } from "./lcov"; -import { renderReport } from "./render"; +} from "./diff.js"; +import { generateFixtures } from "./fixtures/generate.js"; +import type { GeneratedFixtures } from "./fixtures/generate.js"; +import { parseGoCover } from "./go.js"; +import { parseLcov } from "./lcov.js"; +import { renderReport } from "./render.js"; import type { CoverageArtifact, FileCoverage, -} from "./types"; +} from "./types.js"; -const FIXTURES = join(import.meta.dir, "fixtures"); -const read = (name: string) => readFileSync(join(FIXTURES, name), "utf-8"); +// Raw coverage strings populated by real tool invocations +let F: GeneratedFixtures; -/** - * bun-head.lcov expected per-file results (DA-line counting): - * Button.tsx : 18 covered / 30 total = 60% - * format.ts : 30 covered / 35 total ≈ 85.71% - * client.ts : 48 covered / 70 total ≈ 68.57% - * useAuth.ts : 48 covered / 50 total = 96% - * store/index.ts : 45 covered / 45 total = 100% - */ -const BUN_HEAD_EXPECTED: Record = { - "src/components/Button.tsx": { covered: 19, total: 30, pct: 63.33 }, - "src/utils/format.ts": { covered: 31, total: 35, pct: 88.57 }, - "src/api/client.ts": { covered: 49, total: 70, pct: 70 }, - "src/hooks/useAuth.ts": { covered: 48, total: 50, pct: 96 }, - "src/store/index.ts": { covered: 45, total: 45, pct: 100 }, -}; +beforeAll(async () => { + F = await generateFixtures(); +}); -/** - * bun-base.lcov expected per-file results (DA-line counting): - * Button.tsx : 16/24 = 66.67% - * format.ts : 25/29 ≈ 86.21% - * client.ts : 34/40 = 85% - * useAuth.ts : 44/44 = 100% - * store/index.ts : 45/45 = 100% - * legacy/deprecated.ts : 5/10 = 50% - */ -const BUN_BASE_EXPECTED: Record = { - "src/components/Button.tsx": { covered: 19, total: 24, pct: 79.17 }, - "src/utils/format.ts": { covered: 27, total: 29, pct: 93.1 }, - "src/api/client.ts": { covered: 35, total: 40, pct: 87.5 }, - "src/hooks/useAuth.ts": { covered: 44, total: 44, pct: 100 }, - "src/store/index.ts": { covered: 45, total: 45, pct: 100 }, - "src/legacy/deprecated.ts": { covered: 5, total: 10, pct: 50 }, -}; +// --------------------------------------------------------------------------- +// Bun LCOV integration (real `bun test --coverage` output) +// --------------------------------------------------------------------------- describe("Bun LCOV integration", () => { let headFiles: FileCoverage[]; let baseFiles: FileCoverage[]; beforeAll(() => { - headFiles = parseLcov(read("bun-head.lcov")); - baseFiles = parseLcov(read("bun-base.lcov")); + headFiles = parseLcov(F.bun.head); + baseFiles = parseLcov(F.bun.base); }); - test("head: parses all 5 files", () => { - expect(headFiles).toHaveLength(5); + test("head: parses both source files", () => { + expect(headFiles).toHaveLength(2); + const names = headFiles.map((f) => f.file).sort(); + expect(names).toEqual(["calc.ts", "strutil.ts"]); }); - test("head: every file matches expected covered/total/percent", () => { - for (const f of headFiles) { - const exp = BUN_HEAD_EXPECTED[f.file]; - expect(exp).toBeDefined(); - expect(f.coveredLines).toBe(exp.covered); - expect(f.totalLines).toBe(exp.total); - expect(f.percent).toBe(exp.pct); - } + test("head: calc.ts has partial coverage (add, subtract, multiply covered; divide, modulo not)", () => { + const calc = headFiles.find((f) => f.file === "calc.ts")!; + expect(calc).toBeDefined(); + // 3 of 5 functions covered -> roughly 50-60% + expect(calc.coveredLines).toBeGreaterThan(0); + expect(calc.coveredLines).toBeLessThan(calc.totalLines); + expect(calc.percent).toBeGreaterThan(40); + expect(calc.percent).toBeLessThan(70); }); - test("base: parses all 6 files (includes legacy/deprecated.ts)", () => { - expect(baseFiles).toHaveLength(6); + test("head: strutil.ts has partial coverage (reverse, capitalize, truncate covered; padLeft not)", () => { + const strutil = headFiles.find((f) => f.file === "strutil.ts")!; + expect(strutil).toBeDefined(); + // 3 of 4 functions covered -> roughly 65-85% + expect(strutil.coveredLines).toBeGreaterThan(0); + expect(strutil.coveredLines).toBeLessThan(strutil.totalLines); + expect(strutil.percent).toBeGreaterThan(60); + expect(strutil.percent).toBeLessThan(90); }); - test("base: every file matches expected covered/total/percent", () => { - for (const f of baseFiles) { - const exp = BUN_BASE_EXPECTED[f.file]; - expect(exp).toBeDefined(); - expect(f.coveredLines).toBe(exp.covered); - expect(f.totalLines).toBe(exp.total); - expect(f.percent).toBe(exp.pct); - } + test("base: parses both source files", () => { + expect(baseFiles).toHaveLength(2); + const names = baseFiles.map((f) => f.file).sort(); + expect(names).toEqual(["calc.ts", "strutil.ts"]); }); - test("diff: detects deleted file from base (legacy/deprecated.ts)", () => { - const diffs = computeFileDiffs(headFiles, baseFiles); - const deleted = diffs.find((d) => d.file === "src/legacy/deprecated.ts"); - expect(deleted).toBeDefined(); - expect(deleted!.coveredLines).toBe(0); - expect(deleted!.totalLines).toBe(0); - expect(deleted!.percent).toBe(0); - expect(deleted!.basePercent).toBe(50); - expect(deleted!.delta).toBe(-50); - }); + test("base: different functions covered than head", () => { + const baseCalc = baseFiles.find((f) => f.file === "calc.ts")!; + const headCalc = headFiles.find((f) => f.file === "calc.ts")!; + // Base covers add+divide; head covers add+subtract+multiply -> different percentages + expect(baseCalc.percent).not.toBe(headCalc.percent); - test("diff: Button.tsx coverage decreased (79.17% → 63.33%)", () => { - const diffs = computeFileDiffs(headFiles, baseFiles); - const button = diffs.find((d) => d.file === "src/components/Button.tsx"); - expect(button).toBeDefined(); - expect(button!.percent).toBe(63.33); - expect(button!.basePercent).toBe(79.17); - expect(button!.delta).toBeLessThan(0); - expect(button!.delta).toBeCloseTo(-15.84, 1); + const baseStr = baseFiles.find((f) => f.file === "strutil.ts")!; + const headStr = headFiles.find((f) => f.file === "strutil.ts")!; + // Base covers reverse+padLeft; head covers reverse+capitalize+truncate + expect(baseStr.percent).not.toBe(headStr.percent); }); - test("diff: client.ts coverage decreased (87.5% → 70%)", () => { + test("diff: produces deltas for both files", () => { const diffs = computeFileDiffs(headFiles, baseFiles); - const client = diffs.find((d) => d.file === "src/api/client.ts"); - expect(client).toBeDefined(); - expect(client!.delta).toBeLessThan(0); - expect(client!.delta).toBeCloseTo(-17.5, 1); + expect(diffs).toHaveLength(2); + for (const d of diffs) { + expect(d.delta).not.toBeNull(); + expect(d.basePercent).not.toBeNull(); + } }); - test("diff: useAuth.ts stayed at ~100% → 96% (slight decrease)", () => { + test("diff: strutil.ts coverage improved head vs base", () => { const diffs = computeFileDiffs(headFiles, baseFiles); - const auth = diffs.find((d) => d.file === "src/hooks/useAuth.ts"); - expect(auth).toBeDefined(); - expect(auth!.basePercent).toBe(100); - expect(auth!.percent).toBe(96); - expect(auth!.delta).toBe(-4); + const strutil = diffs.find((d) => d.file === "strutil.ts")!; + // Head covers 3/4 funcs (~75%), base covers 2/4 (~53%) -> positive delta + expect(strutil.delta).toBeGreaterThan(0); }); - test("diff: store/index.ts unchanged at 100%", () => { + test("diff: calc.ts coverage changed between head and base", () => { const diffs = computeFileDiffs(headFiles, baseFiles); - const store = diffs.find((d) => d.file === "src/store/index.ts"); - expect(store).toBeDefined(); - expect(store!.delta).toBe(0); + const calc = diffs.find((d) => d.file === "calc.ts")!; + // Different functions covered -> non-zero delta + expect(calc.delta).not.toBe(0); }); test("buildToolReport: summary aggregates correctly", () => { @@ -156,170 +126,104 @@ describe("Bun LCOV integration", () => { }; const report = buildToolReport("bun", headFiles, baseArtifact, []); - // Head totals: 19+31+49+48+45 = 192 covered, 30+35+70+50+45 = 230 total - expect(report.summary.coveredLines).toBe(192); - expect(report.summary.totalLines).toBe(230); - expect(report.summary.percent).toBeCloseTo(83.48, 1); - - // Base totals: 19+27+35+44+45+5 = 175 covered, 24+29+40+44+45+10 = 192 total - expect(report.summary.baseCoveredLines).toBe(175); - expect(report.summary.baseTotalLines).toBe(192); - expect(report.summary.basePercent).toBeCloseTo(91.15, 0); - - // Delta is negative (overall coverage dropped) - expect(report.summary.delta).toBeLessThan(0); - }); + // Summary should reflect the 2 files + expect(report.summary.coveredLines).toBe( + headFiles.reduce((s, f) => s + f.coveredLines, 0), + ); + expect(report.summary.totalLines).toBe( + headFiles.reduce((s, f) => s + f.totalLines, 0), + ); + expect(report.summary.percent).toBeGreaterThan(0); + expect(report.summary.percent).toBeLessThan(100); - test("buildToolReport: includes deleted file in per-file list", () => { - const baseArtifact: CoverageArtifact = { - tool: "bun", - files: baseFiles, - commitSha: "base123", - branch: "main", - timestamp: "2026-01-01T00:00:00Z", - }; - const report = buildToolReport("bun", headFiles, baseArtifact, []); - // 5 head files + 1 deleted file = 6 - expect(report.files).toHaveLength(6); + // Base present -> delta computed + expect(report.summary.baseCoveredLines).toBe( + baseFiles.reduce((s, f) => s + f.coveredLines, 0), + ); + expect(report.summary.delta).not.toBeNull(); }); }); -/** - * go-head.out expected per-file (statement counting): - * cmd/server/main.go : 13 covered / 21 total = 61.90% - * internal/auth/jwt.go : 29 covered / 37 total = 78.38% (8+8+8+5=29, total 8+8+8+8+5=37) - * internal/auth/middleware.go : 23 covered / 28 total = 82.14% (7+8+8=23, total 7+8+8+5=28) - * internal/db/postgres.go : 34 covered / 48 total = 70.83% (8+13+13=34, total 8+13+13+6+8=48) - * internal/handler/user.go : 30 covered / 50 total = 60% (10+10+10=30, total 10+10+10+10+10=50) - * internal/handler/health.go : 11 covered / 11 total = 100% - * internal/model/user.go : 21 covered / 21 total = 100% - * pkg/config/config.go : 26 covered / 34 total = 76.47% (10+6+10=26, total 10+6+10+8=34) - */ -const GO_HEAD_EXPECTED: Record = { - "github.com/myorg/myapp/cmd/server/main.go": { covered: 13, total: 21, pct: 61.9 }, - "github.com/myorg/myapp/internal/auth/jwt.go": { covered: 29, total: 37, pct: 78.38 }, - "github.com/myorg/myapp/internal/auth/middleware.go": { covered: 23, total: 28, pct: 82.14 }, - "github.com/myorg/myapp/internal/db/postgres.go": { covered: 34, total: 48, pct: 70.83 }, - "github.com/myorg/myapp/internal/handler/user.go": { covered: 30, total: 50, pct: 60 }, - "github.com/myorg/myapp/internal/handler/health.go": { covered: 11, total: 11, pct: 100 }, - "github.com/myorg/myapp/internal/model/user.go": { covered: 21, total: 21, pct: 100 }, - "github.com/myorg/myapp/pkg/config/config.go": { covered: 26, total: 34, pct: 76.47 }, -}; - -/** - * go-base.out expected per-file: - * cmd/server/main.go : 21/21 = 100% (all 3 blocks covered) - * internal/auth/jwt.go : 16/37 = 43.24% (8+8=16) - * internal/auth/middleware.go : 15/28 = 53.57% (7+8=15) - * internal/db/postgres.go : 21/48 = 43.75% (8+13=21) - * internal/handler/user.go : 20/50 = 40% (10+10=20) - * internal/handler/health.go : 11/11 = 100% - * internal/model/user.go : 21/21 = 100% - * pkg/config/config.go : 16/34 = 47.06% (10+6=16) - * internal/deprecated/old.go : 10/15 = 66.67% - */ -const GO_BASE_EXPECTED: Record = { - "github.com/myorg/myapp/cmd/server/main.go": { covered: 21, total: 21, pct: 100 }, - "github.com/myorg/myapp/internal/auth/jwt.go": { covered: 16, total: 37, pct: 43.24 }, - "github.com/myorg/myapp/internal/auth/middleware.go": { covered: 15, total: 28, pct: 53.57 }, - "github.com/myorg/myapp/internal/db/postgres.go": { covered: 21, total: 48, pct: 43.75 }, - "github.com/myorg/myapp/internal/handler/user.go": { covered: 20, total: 50, pct: 40 }, - "github.com/myorg/myapp/internal/handler/health.go": { covered: 11, total: 11, pct: 100 }, - "github.com/myorg/myapp/internal/model/user.go": { covered: 21, total: 21, pct: 100 }, - "github.com/myorg/myapp/pkg/config/config.go": { covered: 16, total: 34, pct: 47.06 }, - "github.com/myorg/myapp/internal/deprecated/old.go": { covered: 10, total: 15, pct: 66.67 }, -}; +// --------------------------------------------------------------------------- +// Go cover profile integration (real `go test -coverprofile` output) +// --------------------------------------------------------------------------- describe("Go cover profile integration", () => { let headFiles: FileCoverage[]; let baseFiles: FileCoverage[]; beforeAll(() => { - headFiles = parseGoCover(read("go-head.out")); - baseFiles = parseGoCover(read("go-base.out")); + headFiles = parseGoCover(F.go.head); + baseFiles = parseGoCover(F.go.base); }); - test("head: parses all 8 files", () => { - expect(headFiles).toHaveLength(8); + test("head: parses both Go source files", () => { + expect(headFiles).toHaveLength(2); + const names = headFiles.map((f) => f.file).sort(); + expect(names).toEqual([ + "example.com/fixture/calc.go", + "example.com/fixture/strutil.go", + ]); }); - test("head: every file matches expected covered/total/percent", () => { - for (const f of headFiles) { - const exp = GO_HEAD_EXPECTED[f.file]; - expect(exp).toBeDefined(); - expect(f.coveredLines).toBe(exp.covered); - expect(f.totalLines).toBe(exp.total); - expect(f.percent).toBe(exp.pct); - } + test("head: calc.go — Add, Subtract, Multiply covered; Divide, Modulo not", () => { + const calc = headFiles.find((f) => f.file === "example.com/fixture/calc.go")!; + expect(calc).toBeDefined(); + // 3 statements covered (one per simple func), 6 uncovered (Divide+Modulo bodies) + expect(calc.coveredLines).toBe(3); + expect(calc.totalLines).toBe(9); + expect(calc.percent).toBe(33.33); }); - test("base: parses all 9 files (includes deprecated/old.go)", () => { - expect(baseFiles).toHaveLength(9); + test("head: strutil.go — Reverse, Capitalize, Truncate covered; PadLeft not", () => { + const strutil = headFiles.find((f) => f.file === "example.com/fixture/strutil.go")!; + expect(strutil).toBeDefined(); + expect(strutil.coveredLines).toBe(8); + expect(strutil.totalLines).toBe(13); + expect(strutil.percent).toBe(61.54); }); - test("base: every file matches expected covered/total/percent", () => { - for (const f of baseFiles) { - const exp = GO_BASE_EXPECTED[f.file]; - expect(exp).toBeDefined(); - expect(f.coveredLines).toBe(exp.covered); - expect(f.totalLines).toBe(exp.total); - expect(f.percent).toBe(exp.pct); - } + test("base: parses both Go source files", () => { + expect(baseFiles).toHaveLength(2); }); - test("head: files are sorted alphabetically", () => { - const paths = headFiles.map((f) => f.file); - const sorted = [...paths].sort(); - expect(paths).toEqual(sorted); + test("base: calc.go — Add and Divide covered; Subtract, Multiply, Modulo not", () => { + const calc = baseFiles.find((f) => f.file === "example.com/fixture/calc.go")!; + expect(calc).toBeDefined(); + // Add (1 stmt) + Divide non-zero path (2 stmts) = 3 covered + expect(calc.coveredLines).toBe(3); + expect(calc.totalLines).toBe(9); + expect(calc.percent).toBe(33.33); }); - test("diff: detects deleted file (deprecated/old.go)", () => { - const diffs = computeFileDiffs(headFiles, baseFiles); - const deleted = diffs.find((d) => - d.file === "github.com/myorg/myapp/internal/deprecated/old.go" - ); - expect(deleted).toBeDefined(); - expect(deleted!.percent).toBe(0); - expect(deleted!.basePercent).toBe(66.67); - expect(deleted!.delta).toBe(-66.67); + test("base: strutil.go — Reverse and PadLeft covered; Capitalize, Truncate not", () => { + const strutil = baseFiles.find((f) => f.file === "example.com/fixture/strutil.go")!; + expect(strutil).toBeDefined(); + expect(strutil.coveredLines).toBe(7); + expect(strutil.totalLines).toBe(13); + expect(strutil.percent).toBe(53.85); }); - test("diff: main.go coverage decreased (100% → 61.90%)", () => { - const diffs = computeFileDiffs(headFiles, baseFiles); - const main = diffs.find((d) => d.file === "github.com/myorg/myapp/cmd/server/main.go"); - expect(main).toBeDefined(); - expect(main!.delta).toBeCloseTo(-38.10, 1); - }); - - test("diff: jwt.go coverage increased (43.24% → 78.38%)", () => { - const diffs = computeFileDiffs(headFiles, baseFiles); - const jwt = diffs.find((d) => d.file === "github.com/myorg/myapp/internal/auth/jwt.go"); - expect(jwt).toBeDefined(); - expect(jwt!.delta).toBeGreaterThan(0); - expect(jwt!.delta).toBeCloseTo(35.14, 1); + test("head: files are sorted alphabetically", () => { + const paths = headFiles.map((f) => f.file); + const sorted = [...paths].sort(); + expect(paths).toEqual(sorted); }); - test("diff: middleware.go coverage increased (53.57% → 82.14%)", () => { + test("diff: strutil.go coverage improved (53.85% → 61.54%)", () => { const diffs = computeFileDiffs(headFiles, baseFiles); - const mw = diffs.find((d) => - d.file === "github.com/myorg/myapp/internal/auth/middleware.go" - ); - expect(mw).toBeDefined(); - expect(mw!.delta).toBeCloseTo(28.57, 1); + const strutil = diffs.find((d) => d.file === "example.com/fixture/strutil.go")!; + expect(strutil).toBeDefined(); + expect(strutil.delta).toBe(7.69); + expect(strutil.basePercent).toBe(53.85); + expect(strutil.percent).toBe(61.54); }); - test("diff: health.go and model/user.go unchanged at 100%", () => { + test("diff: calc.go coverage unchanged (33.33% → 33.33%, different stmts)", () => { const diffs = computeFileDiffs(headFiles, baseFiles); - for ( - const path of [ - "github.com/myorg/myapp/internal/handler/health.go", - "github.com/myorg/myapp/internal/model/user.go", - ] - ) { - const d = diffs.find((x) => x.file === path); - expect(d).toBeDefined(); - expect(d!.delta).toBe(0); - } + const calc = diffs.find((d) => d.file === "example.com/fixture/calc.go")!; + expect(calc).toBeDefined(); + expect(calc.delta).toBe(0); }); test("buildToolReport: summary aggregates correctly", () => { @@ -332,21 +236,25 @@ describe("Go cover profile integration", () => { }; const report = buildToolReport("go", headFiles, baseArtifact, []); - // Head totals: 13+29+23+34+30+11+21+26 = 187 covered, 21+37+28+48+50+11+21+34 = 250 total - expect(report.summary.coveredLines).toBe(187); - expect(report.summary.totalLines).toBe(250); - expect(report.summary.percent).toBe(74.8); + // Head: 3+8 = 11 covered, 9+13 = 22 total -> 50% + expect(report.summary.coveredLines).toBe(11); + expect(report.summary.totalLines).toBe(22); + expect(report.summary.percent).toBe(50); - // Base totals: 21+16+15+21+20+11+21+16+10 = 151 covered, 21+37+28+48+50+11+21+34+15 = 265 total - expect(report.summary.baseCoveredLines).toBe(151); - expect(report.summary.baseTotalLines).toBe(265); - expect(report.summary.basePercent).toBeCloseTo(56.98, 1); + // Base: 3+7 = 10 covered, 9+13 = 22 total -> 45.45% + expect(report.summary.baseCoveredLines).toBe(10); + expect(report.summary.baseTotalLines).toBe(22); + expect(report.summary.basePercent).toBe(45.45); // Coverage improved overall - expect(report.summary.delta).toBeGreaterThan(0); + expect(report.summary.delta).toBe(4.55); }); }); +// --------------------------------------------------------------------------- +// Multi-tool full pipeline +// --------------------------------------------------------------------------- + describe("Multi-tool full pipeline", () => { let bunHead: FileCoverage[]; let bunBase: FileCoverage[]; @@ -354,10 +262,10 @@ describe("Multi-tool full pipeline", () => { let goBase: FileCoverage[]; beforeAll(() => { - bunHead = parseLcov(read("bun-head.lcov")); - bunBase = parseLcov(read("bun-base.lcov")); - goHead = parseGoCover(read("go-head.out")); - goBase = parseGoCover(read("go-base.out")); + bunHead = parseLcov(F.bun.head); + bunBase = parseLcov(F.bun.base); + goHead = parseGoCover(F.go.head); + goBase = parseGoCover(F.go.base); }); test("buildFullReport: aggregates both tools", () => { @@ -382,20 +290,20 @@ describe("Multi-tool full pipeline", () => { expect(full.tools).toHaveLength(2); - // Overall: bun 192/230 + go 187/250 = 379/480 - expect(full.overall.coveredLines).toBe(192 + 187); - expect(full.overall.totalLines).toBe(230 + 250); - expect(full.overall.percent).toBeCloseTo(78.96, 0); - - // Base overall: bun 175/192 + go 151/265 = 326/457 - expect(full.overall.basePercent).toBeCloseTo(71.33, 0); + // Overall computed from raw line counts, not averaged + const expectedCovered = bunReport.summary.coveredLines + goReport.summary.coveredLines; + const expectedTotal = bunReport.summary.totalLines + goReport.summary.totalLines; + expect(full.overall.coveredLines).toBe(expectedCovered); + expect(full.overall.totalLines).toBe(expectedTotal); + expect(full.overall.percent).toBeGreaterThan(0); + expect(full.overall.percent).toBeLessThan(100); - // Coverage improved overall (78.96 - 71.33 ≈ +7.63) - expect(full.overall.delta).toBeGreaterThan(0); - expect(full.overall.delta).toBeCloseTo(7.63, 0); + // Base present -> delta computed + expect(full.overall.basePercent).not.toBeNull(); + expect(full.overall.delta).not.toBeNull(); }); - test("renderReport: produces valid markdown with both tool tables", () => { + test("renderReport: produces valid markdown with both tool sections", () => { const bunArtifact: CoverageArtifact = { tool: "bun", files: bunBase, @@ -424,19 +332,14 @@ describe("Multi-tool full pipeline", () => { expect(md).toContain("**Total Coverage:"); expect(md).toContain("xseman/coverage"); - // Specific file rows - expect(md).toContain("src/components/Button.tsx"); - expect(md).toContain("src/api/client.ts"); - expect(md).toContain("github.com/myorg/myapp/internal/auth/jwt.go"); - expect(md).toContain("github.com/myorg/myapp/cmd/server/main.go"); + // Real file paths from fixtures + expect(md).toContain("calc.ts"); + expect(md).toContain("strutil.ts"); + expect(md).toContain("example.com/fixture/calc.go"); + expect(md).toContain("example.com/fixture/strutil.go"); - // Delta markers present - expect(md).toContain("[+]"); // positive deltas (jwt.go improved) - expect(md).toContain("[-]"); // negative deltas (Button.tsx decreased) - - // Deleted files show up - expect(md).toContain("src/legacy/deprecated.ts"); - expect(md).toContain("github.com/myorg/myapp/internal/deprecated/old.go"); + // Delta markers present (strutil.go improved) + expect(md).toContain("[+]"); }); test("renderReport: colorize=off suppresses delta markers", () => { @@ -447,7 +350,6 @@ describe("Multi-tool full pipeline", () => { expect(md).not.toContain("[+]"); expect(md).not.toContain("[-]"); - // No base → no deltas shown at all expect(md).toContain("Bun Coverage:"); expect(md).toContain("Go Coverage:"); }); @@ -468,10 +370,14 @@ describe("Multi-tool full pipeline", () => { }); }); +// --------------------------------------------------------------------------- +// Edge cases with real-ish tool output +// --------------------------------------------------------------------------- + describe("Edge cases with real-ish tool output", () => { test("LCOV: file with only branch data (no DA lines) uses LH/LF fallback", () => { - // Some coverage tools emit BRDA lines but no DA lines - const input = `TN: + const input = ` +TN: SF:src/generated/schema.ts BRDA:1,0,0,1 BRDA:1,0,1,0 @@ -491,8 +397,8 @@ end_of_record }); test("LCOV: multiple records for same file path (should produce separate entries)", () => { - // This can happen with merge of multiple test runs - const input = `TN: + const input = ` +TN: SF:src/shared.ts DA:1,1 DA:2,0 @@ -506,15 +412,14 @@ DA:3,1 end_of_record `; const result = parseLcov(input); - // Parser treats each record independently — 2 entries expect(result).toHaveLength(2); - // First record: 2/3, second: 3/3 expect(result[0].coveredLines).toBe(2); expect(result[1].coveredLines).toBe(3); }); test("Go: mode: atomic with large counts", () => { - const input = `mode: atomic + const input = ` +mode: atomic github.com/myorg/app/hot.go:10.1,20.2 5 104857 github.com/myorg/app/hot.go:22.1,30.2 3 0 github.com/myorg/app/cold.go:5.1,8.2 2 0 @@ -533,7 +438,8 @@ github.com/myorg/app/cold.go:5.1,8.2 2 0 }); test("Go: lines with unusual spacing are still parsed", () => { - const input = `mode: set + const input = ` +mode: set github.com/myorg/app/spaced.go:1.1,5.2 3 1 github.com/myorg/app/spaced.go:7.1,10.2 2 0 `; @@ -544,7 +450,8 @@ github.com/myorg/app/spaced.go:7.1,10.2 2 0 }); test("Go: completely uncovered file produces 0%", () => { - const input = `mode: set + const input = ` +mode: set github.com/myorg/app/dead.go:1.1,10.2 5 0 github.com/myorg/app/dead.go:12.1,20.2 8 0 github.com/myorg/app/dead.go:22.1,25.2 3 0 @@ -597,28 +504,24 @@ github.com/myorg/app/dead.go:22.1,25.2 3 0 expect(md).toContain(""); expect(md).toContain("## Coverage Report"); expect(md).toContain("WARNING:"); - // Overall should be 100% (0/0 → convention) expect(full.overall.percent).toBe(100); expect(md).toContain("100.00%"); }); - test("LCOV: real-world bun output round-trips through full pipeline", () => { - // Parse → build report → render → verify no crash and valid structure - const files = parseLcov(read("bun-head.lcov")); + test("LCOV: real bun output round-trips through full pipeline", () => { + const files = parseLcov(F.bun.head); const report = buildToolReport("bun", files, null, []); const full = buildFullReport([report]); const md = renderReport(full, "", true); - // All 5 files present in markdown for (const f of files) { expect(md).toContain(f.file); } - // Summary line exists expect(md).toContain("Bun Coverage:"); }); - test("Go: real-world output round-trips through full pipeline", () => { - const files = parseGoCover(read("go-head.out")); + test("Go: real output round-trips through full pipeline", () => { + const files = parseGoCover(F.go.head); const report = buildToolReport("go", files, null, []); const full = buildFullReport([report]); const md = renderReport(full, "", true); @@ -630,10 +533,95 @@ github.com/myorg/app/dead.go:22.1,25.2 3 0 }); }); +// --------------------------------------------------------------------------- +// Node.js LCOV integration (real `node --test --experimental-test-coverage` output) +// --------------------------------------------------------------------------- + +describe("Node.js LCOV integration", () => { + test("Node.js LCOV format is parsed identically to Bun", () => { + const nodeFiles = parseLcov(F.node.head); + const bunFiles = parseLcov(F.bun.head); + // Both use same source logic, so same file count and names + expect(nodeFiles).toHaveLength(bunFiles.length); + }); + + test("Tool label renders as 'Node Coverage:'", () => { + const files = parseLcov(F.node.head); + const report = buildToolReport("node", files, null, []); + const full = buildFullReport([report]); + const md = renderReport(full, "", true); + + expect(md).toContain("Node Coverage:"); + expect(md).not.toContain("Bun Coverage:"); + expect(md).not.toContain("Lcov Coverage:"); + }); + + test("Node.js works in multi-tool reports", () => { + const nodeFiles = parseLcov(F.node.head); + const goFiles = parseGoCover(F.go.head); + + const nodeReport = buildToolReport("node", nodeFiles, null, []); + const goReport = buildToolReport("go", goFiles, null, []); + const full = buildFullReport([nodeReport, goReport]); + + expect(full.tools).toHaveLength(2); + expect(full.tools[0].tool).toBe("node"); + expect(full.tools[1].tool).toBe("go"); + + const md = renderReport(full, "", true); + expect(md).toContain("Node Coverage:"); + expect(md).toContain("Go Coverage:"); + expect(md).toContain("**Total Coverage:"); + }); + + test("Node.js with base comparison computes deltas", () => { + const headFiles = parseLcov(F.node.head); + const baseFiles = parseLcov(F.node.base); + + const baseArtifact: CoverageArtifact = { + tool: "node", + files: baseFiles, + commitSha: "node-base", + branch: "main", + timestamp: "2026-01-01T00:00:00Z", + }; + + const report = buildToolReport("node", headFiles, baseArtifact, []); + + // Real Node.js coverage data + expect(report.summary.coveredLines).toBe( + headFiles.reduce((s, f) => s + f.coveredLines, 0), + ); + expect(report.summary.baseCoveredLines).toBe( + baseFiles.reduce((s, f) => s + f.coveredLines, 0), + ); + expect(report.summary.delta).not.toBeNull(); + }); + + test("Node.js round-trips through full pipeline", () => { + const files = parseLcov(F.node.head); + const report = buildToolReport("node", files, null, []); + const full = buildFullReport([report]); + const md = renderReport(full, "", true); + + for (const f of files) { + expect(md).toContain(f.file); + } + expect(md).toContain("Node Coverage:"); + expect(md).toContain("calc.js"); + expect(md).toContain("strutil.js"); + }); +}); + +// --------------------------------------------------------------------------- +// Numeric precision +// --------------------------------------------------------------------------- + describe("Numeric precision", () => { test("LCOV percentages are rounded to 2 decimal places", () => { // 7/11 = 63.636363...% should round to 63.64% - const input = `SF:precision.ts + const input = ` +SF:precision.ts DA:1,1 DA:2,1 DA:3,0 @@ -652,8 +640,9 @@ end_of_record }); test("Go percentages are rounded to 2 decimal places", () => { - // 7/11 statements = 63.636363...% → 63.64% - const input = `mode: set + // 7/11 statements = 63.636363...% -> 63.64% + const input = ` +mode: set pkg/x.go:1.1,5.2 3 1 pkg/x.go:6.1,10.2 4 1 pkg/x.go:11.1,14.2 3 0