From 216b8cc8b1682c508e5762ae9d8c1881cebefe61 Mon Sep 17 00:00:00 2001 From: atlowChemi Date: Sun, 9 Aug 2026 21:07:44 +0300 Subject: [PATCH] test_runner: do not tag-filter test file wrappers Under run({ testTagFilters, isolation: 'process' }) the parent process's FileTest wrappers have empty tag sets, so any include filter filtered out the wrappers themselves and no test file was ever spawned. The same applied to the single re-spawned child in watch mode with isolation 'none'. Exempt file wrappers from tag filtering: the filter is re-emitted to the child process and applied there, matching isolation 'none' results. This also removes the testTagFilterExpressions bookkeeping and the isolation-conditional assignment of testTagFilters, both of which existed only to keep the parent process from filtering its own file wrappers. The parent now always holds the canonical filter values and re-emits them to child processes. Refs: https://github.com/nodejs/node/pull/63221 Signed-off-by: atlowChemi --- lib/internal/test_runner/runner.js | 18 +++++++++++------- lib/internal/test_runner/test.js | 6 +++++- lib/internal/test_runner/utils.js | 18 +++++------------- test/parallel/test-runner-tags-events.mjs | 14 +++++++++++--- 4 files changed, 32 insertions(+), 24 deletions(-) diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js index a5a53e44d29a..548ed006e152 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -182,7 +182,7 @@ function getRunArgs(path, { forceExit, inspectPort, testNamePatterns, testSkipPatterns, - testTagFilterExpressions, + testTagFilters, only, hasFiles, testFiles, @@ -224,8 +224,8 @@ function getRunArgs(path, { forceExit, if (testSkipPatterns != null) { ArrayPrototypeForEach(testSkipPatterns, (pattern) => ArrayPrototypePush(runArgs, `--test-skip-pattern=${pattern}`)); } - if (testTagFilterExpressions != null) { - ArrayPrototypeForEach(testTagFilterExpressions, (value) => ArrayPrototypePush(runArgs, `--experimental-test-tag-filter=${value}`)); + if (testTagFilters != null) { + ArrayPrototypeForEach(testTagFilters, (value) => ArrayPrototypePush(runArgs, `--experimental-test-tag-filter=${value}`)); } if (only === true) { ArrayPrototypePush(runArgs, '--test-only'); @@ -284,6 +284,14 @@ class FileTest extends Test { this.timeout = null; } + willBeFilteredByTags() { + // File wrappers have no tags of their own. Tag filtering applies to the + // tests inside the file, which run in a child process (or in-process + // import); filtering the wrapper would prevent the file from running at + // all. + return false; + } + #skipReporting() { return this.#reportedChildren > 0 && (!this.error || this.error.failureType === kSubtestsFailed); } @@ -864,7 +872,6 @@ function run(options = kEmptyObject) { }); } - let testTagFilterExpressions = null; if (testTagFilters != null) { if (!ArrayIsArray(testTagFilters)) { testTagFilters = [testTagFilters]; @@ -876,10 +883,8 @@ function run(options = kEmptyObject) { testTagFilters = ArrayPrototypeMap(testTagFilters, (value, i) => ( validateAndCanonicalizeTagFilter(value, `options.testTagFilters[${i}]`) )); - testTagFilterExpressions = testTagFilters; } } - testTagFilterExpressions ??= options.testTagFilterExpressions; validateOneOf(isolation, 'options.isolation', ['process', 'none']); validateBoolean(coverage, 'options.coverage'); @@ -982,7 +987,6 @@ function run(options = kEmptyObject) { testNamePatterns, testSkipPatterns, testTagFilters, - testTagFilterExpressions, hasFiles: files != null, globPatterns, only, diff --git a/lib/internal/test_runner/test.js b/lib/internal/test_runner/test.js index 38ce54e4ea9b..a728378182df 100644 --- a/lib/internal/test_runner/test.js +++ b/lib/internal/test_runner/test.js @@ -656,7 +656,7 @@ class Test extends AsyncResource { } if (isFilteringByTags) { - this.filteredByTag = !evaluateTagFilters(config.testTagFilters, this.tagSet); + this.filteredByTag = this.willBeFilteredByTags(); if (!this.filteredByTag) { for (let t = this.parent; t !== null && t.filteredByTag; t = t.parent) { t.filteredByTag = false; @@ -894,6 +894,10 @@ class Test extends AsyncResource { return false; } + willBeFilteredByTags() { + return !evaluateTagFilters(this.config.testTagFilters, this.tagSet); + } + /** * Returns a name of the test prefixed by name of all its ancestors in ascending order, separated by a space * Ex."grandparent parent test" diff --git a/lib/internal/test_runner/utils.js b/lib/internal/test_runner/utils.js index 3590d3ef79b4..982fc6ef7bfe 100644 --- a/lib/internal/test_runner/utils.js +++ b/lib/internal/test_runner/utils.js @@ -273,7 +273,6 @@ function parseCommandLine() { let testNamePatterns = mapPatternFlagToRegExArray('--test-name-pattern'); let testSkipPatterns = mapPatternFlagToRegExArray('--test-skip-pattern'); let testTagFilters = null; - let testTagFilterExpressions = null; if (isChildProcessV8) { kBuiltinReporters.set('v8-serializer', 'internal/test_runner/reporter/v8-serializer'); @@ -309,19 +308,14 @@ function parseCommandLine() { const tagFilterFlag = getOptionValue('--experimental-test-tag-filter'); if (tagFilterFlag?.length > 0) { emitExperimentalWarning('Test tags'); - testTagFilterExpressions = tagFilterFlag; - // Validate at parent startup so a malformed flag fails fast, - // independent of isolation mode. Under isolation='process' the - // validated strings go unused at the parent (children re-validate - // and apply the filter); the validation here only surfaces input - // errors early. - const validated = ArrayPrototypeMap( + // File wrappers are exempt from tag filtering, so holding the filters + // in the parent is safe under any isolation mode; under + // isolation='process' the canonical values are re-emitted to the + // child processes, which apply the filter themselves. + testTagFilters = ArrayPrototypeMap( tagFilterFlag, (value, i) => validateAndCanonicalizeTagFilter(value, `--experimental-test-tag-filter[${i}]`), ); - if (isolation === 'none') { - testTagFilters = validated; - } } if (isolation === 'none') { @@ -365,7 +359,6 @@ function parseCommandLine() { const tagFilterFlag = getOptionValue('--experimental-test-tag-filter'); if (tagFilterFlag?.length > 0) { emitExperimentalWarning('Test tags'); - testTagFilterExpressions = tagFilterFlag; testTagFilters = ArrayPrototypeMap( tagFilterFlag, (value, i) => validateAndCanonicalizeTagFilter(value, `--experimental-test-tag-filter[${i}]`), @@ -433,7 +426,6 @@ function parseCommandLine() { sourceMaps, testNamePatterns, testSkipPatterns, - testTagFilterExpressions, testTagFilters, timeout, updateSnapshots, diff --git a/test/parallel/test-runner-tags-events.mjs b/test/parallel/test-runner-tags-events.mjs index 2f275cf876f9..7d1b35f7349d 100644 --- a/test/parallel/test-runner-tags-events.mjs +++ b/test/parallel/test-runner-tags-events.mjs @@ -83,9 +83,6 @@ describe('tag-bearing event payloads', { concurrency: false }, () => { }); it('test:pass fires only for selected tagged tests when filtered', async () => { - // isolation='none' so the parent applies the filter directly. Under - // 'process', the FileTest wrapper (which has no tags) would itself be - // filtered out by the include filter - same wart as --test-name-pattern. const stream = run({ files: [fixture], testTagFilters: ['db'], isolation: 'none' }); stream.on('test:fail', common.mustNotCall()); // 3 db-tagged tests pass + the db suite itself. @@ -93,4 +90,15 @@ describe('tag-bearing event payloads', { concurrency: false }, () => { // eslint-disable-next-line no-unused-vars for await (const _ of stream); }); + + it('filtering under process isolation runs the file and filters inside it', async () => { + // The FileTest wrapper has no tags and must not be filtered out itself; + // the filter is re-emitted to the child process and applied there. + const stream = run({ files: [fixture], testTagFilters: ['db'], isolation: 'process' }); + stream.on('test:fail', common.mustNotCall()); + // 3 db-tagged tests pass + the db suite itself. + stream.on('test:pass', common.mustCall(4)); + // eslint-disable-next-line no-unused-vars + for await (const _ of stream); + }); });