From d6fdfbb3858e478e75ab9e6f26cb0710735b14e0 Mon Sep 17 00:00:00 2001 From: Moshe Atlow Date: Tue, 19 May 2026 16:56:05 +0300 Subject: [PATCH] test_runner: dont buffer unordered events in process isolation mode Signed-off-by: Moshe Atlow --- lib/internal/test_runner/runner.js | 9 +++ .../execution-ordered-bypass/fast-fail.mjs | 6 ++ .../execution-ordered-bypass/slow.mjs | 12 ++++ .../test-runner-execution-ordered-bypass.mjs | 68 +++++++++++++++++++ 4 files changed, 95 insertions(+) create mode 100644 test/fixtures/test-runner/execution-ordered-bypass/fast-fail.mjs create mode 100644 test/fixtures/test-runner/execution-ordered-bypass/slow.mjs create mode 100644 test/parallel/test-runner-execution-ordered-bypass.mjs diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js index d6cb6438d2b52a..e4f8f6f51d642e 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -128,6 +128,11 @@ const kDiagnosticsFilterArgs = ['tests', 'suites', 'pass', 'fail', 'cancelled', const kCanceledTests = new SafeSet() .add(kCancelledByParent).add(kAborted).add(kTestTimeoutFailure); +// Execution-ordered events are forwarded immediately, bypassing the +// per-file declaration-order buffer. +const kExecutionOrderedEvents = new SafeSet() + .add('test:enqueue').add('test:dequeue').add('test:complete'); + let kResistStopPropagation; // Worker ID pool management for concurrent test execution @@ -331,6 +336,10 @@ class FileTest extends Test { } } addToReport(item) { + if (kExecutionOrderedEvents.has(item.type)) { + this.#handleReportItem(item); + return; + } this.#accumulateReportItem(item); if (!this.isClearToSend()) { ArrayPrototypePush(this.#reportBuffer, item); diff --git a/test/fixtures/test-runner/execution-ordered-bypass/fast-fail.mjs b/test/fixtures/test-runner/execution-ordered-bypass/fast-fail.mjs new file mode 100644 index 00000000000000..74b77682b6821d --- /dev/null +++ b/test/fixtures/test-runner/execution-ordered-bypass/fast-fail.mjs @@ -0,0 +1,6 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; + +test('fast-fail', () => { + assert.fail('fast'); +}); diff --git a/test/fixtures/test-runner/execution-ordered-bypass/slow.mjs b/test/fixtures/test-runner/execution-ordered-bypass/slow.mjs new file mode 100644 index 00000000000000..88e6d7339a492e --- /dev/null +++ b/test/fixtures/test-runner/execution-ordered-bypass/slow.mjs @@ -0,0 +1,12 @@ +import { test } from 'node:test'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { existsSync } from 'node:fs'; + +test('slow', async () => { + const goFile = process.env.NODE_TEST_GO_FILE; + for (let i = 0; i < 1200; i++) { + if (existsSync(goFile)) return; + await sleep(50); + } + throw new Error('go signal from host never arrived'); +}); diff --git a/test/parallel/test-runner-execution-ordered-bypass.mjs b/test/parallel/test-runner-execution-ordered-bypass.mjs new file mode 100644 index 00000000000000..f919914c172b4a --- /dev/null +++ b/test/parallel/test-runner-execution-ordered-bypass.mjs @@ -0,0 +1,68 @@ +// Flags: --no-warnings + +import '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import tmpdir from '../common/tmpdir.js'; +import assert from 'node:assert'; +import { writeFileSync } from 'node:fs'; +import { test, run } from 'node:test'; + +const files = [ + fixtures.path('test-runner', 'execution-ordered-bypass', 'slow.mjs'), + fixtures.path('test-runner', 'execution-ordered-bypass', 'fast-fail.mjs'), +]; + +test('execution-ordered events bypass FileTest declaration-order buffer', async () => { + tmpdir.refresh(); + const goFile = tmpdir.resolve('execution-ordered-go'); + + // Force two-way concurrency. `concurrency: true` would resolve to + // `availableParallelism() - 1`, which is 1 on single-core CI runners — and + // with one slot the runner spawns the files sequentially, so fast-fail + // would never start while slow is polling. + const stream = run({ + files, + isolation: 'process', + concurrency: 2, + env: { ...process.env, NODE_TEST_GO_FILE: goFile }, + }); + + const events = []; + + stream.on('test:complete', (data) => { + if (data.name === 'slow' || data.name === 'fast-fail') { + events.push(`complete:${data.name}`); + if (data.name === 'fast-fail') { + writeFileSync(goFile, ''); + } + } + }); + + stream.on('test:fail', (data) => { + if (data.name === 'fast-fail') { + events.push(`fail:${data.name}`); + } + }); + + // eslint-disable-next-line no-unused-vars + for await (const _ of stream); + + const completeFast = events.indexOf('complete:fast-fail'); + const completeSlow = events.indexOf('complete:slow'); + const failFast = events.indexOf('fail:fast-fail'); + + assert.notStrictEqual(completeFast, -1); + assert.notStrictEqual(completeSlow, -1); + assert.notStrictEqual(failFast, -1); + + assert.ok( + completeFast < completeSlow, + `test:complete for fast-fail should arrive before slow; events=${events.join(', ')}`, + ); + + // test:fail is declaration-ordered, so the bypass must not affect it. + assert.ok( + failFast > completeSlow, + `test:fail for fast-fail should arrive after test:complete for slow; events=${events.join(', ')}`, + ); +});