Skip to content

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 #65946

Description

@aral

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-cache entry.
  • 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions