nodejs / nodejs/node

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

Offen
#65,946 2 Kommentare 0 Reaktionen 0 zugewiesene Personen Auf GitHub ansehen

Dieses Issue hat noch niemand übernommen.

Vorherrschende Sprache
JavaScript
Sterne
122k
Forks
37.3k
Ø Merge
4 T. 2 Std.
Gemergte PRs (30 T.)
283

Beschreibung

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.

Beitragsleitfaden

Beitragsleitfaden öffnen

Erste Schritte

  1. Lies das ganze Issue und danach den Beitragsleitfaden des Projekts.
  2. Schreib ins Issue, dass du es übernimmst — das erspart doppelte Arbeit.
  3. Forke das Repository und arbeite in einem Branch.
  4. Öffne einen Pull Request, der die Issue-Nummer nennt.

Rechercherichtung

Beginne in lib/internal/test_runner/coverage.js und reproduziere das Problem mit dem bereitgestellten Off-Thread-Hook-Befehl. Untersuche mapRangeToLines, mapCoverageWithSourceMap und mergeCoverage und verifiziere anschließend, dass der Off-Thread-Fall beide Funktionen als abgedeckt meldet, ohne In-Thread-Hooks oder das bestehende Coverage-Verhalten zu beeinträchtigen.

Vom Indexierungsmodell aus dem Issue-Text verfasst.

Bewertung

Tech-Stack
javascript, node.js
Bereich
testing-qa, tooling
Issue-Typ
Bug
Schwierigkeit
4/5
Geschätzter Aufwand
3-5 Tage
Aktivitätsstatus
Aktiv
Klarheit
Größtenteils klar
Anfängerfreundlichkeit
52/100

Neue Issues direkt in Ihr Postfach

Eine kurze Übersicht über anfängerfreundliche GitHub-Issues.