From 44f6a3d7b079e1d90fd059f87d4ecdbe3f6503a5 Mon Sep 17 00:00:00 2001 From: sangwook Date: Sun, 3 May 2026 21:57:51 +0900 Subject: [PATCH 1/2] test_runner: cache shouldSkipFileCoverage result per URL `shouldSkipFileCoverage(url)` is invoked twice for every covered script (once during source-map mapping and once during merge), and the same script URL is typically reported by every worker. The result depends only on `options.cwd`, `coverageExcludeGlobs`, and `coverageIncludeGlobs`, all of which are fixed for the lifetime of a TestCoverage instance, so the URL -> boolean mapping is deterministic and safe to cache. Add a private `#skipCache` SafeMap and split the method into a thin caching wrapper plus a private `#computeShouldSkipFileCoverage` that holds the original logic. Callers are unchanged. This eliminates redundant glob and URL parsing work proportional to the number of workers x scripts in the coverage report. Refs: https://github.com/nodejs/node/issues/55103 Signed-off-by: sangwook --- lib/internal/test_runner/coverage.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/internal/test_runner/coverage.js b/lib/internal/test_runner/coverage.js index 37ff2473b68011..755f54b30df70a 100644 --- a/lib/internal/test_runner/coverage.js +++ b/lib/internal/test_runner/coverage.js @@ -85,6 +85,7 @@ class TestCoverage { #sourceLines = new SafeMap(); #typeScriptLines = new SafeSet(); + #skipCache = new SafeMap(); getLines(fileUrl, source) { // Split the file source into lines. Make sure the lines maintain their @@ -534,6 +535,14 @@ class TestCoverage { } shouldSkipFileCoverage(url) { + const cached = this.#skipCache.get(url); + if (cached !== undefined) return cached; + const result = this.#computeShouldSkipFileCoverage(url); + this.#skipCache.set(url, result); + return result; + } + + #computeShouldSkipFileCoverage(url) { // This check filters out core modules, which start with 'node:' in // coverage reports, as well as any invalid coverages which have been // observed on Windows. From 226a920f490d922df085db1bed92de9a3a3f03a1 Mon Sep 17 00:00:00 2001 From: sangwook Date: Sun, 31 May 2026 16:30:27 +0900 Subject: [PATCH 2/2] test_runner: avoid recompiling coverage globs for every file shouldSkipFileCoverage() called matchGlobPattern() for each covered file, and matchGlobPattern() builds a fresh Minimatch (a full glob parse and regexp compile) on every call. The coverage exclude/include globs never change during a run, so the same patterns were recompiled once per file, which dominated the coverage report time. Compile coverageExcludeGlobs/coverageIncludeGlobs to matchers once per TestCoverage instance and reuse them for every file. Expose createMatcher() from internal/fs/glob for this. On a synthetic 200-test-file project this drops shouldSkipFileCoverage from ~117ms to ~11ms (cpu-prof, isolation=none); the saving scales with files * globs. Refs: https://github.com/nodejs/node/issues/55103 Signed-off-by: sangwook --- lib/internal/fs/glob.js | 1 + lib/internal/test_runner/coverage.js | 37 ++++++++++++++-------------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/lib/internal/fs/glob.js b/lib/internal/fs/glob.js index c5bbdb9813c0d1..6cda2c9e6da073 100644 --- a/lib/internal/fs/glob.js +++ b/lib/internal/fs/glob.js @@ -947,5 +947,6 @@ function matchGlobPattern(path, pattern, windows = isWindows) { module.exports = { __proto__: null, Glob, + createMatcher, matchGlobPattern, }; diff --git a/lib/internal/test_runner/coverage.js b/lib/internal/test_runner/coverage.js index 755f54b30df70a..798e41f0ac4be6 100644 --- a/lib/internal/test_runner/coverage.js +++ b/lib/internal/test_runner/coverage.js @@ -37,7 +37,7 @@ const { ERR_SOURCE_MAP_MISSING_SOURCE, }, } = require('internal/errors'); -const { matchGlobPattern } = require('internal/fs/glob'); +const { createMatcher } = require('internal/fs/glob'); const { constants: { kMockSearchParam } } = require('internal/test_runner/mock/loader'); const kCoverageFileRegex = /^coverage-(\d+)-(\d{13})-(\d+)\.json$/; @@ -86,6 +86,8 @@ class TestCoverage { #sourceLines = new SafeMap(); #typeScriptLines = new SafeSet(); #skipCache = new SafeMap(); + #excludeMatchers = null; + #includeMatchers = null; getLines(fileUrl, source) { // Split the file source into lines. Make sure the lines maintain their @@ -550,28 +552,27 @@ class TestCoverage { const absolutePath = fileURLToPath(url); const relativePath = relative(this.options.cwd, absolutePath); - const { - coverageExcludeGlobs: excludeGlobs, - coverageIncludeGlobs: includeGlobs, - } = this.options; + // The exclude/include globs are fixed for the lifetime of this + // TestCoverage instance, so compile each glob to a matcher once and reuse + // it for every file. Building a fresh Minimatch per call (the previous + // behavior) dominated the coverage report time, scaling with + // files * globs. + this.#excludeMatchers ??= ArrayPrototypeMap( + this.options.coverageExcludeGlobs ?? [], (pattern) => createMatcher(pattern)); + this.#includeMatchers ??= ArrayPrototypeMap( + this.options.coverageIncludeGlobs ?? [], (pattern) => createMatcher(pattern)); // This check filters out files that match the exclude globs. - if (excludeGlobs?.length > 0) { - for (let i = 0; i < excludeGlobs.length; ++i) { - if ( - matchGlobPattern(relativePath, excludeGlobs[i]) || - matchGlobPattern(absolutePath, excludeGlobs[i]) - ) return true; - } + for (let i = 0; i < this.#excludeMatchers.length; ++i) { + const matcher = this.#excludeMatchers[i]; + if (matcher.match(relativePath) || matcher.match(absolutePath)) return true; } // This check filters out files that do not match the include globs. - if (includeGlobs?.length > 0) { - for (let i = 0; i < includeGlobs.length; ++i) { - if ( - matchGlobPattern(relativePath, includeGlobs[i]) || - matchGlobPattern(absolutePath, includeGlobs[i]) - ) return false; + if (this.#includeMatchers.length > 0) { + for (let i = 0; i < this.#includeMatchers.length; ++i) { + const matcher = this.#includeMatchers[i]; + if (matcher.match(relativePath) || matcher.match(absolutePath)) return false; } return true; }