From fb2f2700d2927fd0899f14d5a03ea16efb819d68 Mon Sep 17 00:00:00 2001 From: semimikoh Date: Tue, 30 Jun 2026 11:27:41 +0900 Subject: [PATCH 1/2] test_runner: wait for filtered suite build Signed-off-by: semimikoh --- lib/internal/test_runner/test.js | 5 +++++ .../test-runner/awaited-filtered-suite.mjs | 12 ++++++++++ .../test-runner-no-isolation-filtering.js | 22 +++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 test/fixtures/test-runner/awaited-filtered-suite.mjs diff --git a/lib/internal/test_runner/test.js b/lib/internal/test_runner/test.js index 1b9faad58a68b5..38ce54e4ea9b6f 100644 --- a/lib/internal/test_runner/test.js +++ b/lib/internal/test_runner/test.js @@ -1854,6 +1854,11 @@ class Suite extends Test { return { __proto__: null, ctx, args: [ctx] }; } + async filteredRun() { + await this.buildSuite; + return super.filteredRun(); + } + async run() { this.computeInheritedHooks(); const hookArgs = this.getRunArgs(); diff --git a/test/fixtures/test-runner/awaited-filtered-suite.mjs b/test/fixtures/test-runner/awaited-filtered-suite.mjs new file mode 100644 index 00000000000000..8af8d191d787cf --- /dev/null +++ b/test/fixtures/test-runner/awaited-filtered-suite.mjs @@ -0,0 +1,12 @@ +import test from 'node:test'; + +await test.suite('Suite A', async () => {}); + +await test.suite('Suite B', async () => { + await test('Test 1', async () => {}); + await test('Test 2', async () => {}); +}); + +await test.suite('Suite C', async () => { + await test('Test 3', async () => {}); +}); diff --git a/test/parallel/test-runner-no-isolation-filtering.js b/test/parallel/test-runner-no-isolation-filtering.js index e26130c56b196d..6b9b29bfef1ee2 100644 --- a/test/parallel/test-runner-no-isolation-filtering.js +++ b/test/parallel/test-runner-no-isolation-filtering.js @@ -7,6 +7,8 @@ const { test } = require('node:test'); const fixture1 = fixtures.path('test-runner', 'no-isolation', 'one.test.js'); const fixture2 = fixtures.path('test-runner', 'no-isolation', 'two.test.js'); +const awaitedFilteredSuite = + fixtures.path('test-runner', 'awaited-filtered-suite.mjs'); test('works with --test-only', () => { const args = [ @@ -71,6 +73,26 @@ test('works with --test-name-pattern', () => { assert.match(stdout, /# suites 0/); }); +test('awaited filtered suites do not leave cancelled tests', () => { + const args = [ + '--test', + '--test-reporter=tap', + '--test-isolation=none', + '--test-name-pattern=C', + awaitedFilteredSuite, + ]; + const child = spawnSync(process.execPath, args); + const stdout = child.stdout.toString(); + + assert.strictEqual(child.status, 0); + assert.strictEqual(child.signal, null); + assert.match(stdout, /# tests 2/); + assert.match(stdout, /# suites 1/); + assert.match(stdout, /# pass 1/); + assert.match(stdout, /# fail 0/); + assert.match(stdout, /# cancelled 0/); +}); + test('works with --test-skip-pattern', () => { const args = [ '--test', From a610e7addb9f7ffb7aaf17f50c9f73bfd78593f7 Mon Sep 17 00:00:00 2001 From: semimikoh Date: Mon, 27 Jul 2026 11:20:31 +0900 Subject: [PATCH 2/2] test: avoid top-level await in filtered suite fixture The fixture used top-level `await test.suite()`, which deadlocks under --test-isolation=none independently of this change: runFiles() only calls finishBootstrap() after the import loop, while startSubtestAfterBootstrap() awaits bootstrapPromise, so module evaluation and bootstrap wait on each other. That made the new test time out on every CI platform. Cover the actual regression instead: a nested suite that is filtered out by name while its build is still pending. Nested suites bypass startSubtestAfterBootstrap(), so without the Suite#filteredRun change their late-registered subtest fails with parentAlreadyFinished. Signed-off-by: semimikoh --- .../test-runner/awaited-filtered-suite.mjs | 12 ------------ .../test-runner/filtered-suite-async-build.mjs | 17 +++++++++++++++++ .../test-runner-no-isolation-filtering.js | 13 +++++++------ 3 files changed, 24 insertions(+), 18 deletions(-) delete mode 100644 test/fixtures/test-runner/awaited-filtered-suite.mjs create mode 100644 test/fixtures/test-runner/filtered-suite-async-build.mjs diff --git a/test/fixtures/test-runner/awaited-filtered-suite.mjs b/test/fixtures/test-runner/awaited-filtered-suite.mjs deleted file mode 100644 index 8af8d191d787cf..00000000000000 --- a/test/fixtures/test-runner/awaited-filtered-suite.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import test from 'node:test'; - -await test.suite('Suite A', async () => {}); - -await test.suite('Suite B', async () => { - await test('Test 1', async () => {}); - await test('Test 2', async () => {}); -}); - -await test.suite('Suite C', async () => { - await test('Test 3', async () => {}); -}); diff --git a/test/fixtures/test-runner/filtered-suite-async-build.mjs b/test/fixtures/test-runner/filtered-suite-async-build.mjs new file mode 100644 index 00000000000000..0e96ff889fb828 --- /dev/null +++ b/test/fixtures/test-runner/filtered-suite-async-build.mjs @@ -0,0 +1,17 @@ +import test from 'node:test'; +import { setTimeout as delay } from 'node:timers/promises'; + +test.suite('Outer', async () => { + await delay(1); + + // This suite is filtered out by name. Its build is still pending when the + // filtered run starts, so the subtest below is registered late. + test.suite('Nested A', async () => { + await delay(1); + test('Nested A test', async () => {}); + }); + + test.suite('Nested C', async () => { + test('Nested C test', async () => {}); + }); +}); diff --git a/test/parallel/test-runner-no-isolation-filtering.js b/test/parallel/test-runner-no-isolation-filtering.js index 6b9b29bfef1ee2..f7b0f3f73ff0c1 100644 --- a/test/parallel/test-runner-no-isolation-filtering.js +++ b/test/parallel/test-runner-no-isolation-filtering.js @@ -7,8 +7,8 @@ const { test } = require('node:test'); const fixture1 = fixtures.path('test-runner', 'no-isolation', 'one.test.js'); const fixture2 = fixtures.path('test-runner', 'no-isolation', 'two.test.js'); -const awaitedFilteredSuite = - fixtures.path('test-runner', 'awaited-filtered-suite.mjs'); +const asyncBuildFilteredSuite = + fixtures.path('test-runner', 'filtered-suite-async-build.mjs'); test('works with --test-only', () => { const args = [ @@ -73,24 +73,25 @@ test('works with --test-name-pattern', () => { assert.match(stdout, /# suites 0/); }); -test('awaited filtered suites do not leave cancelled tests', () => { +test('filtered suites with an async build do not leave cancelled tests', () => { const args = [ '--test', '--test-reporter=tap', '--test-isolation=none', '--test-name-pattern=C', - awaitedFilteredSuite, + asyncBuildFilteredSuite, ]; const child = spawnSync(process.execPath, args); const stdout = child.stdout.toString(); assert.strictEqual(child.status, 0); assert.strictEqual(child.signal, null); - assert.match(stdout, /# tests 2/); - assert.match(stdout, /# suites 1/); + assert.match(stdout, /# tests 1/); + assert.match(stdout, /# suites 2/); assert.match(stdout, /# pass 1/); assert.match(stdout, /# fail 0/); assert.match(stdout, /# cancelled 0/); + assert.doesNotMatch(stdout, /parentAlreadyFinished/); }); test('works with --test-skip-pattern', () => {