Coverage
`mandu test --coverage` turns on Bun's native line coverage and Playwright V8 coverage, then merges them into the canonical `.mandu/coverage/lcov.info`.
On this page
Coverage
--coverage turns on Bun's native line coverage and, when combined
with --e2e, also Playwright V8 coverage. Both outputs land in
separate LCOV files which Mandu merges into a single canonical file
at .mandu/coverage/lcov.info — the conventional location for
Codecov, Coveralls, genhtml, and nyc-report.
Quick start
# Unit + integration coverage only
mandu test --coverage
# Full coverage including E2E (Playwright V8)
mandu test --coverage --e2e
# CI: fail when below threshold
mandu test --coverage --bail
Output layout
.mandu/
└── coverage/
└── lcov.info # merged canonical output (LCOV v2)
Sources that get merged in:
coverage/lcov.info— Bun's default coverage output (perbunfig.toml).mandu/coverage/unit.lcov— optional Bun fallback locationcoverage/e2e.lcov— Playwright V8 coverage (viaPW_COVERAGE=1)
Missing inputs are silently skipped. Running --coverage unit-only
still produces .mandu/coverage/lcov.info — just without the E2E
records.
Threshold enforcement
Configure minimum coverage in mandu.config.ts:
// mandu.config.ts
export default {
test: {
coverage: {
lines: 80, // fail CI if < 80% lines hit
branches: 60, // reserved — not yet enforced in 0.24
},
},
};
When the threshold is set, mandu test --coverage exits 1 and
prints CLI_E065 with actual% < expected% if the line coverage
doesn't meet the gate:
CLI_E065: coverage threshold not met
lines: 72.4% < 80.0%
file: .mandu/coverage/lcov.info
Thresholds are evaluated on the merged LCOV, so unit + E2E hits are combined before the check.
Merging behavior
The merger (@mandujs/ate/coverage-merger) is a pure LCOV v2
parser/serializer:
DA:line hits are summed across inputs.FNDA:function hits are summed by function name.BRDA:branch hits are summed by (line, block, branch).- Records are emitted in sorted source-file order for byte-stable CI diffs.
LF/LH/BRF/BRH/FNF/FNHsummaries are recomputed after merge.
Round-trip invariant:
parse(serialize(parse(x))) === parse(x)
This means you can run the merge twice and the second run is a no-op.
Programmatic API
import {
mergeAndWriteLcov,
mergeLcovFiles,
parseLcov,
} from "@mandujs/ate";
// Merge + write in one call
const res = mergeAndWriteLcov({
repoRoot: process.cwd(),
inputs: [
{ label: "unit", source: { kind: "file", path: "coverage/lcov.info" } },
{ label: "e2e", source: { kind: "file", path: "coverage/e2e.lcov" } },
],
});
console.log(`merged ${res.summary.files} files → ${res.outputPath}`);
// Or merge in-memory for custom reporters
const parsed = mergeLcovFiles([/* ... */]);
console.log(parsed.lcov); // canonical body
console.log(parsed.summary); // { linesFound, linesHit, ... }
CI integration
GitHub Actions + Codecov
# .github/workflows/test.yml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
with:
bun-version: 1.3.12
- run: bun install --frozen-lockfile
- run: mandu test --coverage --e2e
- uses: codecov/codecov-action@v4
with:
files: .mandu/coverage/lcov.info
genhtml (HTML report)
mandu test --coverage --e2e
genhtml .mandu/coverage/lcov.info --output-directory .mandu/coverage/html
open .mandu/coverage/html/index.html
Exit codes
| Code | Meaning |
|---|---|
0 |
All tests passed + coverage ≥ threshold |
1 |
Any test failed OR coverage below threshold |
4 |
Coverage config invalid (schema error) |
Troubleshooting
.mandu/coverage/lcov.info is missing — check that --coverage
was passed. When no LCOV sources exist on disk, the merge step is
skipped (no empty file is written).
Threshold fails unexpectedly — inspect the merged LCOV directly to see per-file LF/LH. A single file with very low coverage can drag the aggregate below the line threshold even when "most" files are fine.
Playwright coverage is empty — ensure your test files honor
PW_COVERAGE=1. Mandu's generated specs from ateGenerate include
the V8 collection block by default. If you hand-wrote a spec, add:
test.beforeEach(async ({ page }) => {
if (process.env.PW_COVERAGE) {
await page.coverage.startJSCoverage();
}
});
🤖 Agent Prompt
Apply the guidance from the Mandu docs page at https://mandujs.com/docs/testing/coverage to my project.
Summary of the page:
`--coverage` merges LCOV v2 from Bun (`coverage/lcov.info`) + Playwright (`coverage/e2e.lcov`) → `.mandu/coverage/lcov.info`. Thresholds configured in `mandu.config.ts::test.coverage` fail CI with CLI_E065 when unmet.
Required invariants — must hold after your changes:
- Merged LCOV always lands at `.mandu/coverage/lcov.info`
- Missing input files are silently skipped — single-leg coverage still produces the merged output
- Thresholds are evaluated on the MERGED LCOV — unit + E2E hits combine before the check
- Exit code 1 on threshold failure, with CLI_E065 and `actual% < expected%`
- Round-trip: `parse(serialize(parse(x))) === parse(x)` — merge is byte-stable for CI diffs
Then:
1. Make the change in my codebase consistent with the page.
2. Run `bun run guard` and `bun run check` to verify nothing
in src/ or app/ breaks Mandu's invariants.
3. Show me the diff and any guard violations.
Related
- E2E tests —
--coveragepairs well with--e2efor end-to-end coverage. - Unit tests — fast unit coverage is the bedrock of any merged report.
For Agents
{
"schema": "mandu.testing.coverage/v0.24",
"command": "mandu test --coverage",
"output_path": ".mandu/coverage/lcov.info",
"merge_sources": [
"coverage/lcov.info",
".mandu/coverage/unit.lcov",
"coverage/e2e.lcov"
],
"merge_rules": [
"DA lines: sum",
"FNDA functions: sum by name",
"BRDA branches: sum by (line, block, branch)",
"LF/LH/BRF/BRH/FNF/FNH: recomputed",
"records sorted by source path (byte-stable)"
],
"threshold_error_code": "CLI_E065",
"programmatic_api": "@mandujs/ate/coverage-merger",
"rules": [
"Merge is idempotent: running twice is a no-op",
"Missing inputs are skipped — empty merge never writes a file",
"Thresholds evaluate on merged LCOV, not per-leg"
]
}For Agents
`--coverage` merges LCOV v2 from Bun (`coverage/lcov.info`) + Playwright (`coverage/e2e.lcov`) → `.mandu/coverage/lcov.info`. Thresholds configured in `mandu.config.ts::test.coverage` fail CI with CLI_E065 when unmet.
- Merged LCOV always lands at `.mandu/coverage/lcov.info`
- Missing input files are silently skipped — single-leg coverage still produces the merged output
- Thresholds are evaluated on the MERGED LCOV — unit + E2E hits combine before the check
- Exit code 1 on threshold failure, with CLI_E065 and `actual% < expected%`
- Round-trip: `parse(serialize(parse(x))) === parse(x)` — merge is byte-stable for CI diffs