Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
14 changes: 10 additions & 4 deletions doc/node.1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
21 changes: 15 additions & 6 deletions lib/internal/test_runner/coverage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -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(
Expand All @@ -629,15 +638,15 @@ 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.
if (this.#includeMatchers.length > 0) {
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;
}
Expand Down
53 changes: 53 additions & 0 deletions test/parallel/test-runner-coverage-default-exclusion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -98,6 +101,56 @@ 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 dotfile test files from coverage by default', async () => {
const args = [
'--no-experimental-strip-types',
Expand Down