test_runner: source-mapped coverage resolves ranges starting at column 0 to the previous line, so executed functions report as uncovered under off-thread module hooks
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 122k
- Forks
- 37.3k
- Avg merge
- 4d 2h
- Merged PRs (30d)
- 283
Description
Version
v24.19.0 and v26.8.2
Platform
Darwin 24.6.0 arm64
Subsystem
test_runner
What steps will reproduce the bug?
Five files in an empty directory. The load hook transpiles .ts with esbuild and an inline source map. The hooks module also imports the file being tested.
package.json
{ "type": "module", "devDependencies": { "esbuild": "0.28.1" } }
lib.ts
/**
Adds two numbers.
*/
export function add (a: number, b: number): number {
const sum = a + b
return sum
}
/**
Greets someone by name.
*/
export function greet (name: string): string {
const greeting = `Hello, ${name}`
return greeting
}
hooks.mjs
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { transform } from 'esbuild'
// The hooks module imports the file under test. Hooks do not apply to the
// hooks thread, so this copy is compiled by native type stripping.
import { greet } from './lib.ts'
export async function load (url, context, nextLoad) {
if (!url.endsWith('.ts')) return nextLoad(url, context)
const source = await readFile(fileURLToPath(url), 'utf8')
const { code } = await transform(source, {
loader: 'ts',
format: 'esm',
sourcefile: fileURLToPath(url),
sourcemap: 'inline'
})
return { format: 'module', source: code, shortCircuit: true }
}
register.mjs
import { register } from 'node:module'
register('./hooks.mjs', import.meta.url)
lib.test.mjs
import test from 'node:test'
import assert from 'node:assert/strict'
import { add, greet } from './lib.ts'
test('add', () => assert.equal(add(1, 2), 3))
test('greet', () => assert.equal(greet('world'), 'Hello, world'))
Run:
npm install
node --enable-source-maps --import ./register.mjs --test --experimental-test-coverage lib.test.mjs
How often does it reproduce? Is there a required condition?
Every time.
What is the expected behavior? Why is that the expected behavior?
Both tests pass and both functions run, so lib.ts should report 100% functions and 100% of executable lines covered. Commenting out the import { greet } from './lib.ts' line in hooks.mjs gives us that:
file | line % | branch % | funcs % | uncovered lines
lib.ts | 80.00 | 100.00 | 100.00 | 1-3
(Lines 1 to 3 are the leading comment. That is a separate issue with empty-lines – see #60996.)
What do you see instead?
With the import in hooks.mjs, the body of greet, called by the test, is reported as uncovered and one of the two functions is reported as not run:
file | line % | branch % | funcs % | uncovered lines
lib.ts | 80.00 | 100.00 | 75.00 | 13-15
The --experimental-loader ./hooks.mjs form gives the same result. Output is identical on v24.19.0 and v26.8.2.
Registering the same hook in-thread with module.registerHooks() (using transformSync) reports 100% on both versions, so the problem is confined to off-thread hooks: module.register() and --experimental-loader.
Additional information
Reproduction without esbuild
The same happens with a hand-written transform and source map, so it’s not specific to esbuild’s output. Replace lib.ts with a plain lib.js that has the same content minus the type annotations, point the test at ./lib.js, and use this hooks module instead. It drops comment and blank lines, moves export off the declarations into a trailing export list, and emits a map with one segment per generated line pointing at the original token.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { greet } from './lib.js'
const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
function vlq (value) {
let v = value < 0 ? ((-value) << 1) | 1 : value << 1
let out = ''
do { let digit = v & 31; v >>>= 5; if (v > 0) digit |= 32; out += BASE64[digit] } while (v > 0)
return out
}
export async function load (url, context, nextLoad) {
if (!url.endsWith('/lib.js')) return nextLoad(url, context)
const source = await readFile(fileURLToPath(url), 'utf8')
const generated = [], segments = [], exported = []
let inComment = false, previousLine = 0, previousColumn = 0
source.split('\n').forEach((line, index) => {
if (line.startsWith('/**')) inComment = true
if (inComment) { if (line.startsWith('*/')) inComment = false; return }
if (line.trim() === '') return
let column = 0
if (line.startsWith('export function ')) {
column = 'export '.length
exported.push(line.match(/^export function (\w+)/)[1])
line = line.slice(column)
}
generated.push(line)
segments.push(vlq(0) + vlq(0) + vlq(index - previousLine) + vlq(column - previousColumn))
previousLine = index; previousColumn = column
})
generated.push(`export { ${exported.join(', ')} }`)
segments.push('')
const map = { version: 3, sources: [fileURLToPath(url)], names: [], mappings: segments.join(';') }
const code = generated.join('\n') + '\n//# sourceMappingURL=data:application/json;base64,' + Buffer.from(JSON.stringify(map)).toString('base64')
return { format: 'module', source: code, shortCircuit: true }
}
With the import { greet } from './lib.js' line present, v24.19.0 and v26.8.2 both report lib.js at 75% functions although the test called both. With it removed, both report 100%.
(We can’t use Node’s stripTypeScriptTypes here as v26 accepts only mode: 'strip', which preserves offsets and needs no map, and v24’s transform output for this file happens to keep the function keyword off column 0.)
Further details
Running the repro with NODE_V8_COVERAGE set (so raw V8 coverage output is kept) shows where the incorrect numbers come from. The process writes two coverage files, one per thread, and both contain a record for file:///…/lib.ts:
- The main-thread record is for the esbuild output and has a
source-map-cacheentry. - The hooks-thread record is for the natively type-stripped source and has no source map. Its functions have count 0 because nothing on that thread calls them.
Two things in lib/internal/test_runner/coverage.js combine to produce the wrong totals.
1. mapRangeToLines can resolve a range that starts at column 0 to the previous line. mapCoverageWithSourceMap builds executedLines from lineLengths with new CoverageLine(i + 1, offset, null, length + 1). With src null the newline is not subtracted, so each line's endOffset equals the next line's startOffset. In the repro the esbuild output has add at generated offsets 0 to 57 and greet starting at offset 58, which is column 0 of generated line 5. Generated line 4 (the }) spans 56 to 58 inclusive, so offset 58 satisfies the startOffset <= line.endOffset test for both lines, and the binary search picks line 4. findEntry(3, 2) then returns the segment for that }, and the start of greet maps to raw offset 113, the closing brace of add, instead of 156. The esbuild map itself is precise: its segment at generated line 5 column 0 points at line 12 column 7 of the source (the function keyword). add maps correctly as a range starting at offset 0 has no previous line to collide with.
This only happens with the main-thread record. On v24, Node’s stripTypeScriptTypes transform does not exhibit this behaviour because it emits the comment’s closing */ and the declaration on one line, as */ export function greet(name) {, so the keyword is at column 10 and the lookup is unambiguous.
2. mergeCoverage merges records by URL alone. mergeCoverageScripts matches functions between records by name plus the start and end offset of the first range. The hooks-thread record uses raw-file offsets, since the copy is compiled using native type stripping without a source map. Because of (1) the mapped main-thread start offset for greet differs from the raw one, the functions fail to match, both copies are kept, and the zero-count copy from the hooks thread wins in the line and function totals.
In a larger real file the effect is greater. In my project (Kitten; https://codeberg.org/kitten/app) the same setup reports the three largest functions in a 1,500-line renderer as uncovered, with line coverage of 49.58% where the main-thread record gives 81.03%.
Fixing (1), e.g., by testing startOffset < line.endOffset when locating the first line, makes the records match in this case. Merging by URL still assumes that every record for a URL is compiled from the same code, which isn’t true when the module-hooks thread loads a file natively while the main thread loads it using a transforming hook. Skipping records from the hooks thread, or refusing to merge two records for a URL when only one of them has a source-map-cache entry, should fix that.
module.register() is deprecated on v26 (DEP0205) but still supported, and --experimental-loader still works, it’s also affected by this.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in lib/internal/test_runner/coverage.js and reproduce the issue with the provided off-thread hook command. Inspect mapRangeToLines, mapCoverageWithSourceMap, and mergeCoverage, then verify that the off-thread case reports both functions as covered without regressing in-thread hooks or existing coverage behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, node.js
- Domain
- testing-qa, tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100