From 1a0896c6e45cd0e444395c0f6ac8f5675619dbef Mon Sep 17 00:00:00 2001 From: NAVEENKUMARKR777 Date: Wed, 9 Sep 2026 15:03:15 +0000 Subject: [PATCH] test_runner: match coverage globs against cwd-relative paths only --test-coverage-exclude and --test-coverage-include (including the default exclude pattern used to drop test files from coverage reports) were matched against both the cwd-relative path and the absolute filesystem path of every candidate file. For a relative-style glob such as the default `**/{test,test/**/*,test-*,*[._-]test}.{js,mjs,cjs}`, matching against the absolute path lets a leading `**` cross into directory segments that have nothing to do with the project: a container `WORKDIR` of `/test`, a home directory literally named `test`, a CI checkout path with a `test` segment, and so on. Any project living under such a path had every one of its files spuriously match the default exclude glob and silently vanish from the coverage report, even though `--experimental-test-coverage` reported 100% coverage of nothing. This was previously papered over in Node's own test suite by an unrelated detail: the absolute-path matcher uses `dot: false`, and this repository's own test tmp dir (test/.tmp.N) has a dot-prefixed segment that happens to block the buggy match from crossing it. That made the bug unobservable through the existing coverage-default- exclusion fixtures despite them running from a directory nested under this repo's own `test/` folder, and meant the dotfile-handling fix in 22e99dc2f1 addressed a related but different problem without touching this one. Fix this by only matching a glob against the absolute path when the glob pattern itself is an absolute path (e.g. an explicit `/abs/path/**` passed to --test-coverage-include). Every relative-style pattern, including all of the built-in default exclude patterns, is now evaluated exclusively against each file's path relative to the current working directory, which is what every default pattern was actually designed to describe. The new regression test is deliberately set up under a fresh directory outside of test/.tmp.N, since that directory's dot-prefixed segment is exactly what prevented the existing fixtures from catching this bug. Fixes: https://github.com/nodejs/node/issues/58654 Signed-off-by: NAVEENKUMARKR777 --- doc/api/cli.md | 14 ++- doc/node.1 | 14 ++- lib/internal/test_runner/coverage.js | 21 +++- ...test-runner-coverage-default-exclusion.mjs | 109 ++++++++++++++++++ 4 files changed, 144 insertions(+), 14 deletions(-) diff --git a/doc/api/cli.md b/doc/api/cli.md index 41f9d6534f81..6df878715e72 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -3046,8 +3046,11 @@ added: > Stability: 1 - Experimental -Excludes specific files from code coverage using a glob pattern, which can match -both absolute and relative file paths. +Excludes specific files from code coverage using a glob pattern. A pattern +that is itself an absolute path (for example, one starting with `/` or a +drive letter) is matched against each file's absolute path; any other +pattern is matched against each file's path relative to the current working +directory. This option may be specified multiple times to exclude multiple glob patterns. @@ -3077,8 +3080,11 @@ added: > Stability: 1 - Experimental -Includes specific files in code coverage using a glob pattern, which can match -both absolute and relative file paths. +Includes specific files in code coverage using a glob pattern. A pattern +that is itself an absolute path (for example, one starting with `/` or a +drive letter) is matched against each file's absolute path; any other +pattern is matched against each file's path relative to the current working +directory. This option may be specified multiple times to include multiple glob patterns. diff --git a/doc/node.1 b/doc/node.1 index 5448bfd9b78e..06f7b9b02112 100644 --- a/doc/node.1 +++ b/doc/node.1 @@ -1496,8 +1496,11 @@ Require a minimum percent of covered branches. If code coverage does not reach the threshold specified, the process will exit with code \fB1\fR. . .It Fl -test-coverage-exclude -Excludes specific files from code coverage using a glob pattern, which can match -both absolute and relative file paths. +Excludes specific files from code coverage using a glob pattern. A pattern +that is itself an absolute path (for example, one starting with \fB/\fR or a +drive letter) is matched against each file's absolute path; any other +pattern is matched against each file's path relative to the current working +directory. This option may be specified multiple times to exclude multiple glob patterns. If both \fB--test-coverage-exclude\fR and \fB--test-coverage-include\fR are provided, files must meet \fBboth\fR criteria to be included in the coverage report. @@ -1509,8 +1512,11 @@ Require a minimum percent of covered functions. If code coverage does not reach the threshold specified, the process will exit with code \fB1\fR. . .It Fl -test-coverage-include -Includes specific files in code coverage using a glob pattern, which can match -both absolute and relative file paths. +Includes specific files in code coverage using a glob pattern. A pattern +that is itself an absolute path (for example, one starting with \fB/\fR or a +drive letter) is matched against each file's absolute path; any other +pattern is matched against each file's path relative to the current working +directory. This option may be specified multiple times to include multiple glob patterns. If both \fB--test-coverage-exclude\fR and \fB--test-coverage-include\fR are provided, files must meet \fBboth\fR criteria to be included in the coverage report. diff --git a/lib/internal/test_runner/coverage.js b/lib/internal/test_runner/coverage.js index 1ce9578a9a0a..e8270e62b862 100644 --- a/lib/internal/test_runner/coverage.js +++ b/lib/internal/test_runner/coverage.js @@ -29,7 +29,7 @@ const { } = require('fs'); const { setupCoverageHooks } = require('internal/util'); const { tmpdir } = require('os'); -const { join, resolve, relative } = require('path'); +const { isAbsolute, join, resolve, relative } = require('path'); const { fileURLToPath, pathToFileURL, URL } = require('internal/url'); const { kMappings, SourceMap } = require('internal/source_map/source_map'); const { @@ -67,10 +67,19 @@ function getStripTypeScriptTypesForCoverage() { } function createCoverageMatcher(pattern) { + // Only match against the absolute file path when the glob pattern is + // itself rooted (e.g. `/abs/path/**`). A relative-style pattern (which + // includes every default exclude pattern, such as `**/test/**/*.js`) must + // never be tested against the absolute path: a leading `**` would then + // also match directory segments *outside* of the project, e.g. an ancestor + // directory happening to be named `test` (`/home/test-user/project/...`, + // a container `WORKDIR` of `/test`, etc), silently excluding the entire + // project from coverage. See: https://github.com/nodejs/node/issues/58654. + const absolutePattern = isAbsolute(pattern) ? createMatcher(pattern) : null; return { __proto__: null, relative: createMatcher(pattern, kMatchGlobPatternOptions), - absolute: createMatcher(pattern), + absolute: absolutePattern, }; } @@ -618,8 +627,8 @@ class TestCoverage { // behavior) dominated the coverage report time, scaling with // files * globs. Each glob compiles to a matcher pair: `relative` enables // dot:true so globs match dotfiles within the project, while `absolute` - // keeps the default behavior to avoid misinterpreting dot segments in the - // absolute filesystem path (e.g. tmp dirs like `test/.tmp.0`). + // is only present for patterns that are themselves absolute (see + // createCoverageMatcher). this.#excludeMatchers ??= ArrayPrototypeMap( this.options.coverageExcludeGlobs ?? [], createCoverageMatcher); this.#includeMatchers ??= ArrayPrototypeMap( @@ -629,7 +638,7 @@ class TestCoverage { for (let i = 0; i < this.#excludeMatchers.length; ++i) { const matcher = this.#excludeMatchers[i]; if (matcher.relative.match(relativePath) || - matcher.absolute.match(absolutePath)) return true; + matcher.absolute?.match(absolutePath)) return true; } // This check filters out files that do not match the include globs. @@ -637,7 +646,7 @@ class TestCoverage { for (let i = 0; i < this.#includeMatchers.length; ++i) { const matcher = this.#includeMatchers[i]; if (matcher.relative.match(relativePath) || - matcher.absolute.match(absolutePath)) return false; + matcher.absolute?.match(absolutePath)) return false; } return true; } diff --git a/test/parallel/test-runner-coverage-default-exclusion.mjs b/test/parallel/test-runner-coverage-default-exclusion.mjs index acc8c3bbfe96..1b2aff891806 100644 --- a/test/parallel/test-runner-coverage-default-exclusion.mjs +++ b/test/parallel/test-runner-coverage-default-exclusion.mjs @@ -2,6 +2,9 @@ import '../common/index.mjs'; import { before, describe, it } from 'node:test'; import assert from 'node:assert'; import { cp } from 'node:fs/promises'; +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir as osTmpdir } from 'node:os'; +import { join } from 'node:path'; import tmpdir from '../common/tmpdir.js'; import fixtures from '../common/fixtures.js'; import { spawnSyncAndAssert } from '../common/child_process.js'; @@ -98,6 +101,112 @@ describe('test runner coverage default exclusion', skipIfNoInspector, () => { }); }); + it('should not exclude files based on ancestor directories named "test"', async () => { + // Regression test for https://github.com/nodejs/node/issues/58654: the + // default coverage exclusion globs must only be evaluated against paths + // relative to the project, never against the absolute filesystem path. + // Otherwise, a project living anywhere underneath a directory that + // happens to be named "test" (a container WORKDIR, a "test" home + // directory, a CI checkout path, etc.) would have every one of its + // files spuriously match `**/test/**/*.js` and silently disappear from + // the coverage report, even though none of those files are actually + // part of the project's own test suite. + // + // This is deliberately set up outside of `tmpdir.path` (which is nested + // under this repository's own `test/` directory as `test/.tmp.N`) + // because the `.` prefix on `.tmp.N` happens to block the buggy + // absolute-path glob match by itself (globs don't cross dotfile/dotdir + // segments unless `dot: true`), which would mask the very bug this test + // exists to catch. + const base = mkdtempSync(join(osTmpdir(), 'node-test-coverage-ancestor-')); + const projectDir = join(base, 'test', 'project'); + mkdirSync(projectDir, { recursive: true }); + + try { + await cp(fixtures.path('test-runner', 'coverage-default-exclusion'), projectDir, { recursive: true }); + + const args = [ + '--no-experimental-strip-types', + '--test', + '--experimental-test-coverage', + '--test-reporter=tap', + ]; + spawnSyncAndAssert(process.execPath, args, { + env: { ...process.env, NODE_TEST_TMPDIR: tmpdir.path }, + cwd: projectDir, + }, { + stderr: '', + stdout(output) { + assertDefaultExclusions(output); + // logic-file.js is not a test file and lives directly in the + // project root, so it must still be reported with its real + // (non-zero, partial) coverage numbers rather than being + // silently excluded because an ancestor directory is named + // "test". + assert.match(output, /# logic-file\.js\s+\|\s*66\.67\s+\|\s*100\.00\s+\|\s*50\.00\s+\|\s*5-7/); + }, + }); + } finally { + rmSync(base, { recursive: true, force: true }); + } + }); + + it('should exclude a file matched by an absolute --test-coverage-exclude pattern', async () => { + // Coverage for the isAbsolute(pattern) branch in createCoverageMatcher: + // a pattern that is itself an absolute path must still be matched + // against each file's absolute path, even though every relative-style + // pattern (including all of the defaults) is now evaluated only + // against the cwd-relative path. Passing --test-coverage-exclude + // replaces the default exclude patterns entirely, so if the absolute + // match didn't work, nothing would be excluded and logic-file.js would + // show up in the report. + const absoluteLogicFilePath = join(tmpdir.path, 'logic-file.js'); + const args = [ + '--no-experimental-strip-types', + '--test', + '--experimental-test-coverage', + `--test-coverage-exclude=${absoluteLogicFilePath}`, + '--test-reporter=tap', + ]; + spawnSyncAndAssert(process.execPath, args, { + env: { ...process.env, NODE_TEST_TMPDIR: tmpdir.path }, + cwd: tmpdir.path, + }, { + stderr: '', + stdout(output) { + assert.match(output, /# start of coverage report/); + assert.doesNotMatch(output, /# logic-file\.js\s+\|/); + assert.match(output, /# file-test\.js\s+\|/); + }, + }); + }); + + it('should include a file matched by an absolute --test-coverage-include pattern', async () => { + // Coverage for the isAbsolute(pattern) branch on the include-glob side: + // an absolute --test-coverage-include pattern must match by absolute + // path. Combined with the (still relative-only) default exclude + // patterns, only logic-file.js should end up in the report. + const absoluteLogicFilePath = join(tmpdir.path, 'logic-file.js'); + const args = [ + '--no-experimental-strip-types', + '--test', + '--experimental-test-coverage', + `--test-coverage-include=${absoluteLogicFilePath}`, + '--test-reporter=tap', + ]; + spawnSyncAndAssert(process.execPath, args, { + env: { ...process.env, NODE_TEST_TMPDIR: tmpdir.path }, + cwd: tmpdir.path, + }, { + stderr: '', + stdout(output) { + assert.match(output, /# logic-file\.js\s+\|/); + assert.doesNotMatch(output, /# file-test\.js\s+\|/); + assert.doesNotMatch(output, /# test\.cjs\s+\|/); + }, + }); + }); + it('should exclude dotfile test files from coverage by default', async () => { const args = [ '--no-experimental-strip-types',