LangENKO

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`.

since v0.24
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 (per bunfig.toml)
  • .mandu/coverage/unit.lcov — optional Bun fallback location
  • coverage/e2e.lcov — Playwright V8 coverage (via PW_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/FNH summaries 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

🤖 Agent Prompt — Coverage
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.
  • E2E tests--coverage pairs well with --e2e for 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

AI hint

`--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.

Invariants
  • 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
Guard scope
testing