Skip to content

Blitzy: test_runner: bind built-in coverage exclusions to cwd-relative paths (fixes nodejs/node#58654) - #1

Open
blitzy[bot] wants to merge 7 commits into
mainfrom
blitzy-b2b6224e-33b7-4c6f-9925-b4f09649cbc1
Open

Blitzy: test_runner: bind built-in coverage exclusions to cwd-relative paths (fixes nodejs/node#58654)#1
blitzy[bot] wants to merge 7 commits into
mainfrom
blitzy-b2b6224e-33b7-4c6f-9925-b4f09649cbc1

Conversation

@blitzy

@blitzy blitzy Bot commented Jul 31, 2026

Copy link
Copy Markdown

Summary

Fixes a silent path-domain logic error in the test runner's coverage exclusion filter. shouldSkipFileCoverage() matched the framework's own default test-file glob and its node_modules filter against absolute paths. Because that pattern is prefixed with **/, its test/**/* alternative anchored at any depth — so every source file of any project stored below a directory named test (or node_modules) was misclassified as a test file and dropped from the coverage report.

The report still printed, tests still passed, the process still exited 0, and nothing hit stderr. Worse, toPercentage() renders 0/0 as 100, so --test-coverage-lines CI gates passed vacuously — a correctness control defeated by nothing but a directory name. ~/test/, a CI workspace or Unix account named test, and any vendored package all trigger it.

Fixes: nodejs#58654 (open, confirmed-bug)

Root causes

# Defect
RC#1 matchGlobPattern(absolutePath, kDefaultPattern) — a cwd-relative pattern applied to an absolute path
RC#2 StringPrototypeIncludes(url, '/node_modules/') — unscoped substring test on the absolute file URL

Approach

The built-in default now decides solely on the cwd-relative path, discriminated by strict reference identity against the exported kDefaultPattern. User globs keep dual relative+absolute matching — the documented contract (doc/api/cli.md:2642-2643, PR nodejs#53553). The pattern's text is never altered, since the same constant is the test-discovery glob. This is deliberately not PR nodejs#62362's approach, which strips absolute matching from both loops and breaks that contract.

Changes — 5 files, +171/−8

  • lib/internal/test_runner/coverage.js — add sep/isAbsolute; import kDefaultPattern; add kNodeModulesPrefix/kNodeModulesSegment; gate the absolute arm behind excludeGlob !== kDefaultPattern; move the node_modules test onto relativePath. Includes a Windows cross-volume isRelativeToCwd guard (fail-safe).
  • test/parallel/test-runner-coverage-default-exclusion.mjs — 3 regression cases (ancestor test · ancestor node_modules · absolute-user-glob contract guard) + fixture staging.
  • 3 fixtures under test/fixtures/test-runner/coverage-relative-exclusion/.

No changes to deps/, src/, docs, build config, CLI flags, exports or dependencies. The include loop, file: guard and toPercentage are untouched.

Validation

  • 5,341/5,341 autonomous test units pass, 0 failures — 6/6 exclusion cases, 4/4 contract guards, 60/60 test-runner-*, 100/100 snapshots (zero diffs), 5,057/5,057 full JS+native, 179/179 cctest, 5/5 tooltest.
  • Tests proven load-bearing, not inert. Reverting only coverage.js and rebuilding yields tests 6 | pass 3 | fail 3 with the exact empty-table failure mode — the flaw that made the upstream attempt worthless.
  • The gate is repaired. Affected and control now both exit 1 with Error: 63.64% line coverage does not meet threshold of 90%. Pre-fix the affected exited 0.
  • 20+ case matrix 100% correct — no false positives (node_modules_extra/, node_modules.js, ancestor test-utils still reported); existing exclusions preserved.
  • make lint-js EXIT=0.

Requires human action

Upstream PR submission, a real CI matrix (native Windows/UNC especially), review, and semver-patch backports to v24.x/v25.x — ~34h. Project is 67.6% complete.

blitzyai added 7 commits July 30, 2026 23:46
Add a coverage-relative-exclusion fixture directory for exercising the test
runner's coverage exclusion rules against working-directory-relative paths.
It is deliberately placed so that its absolute path contains a directory
named test while none of its own relative paths match the default test file
pattern, which is the shape that a project stored below such a directory
has.

logic-file.js mirrors the logic-file.js of the coverage-default-exclusion
fixture so that it measures identically, reporting 66.67% line, 100.00%
branch and 50.00% function coverage with lines 5-7 uncovered.

node_modules/dependency.js is a dependency that must never show up in a
coverage report. It covers the case of a project's own top level
node_modules directory, whose relative path has no leading separator.

Refs: nodejs#58654
Add the test file of the coverage-relative-exclusion fixture, which
backs the regression coverage for the built-in coverage exclusion
filter being matched against absolute paths instead of working
directory relative ones.

A single coverage run over this fixture exercises all three exclusion
rules at once: logic-file.js is included, this file is excluded by the
default test file pattern through its relative path, and
node_modules/dependency.js is excluded by the node_modules rule
through its relative path. Importing the nested dependency is what
makes one run cover all three.

The fixture deliberately lives outside test/.tmp.*, because
matchGlobPattern does not pass the dot option and so ** cannot
traverse a dot-prefixed segment. A fixture staged below the temporary
directory would be masked, and its regression test would then pass
both before and after the fix.

Refs: nodejs#58654
The built-in default test-file exclusion glob and the built-in
node_modules filter in TestCoverage.prototype.shouldSkipFileCoverage()
were evaluated against absolute paths, so any project stored below a
directory named test or node_modules had its entire coverage report
silently emptied and its --test-coverage-lines/-branches/-functions
gates satisfied vacuously.

Add three cases to the default exclusion suite:

- an ancestor directory named test must not exclude the project. This
  case deliberately runs in place at the fixture directory, which sits
  below the repository's own test/ directory, because the temporary
  directory is dot prefixed (test/.tmp.N) and ** cannot traverse a dot
  prefixed segment without the dot option, which would make the case
  inert.
- an ancestor directory named node_modules must not exclude the project.
- a user supplied absolute --test-coverage-exclude glob must still be
  honoured, guarding the documented contract that user globs match both
  absolute and relative paths.

Stage the shared fixture twice below tmpdir's node_modules so the three
pre-existing cases, which run from tmpdir.path, cannot discover the new
test file: discovery skips node_modules by directory name, so their
asserted report tables stay byte identical.

Refs: nodejs#58654
…paths

TestCoverage.prototype.shouldSkipFileCoverage() made two path decisions
against absolute paths, so a directory name anywhere above the project
could silently empty the whole coverage report.

The exclude loop applied every entry of coverageExcludeGlobs to the
absolute path as well as the working directory relative path. That array
also carries the framework's own default pattern, which was authored to
be resolved relative to the working directory: it is the very same
pattern that test discovery hands to the glob walker with an explicit
cwd. Matched against an absolute path, its 'test/**/*' alternative
combined with the leading '**/' reaches any ancestor directory named
'test', so every source file of a project stored at, say,
/home/test/my-project was classified as a test file and dropped.

The terminal node_modules filter had the same defect in a different
form: it was a substring test on the absolute file: URL, so any project
stored below an ancestor node_modules directory was excluded entirely.

Both decisions now use the working directory relative path. The
built-in default is discriminated by identity against the exported
kDefaultPattern, so user supplied globs keep matching both absolute and
relative paths as documented, and the default pattern's text is left
untouched because test discovery shares the constant. The node_modules
check uses two path.sep derived constants: a prefix for the project's
own top-level dependency directory, whose relative path has no leading
separator, and a segment for nested and out-of-tree ones. This mirrors
discovery, which has always skipped node_modules by directory name.

Every exclusion the default is meant to perform still fires through the
relative arm, and the failure was silent because toPercentage() maps a
0/0 ratio to 100, which also let --test-coverage-lines,
--test-coverage-branches and --test-coverage-functions gates pass
vacuously on an empty report.

Refs: nodejs#58654
The regression case for the working directory relative node_modules
exclusion ran with the staged project itself as the working directory,
so the fixture's own dependency was always relative as
node_modules/dependency.js. That exercised the top level check only: an
implementation which inspected just the start of the relative path
still satisfied the case, while a nested dependency such as
packages/a/node_modules/x.js could leak into the report unnoticed.

Run the case from the ancestor node_modules directory and name the
staged project explicitly instead, so the dependency is relative as
project/node_modules/dependency.js and the interior segment check is
the only thing that can exclude it. The expected report becomes the
grouped tree the renderer produces for that layout, and the assertion
stays a comparison of the whole table.

Refs: nodejs#58654
shouldSkipFileCoverage() assumed that path.relative() always yields a path
relative to the working directory. On Windows it cannot express a file that
lives on another volume that way and returns an absolute path instead, so the
built-in default test-file pattern and the built-in node_modules rule were
still matched against an absolute path for cross-drive and cross-UNC-server
files. An ancestor directory named test or node_modules on that volume then
excluded every measured file again, emptying the report and silently
satisfying the --test-coverage-lines, --test-coverage-branches and
--test-coverage-functions gates.

Only apply the two built-in exclusions when the derived path really is
relative to the working directory. User supplied globs are unchanged and keep
matching absolute paths as documented, so an explicit
--test-coverage-exclude still excludes such a file.

Refs: nodejs#58654
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

--experimental-test-coverage empty since 23.5.0

1 participant