test_runner: match dotfiles in default coverage exclude
Project: nodejs/node | Language: JavaScript | Concepts: testing-strategy, refactoring | Difficulty: mid
Link: https://github.com/nodejs/node/pull/63401
The problem
When running node --experimental-test-coverage, test files with names starting with a dot (dotfiles, e.g. test/.foo.cjs) were incorrectly included in the coverage report, even though the default exclude patterns (such as test/**/*) were supposed to exclude all test files. Non-dotfile test files were excluded as intended.
Why it's non-trivial
A naive fix might simply add more patterns or tweak the glob, but the underlying cause is that the glob-matching library (minimatch) does not match dotfiles unless the dot: true option is set. Changing this globally could have unintended consequences, such as accidentally excluding files in dot-prefixed directories elsewhere in the filesystem (e.g., temp directories), so care must be taken to apply the option only where appropriate.
The approach
The PR changes how coverage exclusion globs are matched so that dotfiles are correctly excluded, but only in the intended context.
1. Add a constant with the correct minimatch option:
+// Match dotfiles (e.g. `test/.foo.js`) when applying coverage globs so the
+// default exclude patterns cover them.
+const kMatchGlobPatternOptions = { __proto__: null, dot: true };
This constant ensures that dotfiles are included in glob matches when desired.
2. Add a helper to create matchers with and without the dot option:
+function createCoverageMatcher(pattern) {
+ return {
+ __proto__: null,
+ relative: createMatcher(pattern, kMatchGlobPatternOptions),
+ absolute: createMatcher(pattern),
+ };
+}
This helper returns an object with two matchers:
relativeusesdot: trueso patterns liketest/**/*match dotfiles in the project.absoluteomitsdot: trueto avoid matching dot-prefixed directories in absolute paths (like temp directories).
3. Update matcher usage in the coverage logic:
- this.#excludeMatchers ??= ArrayPrototypeMap(
- this.options.coverageExcludeGlobs ?? [], (pattern) => createMatcher(pattern));
- this.#includeMatchers ??= ArrayPrototypeMap(
- this.options.coverageIncludeGlobs ?? [], (pattern) => createMatcher(pattern));
+ this.#excludeMatchers ??= ArrayPrototypeMap(
+ this.options.coverageExcludeGlobs ?? [], createCoverageMatcher);
+ this.#includeMatchers ??= ArrayPrototypeMap(
+ this.options.coverageIncludeGlobs ?? [], createCoverageMatcher);
Now, exclude/include globs are compiled into the new matcher objects.
4. Use both matchers when checking for exclusions:
- if (matcher.match(relativePath) || matcher.match(absolutePath)) return true;
+ if (matcher.relative.match(relativePath) ||
+ matcher.absolute.match(absolutePath)) return true;
Both the relative (with dot: true) and absolute (default) matchers are checked. This ensures that dotfiles are excluded when matched relative to the project, but not over-excluded due to dot directories in absolute paths.
5. Add a test for dotfile exclusion:
+ it('should exclude dotfile test files from coverage by default', async () => {
+ const args = [
+ '--no-experimental-strip-types',
+ '--test',
+ '--experimental-test-coverage',
+ '--test-reporter=tap',
+ 'test/.dotfile.cjs',
+ ];
+ const result = spawnSync(process.execPath, args, {
+ env: { ...process.env, NODE_TEST_TMPDIR: tmpdir.path },
+ cwd: tmpdir.path
+ });
+
+ assert.strictEqual(result.stderr.toString(), '');
+ assertDefaultExclusions(result.stdout.toString());
+ assert.doesNotMatch(result.stdout.toString(), /#\s+\.dotfile\.cjs\s+\|/);
+ assert.strictEqual(result.status, 0);
+ });
This test verifies that a dotfile test is not included in the coverage report.
From the review thread
avivkeller: Can't we just set
dot: true?
semimikoh: Good catch — reverted to the
dot: trueapproach (which the PR body originally described). Pushed as a follow-up fixup. This also makes user-supplied--test-coverage-excludeglobs match dotfiles, which the extra default pattern wouldn't have covered.
avivkeller: Can't we pass the same options and thus and use the same matcher for both or no?
semimikoh: Intentional —
relativeneedsdot: trueso the default exclude globs match project dotfiles liketest/.foo.test.js(the point of this PR), butabsolutedeliberately leaves it off. Enablingdot: truethere could make a glob unintentionally match a dot segment that shows up in the absolute path for unrelated reasons (e.g. an OS temp dir liketest/.tmp.0), which isn't something the user's pattern was written to target.
Takeaways
- Understand library defaults and edge cases: Always check how third-party libraries (like minimatch) handle special cases (e.g., dotfiles), and set options explicitly to match your intent.
- Be careful with global option changes: Applying an option like
dot: trueeverywhere can have unintended side effects, especially when matching both relative and absolute paths. Use context-aware logic to avoid over-matching. - Test the specific edge case: When fixing a subtle bug, add a targeted test to ensure the fix works as expected and to prevent regressions.