@hyperfrontend/testing/coveragecoverage
The coverage side of a run: lcov reports merged per file, the threshold gate, the per-file table, and the completeness check for source files no test loaded.
Overview
Node's test runner can write an lcov report, and this entry is what turns one or more of those reports into a verdict.
mergeLcov reads the reports and returns one FileCoverage per path, holding execution counts per line, per function and per branch. fileTotals and overallTotals reduce those counters to found-and-hit pairs, percentOf turns a pair into a percentage, and uncoveredRanges lists the lines a file never ran as ranges. renderLcov writes the merged files back out as a single report for a coverage service to read.
Why reports are merged
Node measures each evaluation of a module as a script of its own, so after jest.resetModules a later import re-runs the module body and the report carries one record per evaluation. Treating those as separate files counts every line of the module once per evaluation while crediting only the evaluation that ran it, which drags the percentages down in proportion to how often a suite reset. Combining the records per file is what Jest's provider did implicitly. Reports from different environments merge the same way, so a file exercised by one environment and merely imported by another is judged on both.
The merge keys are chosen to hold still between runs. Node numbers anonymous functions by discovery order and numbers branch blocks in a way that shifts between runs and environments, so functions are keyed by declaration line and name with the anonymous numbering folded, and branches by line and branch index rather than block. Anything a node:coverage ignore comment covers is dropped rather than measured: Node omits an ignored line from the report but still writes the branches and functions sitting on it, marked untaken, and dropping them is what istanbul ignore used to mean.
What the gate judges
findThresholdShortfalls compares the merged totals of the whole project against the CoverageThresholds a project declares for lines, branches and functions, which is what a Jest global threshold meant, and returns one message per threshold missed. A metric with nothing to measure counts as complete, which is what Jest reported for a file holding no branches at all. There is no statements metric: Node does not measure one.
findUnmeasuredFiles restores the guarantee a full-coverage threshold is supposed to give. Node's report only names files the run loaded, so a source file no test imports vanishes instead of landing at zero percent, and a new, wholly untested file would not fail the build. Comparing the report against the include globs, less the exclude globs, names every file that should have been measured and was not.
renderCoverageTable is the human-readable form: one row per file with line, branch and function percentages and the uncovered line ranges, closed by the project's totals.
Usage
The runner calls all of this for you. Reach for the entry directly to judge reports another tool wrote, or to read a merged report off disk:
import { mergeLcov, overallTotals, percentOf, renderCoverageTable } from '@hyperfrontend/testing/coverage'
const reports = ['coverage/lcov.node.info', 'coverage/lcov.browser.info']
const files = [...mergeLcov(reports).values()]
for (const row of renderCoverageTable(files)) console.log(row)
console.log(`${percentOf(overallTotals(files).lines).toFixed(2)}% of lines ran`)API Reference§
ƒ Functions
Parameters
| Name | Type | Description |
|---|---|---|
§file | FileCoverage | The file's merged counters. |
Returns
MetricTotalsExample
Reducing a file's counters
const file = {
path: 'src/double.ts',
lines: new Map([[1, 5], [2, 4], [3, 0]]),
branches: new Map([['2,0', 4], ['2,1', 0]]),
functions: new Map([['1,double', 5]]),
}
fileTotals(file) // => { lines: { found: 3, hit: 2 }, branches: { found: 2, hit: 1 }, functions: { found: 1, hit: 1 } }The comparison is against the project as a whole, which is what a Jest
global threshold meant.Parameters
| Name | Type | Description |
|---|---|---|
§files | unknown | The merged files. |
§thresholds | CoverageThresholds | The percentages the project requires. |
Returns
string[ ]Example
Judging the merged report against a project's thresholds
const measured = [...mergeLcov(reports).values()]
findThresholdShortfalls(measured, { lines: 99, branches: 96, functions: 97 })
// => ['80.00% line coverage does not meet threshold of 99%', '50.00% branch coverage does not meet threshold of 96%']
findThresholdShortfalls(measured, undefined) // => [], nothing declared means nothing to missfindUnmeasuredFiles( measured: unknown, projectRoot: string, include: string[ ], exclude: string[ ]): CompletenessReport
Node's coverage only reports files the run actually loaded, so a source file no test imports simply vanishes instead of landing at zero percent. That silently removes the guarantee a full-coverage threshold is supposed to give: a new, wholly untested file would not fail the build. Comparing the report against the include globs restores it.
Parameters
Returns
CompletenessReportExample
Catching a source file no spec imported
const measured = [...mergeLcov(['/repo/coverage/libs/example/lcov.node.info']).values()]
findUnmeasuredFiles(measured, '/repo/libs/example', ['src/**/*.ts'], ['**/*.spec.ts'])
// => { missing: ['src/orphan.ts'], covered: 4 }A file appears more than once whenever a module was evaluated more than once: after
jest.resetModules a later import re-runs the module body, and V8 measures each evaluation as a script of its own. Treating those as separate files counts every line of the module once per evaluation while crediting only the evaluation that ran it, which drags the percentages down in proportion to how often a suite reset. Combining them is what Jest's provider did implicitly, by accumulating into counters keyed by file rather than by script. Reports from different environments merge the same way, so a file exercised by one environment and merely imported by another is judged on both.
Anything a
node:coverage ignore comment covers is dropped rather than measured. Node omits an ignored line from the report but still writes out the branches and functions sitting on it, marked untaken, while counting them as covered in its own totals. Dropping them is both what makes the two agree and what istanbul ignore used to mean.Parameters
| Name | Type | Description |
|---|---|---|
§lcovPaths | unknown | Paths to the emitted lcov files. |
Returns
Map< string, FileCoverage>Example
Combining every environment's report into one entry per file
const files = mergeLcov(['/repo/coverage/libs/example/lcov.node.info', '/repo/coverage/libs/example/lcov.browser.info'])
files.get('src/double.ts')?.lines // => Map { 1 => 5, 2 => 4, 3 => 0 }, each count summed across records and reportsParameters
| Name | Type | Description |
|---|---|---|
§files | Iterable< | The merged files. |
Returns
MetricTotalsExample
Adding up a whole report
overallTotals(mergeLcov(reports).values()).lines // => { found: 5, hit: 4 }, the sum over every fileA metric with nothing to measure is complete, which is what Jest reported for a file holding no branches at all.
Returns
numberExample
Rendering a metric as a percentage
percentOf({ found: 4, hit: 3 }) // => 75
percentOf({ found: 0, hit: 0 }) // => 100, nothing to measure counts as completeParameters
| Name | Type | Description |
|---|---|---|
§files | unknown | The merged files, in any order. |
Returns
string[ ]Example
Printing the table a run ends with
console.log(renderCoverageTable([...mergeLcov(reports).values()]).join('\n'))
// file | line % | branch % | funcs % | uncovered lines
// -------------------------------------------------------------
// src/double.ts | 66.67 | 50.00 | 100.00 | 3
// src/other.ts | 100.00 | 100.00 | 100.00 |
// -------------------------------------------------------------
// all files | 80.00 | 50.00 | 100.00 |
// -------------------------------------------------------------The raw reports are what Node measured, one per environment and one record per evaluation. A consumer reading coverage off disk wants what the gate judged instead: one record per file, with the counts combined. Paths are written as given, so a caller that needs them anchored somewhere other than the project root rebases them first.
Parameters
| Name | Type | Description |
|---|---|---|
§files | Iterable< | The merged files, in any order. |
Returns
stringExample
Writing the merged report back out
const merged = mergeLcov(['coverage/lcov.node.info', 'coverage/lcov.browser.info'])
writeFileSync('coverage/lcov.info', renderLcov(merged.values()))
// one record per file, its counts combined, in the shape a coverage service readsParameters
| Name | Type | Description |
|---|---|---|
§file | FileCoverage | The file's merged counters. |
Returns
string[ ]Example
Collapsing missed lines into ranges
const file = { path: 'src/a.ts', lines: new Map([[1, 1], [5, 0], [6, 0], [9, 0]]), branches: new Map(), functions: new Map() }
uncoveredRanges(file) // => ['5-6', '9']◆ Types
The counters stay as maps rather than totals because merging is the whole point: two records for the same file have to be combined per line, per function, and per branch before any percentage means anything.