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',