From 86a4589269299dcd06279625ad8732aafc5dfd68 Mon Sep 17 00:00:00 2001 From: Moshe Atlow Date: Sun, 5 Jul 2026 17:57:56 +0300 Subject: [PATCH] test_runner: report `entryFile` in `TestStream` events Signed-off-by: Moshe Atlov --- doc/api/test.md | 42 +++++++++++++ lib/internal/test_runner/runner.js | 4 ++ .../test-runner/entry-file/a.test.mjs | 3 + .../test-runner/entry-file/b.test.mjs | 3 + .../test-runner/entry-file/helper.mjs | 3 + test/parallel/test-runner-entry-file.mjs | 59 +++++++++++++++++++ test/parallel/test-runner-v8-deserializer.mjs | 28 +++++---- 7 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 test/fixtures/test-runner/entry-file/a.test.mjs create mode 100644 test/fixtures/test-runner/entry-file/b.test.mjs create mode 100644 test/fixtures/test-runner/entry-file/helper.mjs create mode 100644 test/parallel/test-runner-entry-file.mjs diff --git a/doc/api/test.md b/doc/api/test.md index 504dcf9a99336c..3b1ea94eff9781 100644 --- a/doc/api/test.md +++ b/doc/api/test.md @@ -3440,6 +3440,10 @@ added: - v18.9.0 - v16.19.0 changes: + - version: REPLACEME + pr-url: https://github.com/nodejs/node/pull/64309 + description: Added `entryFile` to events forwarded from child processes + when tests run with process isolation. - version: v26.3.0 pr-url: https://github.com/nodejs/node/pull/63435 description: Added `parentId` to test events that carry a `testId`. @@ -3524,6 +3528,10 @@ Emitted when code coverage is enabled and all tests have completed. * `cause` {Error} The actual error thrown by the test. * `type` {string|undefined} The type of the test, used to denote whether this is a suite. + * `entryFile` {string|undefined} The path of the test file that was + executed as the entry point of the child process that emitted this event. + Only present when tests run with process isolation. May differ from + `file` when the test is defined in a module imported by the entry file. * `file` {string|undefined} The path of the test file, `undefined` if test was run through the REPL. * `line` {number|undefined} The line number where the test is defined, or @@ -3553,6 +3561,10 @@ The corresponding declaration ordered events are `'test:pass'` and `'test:fail'` * `data` {Object} * `column` {number|undefined} The column number where the test is defined, or `undefined` if the test was run through the REPL. + * `entryFile` {string|undefined} The path of the test file that was + executed as the entry point of the child process that emitted this event. + Only present when tests run with process isolation. May differ from + `file` when the test is defined in a module imported by the entry file. * `file` {string|undefined} The path of the test file, `undefined` if test was run through the REPL. * `line` {number|undefined} The line number where the test is defined, or @@ -3579,6 +3591,10 @@ defined. The corresponding declaration ordered event is `'test:start'`. * `data` {Object} * `column` {number|undefined} The column number where the test is defined, or `undefined` if the test was run through the REPL. + * `entryFile` {string|undefined} The path of the test file that was + executed as the entry point of the child process that emitted this event. + Only present when tests run with process isolation. May differ from + `file` when the test is defined in a module imported by the entry file. * `file` {string|undefined} The path of the test file, `undefined` if test was run through the REPL. * `line` {number|undefined} The line number where the test is defined, or @@ -3600,6 +3616,10 @@ defined. * `data` {Object} * `column` {number|undefined} The column number where the test is defined, or `undefined` if the test was run through the REPL. + * `entryFile` {string|undefined} The path of the test file that was + executed as the entry point of the child process that emitted this event. + Only present when tests run with process isolation. May differ from + `file` when the test is defined in a module imported by the entry file. * `file` {string|undefined} The path of the test file, `undefined` if test was run through the REPL. * `line` {number|undefined} The line number where the test is defined, or @@ -3632,6 +3652,10 @@ Emitted when a test is enqueued for execution. this is a suite. * `attempt` {number|undefined} The attempt number of the test run, present only when using the [`--test-rerun-failures`][] flag. + * `entryFile` {string|undefined} The path of the test file that was + executed as the entry point of the child process that emitted this event. + Only present when tests run with process isolation. May differ from + `file` when the test is defined in a module imported by the entry file. * `file` {string|undefined} The path of the test file, `undefined` if test was run through the REPL. * `line` {number|undefined} The line number where the test is defined, or @@ -3697,6 +3721,10 @@ since the parent runner only knows about file-level tests. When using present only when using the [`--test-rerun-failures`][] flag. * `passed_on_attempt` {number|undefined} The attempt number the test passed on, present only when using the [`--test-rerun-failures`][] flag. + * `entryFile` {string|undefined} The path of the test file that was + executed as the entry point of the child process that emitted this event. + Only present when tests run with process isolation. May differ from + `file` when the test is defined in a module imported by the entry file. * `file` {string|undefined} The path of the test file, `undefined` if test was run through the REPL. * `line` {number|undefined} The line number where the test is defined, or @@ -3726,6 +3754,10 @@ The corresponding execution ordered event is `'test:complete'`. * `data` {Object} * `column` {number|undefined} The column number where the test is defined, or `undefined` if the test was run through the REPL. + * `entryFile` {string|undefined} The path of the test file that was + executed as the entry point of the child process that emitted this event. + Only present when tests run with process isolation. May differ from + `file` when the test is defined in a module imported by the entry file. * `file` {string|undefined} The path of the test file, `undefined` if test was run through the REPL. * `line` {number|undefined} The line number where the test is defined, or @@ -3742,6 +3774,10 @@ defined. * `data` {Object} * `column` {number|undefined} The column number where the test is defined, or `undefined` if the test was run through the REPL. + * `entryFile` {string|undefined} The path of the test file that was + executed as the entry point of the child process that emitted this event. + Only present when tests run with process isolation. May differ from + `file` when the test is defined in a module imported by the entry file. * `file` {string|undefined} The path of the test file, `undefined` if test was run through the REPL. * `line` {number|undefined} The line number where the test is defined, or @@ -3766,6 +3802,9 @@ The corresponding execution ordered event is `'test:dequeue'`. ### Event: `'test:stderr'` * `data` {Object} + * `entryFile` {string|undefined} The path of the test file that was + executed as the entry point of the child process that emitted this event. + Only present when tests run with process isolation. * `file` {string} The path of the test file. * `message` {string} The message written to `stderr`. @@ -3777,6 +3816,9 @@ defined. ### Event: `'test:stdout'` * `data` {Object} + * `entryFile` {string|undefined} The path of the test file that was + executed as the entry point of the child process that emitted this event. + Only present when tests run with process isolation. * `file` {string} The path of the test file. * `message` {string} The message written to `stdout`. diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js index a4441ea31a9dc9..7f50cc8521bcda 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -295,6 +295,10 @@ class FileTest extends Test { ArrayPrototypeIncludes(kDiagnosticsFilterArgs, StringPrototypeSlice(comment, 0, firstSpaceIndex)); } #handleReportItem(item) { + // The name is empty when a single child process runs all test files. + if (this.name !== '') { + item.data.entryFile = this.loc.file; + } const isTopLevel = item.data.nesting === 0; if (isTopLevel) { if (item.type === 'test:plan' && this.#skipReporting()) { diff --git a/test/fixtures/test-runner/entry-file/a.test.mjs b/test/fixtures/test-runner/entry-file/a.test.mjs new file mode 100644 index 00000000000000..3f41c33976751c --- /dev/null +++ b/test/fixtures/test-runner/entry-file/a.test.mjs @@ -0,0 +1,3 @@ +import { test } from 'node:test'; +import { runShared } from './helper.mjs'; +test('backup A', async (t) => { await runShared(t, 'A'); }); diff --git a/test/fixtures/test-runner/entry-file/b.test.mjs b/test/fixtures/test-runner/entry-file/b.test.mjs new file mode 100644 index 00000000000000..f555987816ebb5 --- /dev/null +++ b/test/fixtures/test-runner/entry-file/b.test.mjs @@ -0,0 +1,3 @@ +import { test } from 'node:test'; +import { runShared } from './helper.mjs'; +test('backup B', async (t) => { await runShared(t, 'B'); }); diff --git a/test/fixtures/test-runner/entry-file/helper.mjs b/test/fixtures/test-runner/entry-file/helper.mjs new file mode 100644 index 00000000000000..25fe93ca074598 --- /dev/null +++ b/test/fixtures/test-runner/entry-file/helper.mjs @@ -0,0 +1,3 @@ +export async function runShared(t, target) { + await t.test(`restore ${target}`, async () => {}); +} diff --git a/test/parallel/test-runner-entry-file.mjs b/test/parallel/test-runner-entry-file.mjs new file mode 100644 index 00000000000000..94750e6a745e50 --- /dev/null +++ b/test/parallel/test-runner-entry-file.mjs @@ -0,0 +1,59 @@ +import '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import { describe, it, run } from 'node:test'; +import assert from 'node:assert'; + +const aPath = fixtures.path('test-runner', 'entry-file', 'a.test.mjs'); +const bPath = fixtures.path('test-runner', 'entry-file', 'b.test.mjs'); +const helperPath = fixtures.path('test-runner', 'entry-file', 'helper.mjs'); + +async function collectEvents(options) { + const events = []; + const stream = run({ files: [aPath, bPath], ...options }); + stream.on('test:fail', () => {}); + for await (const event of stream) { + events.push(event); + } + return events; +} + +describe('entryFile attribution in reporter events', { concurrency: false }, () => { + it('stamps entryFile on events forwarded from child processes', async () => { + const events = await collectEvents({ isolation: 'process' }); + const checked = { __proto__: null, A: 0, B: 0 }; + + for (const { type, data } of events) { + if (data?.name === 'restore A' || data?.name === 'restore B') { + const target = data.name === 'restore A' ? 'A' : 'B'; + const expectedEntry = target === 'A' ? aPath : bPath; + assert.strictEqual(data.file, helperPath, + `${type} file should be the definition site`); + assert.strictEqual(data.entryFile, expectedEntry, + `${type} entryFile should be the entry file`); + checked[target]++; + } + } + + // Each subtest emits at least enqueue/dequeue/start/pass/complete. + assert.ok(checked.A >= 4, `expected events for restore A, got ${checked.A}`); + assert.ok(checked.B >= 4, `expected events for restore B, got ${checked.B}`); + }); + + it('stamps entryFile on top-level tests forwarded from child processes', async () => { + const events = await collectEvents({ isolation: 'process' }); + const pass = events.filter(({ type }) => type === 'test:pass'); + const backupA = pass.find(({ data }) => data.name === 'backup A'); + const backupB = pass.find(({ data }) => data.name === 'backup B'); + assert.strictEqual(backupA.data.entryFile, aPath); + assert.strictEqual(backupB.data.entryFile, bPath); + }); + + it('does not stamp entryFile with isolation none', async () => { + const events = await collectEvents({ isolation: 'none' }); + for (const { data } of events) { + if (data?.name === 'restore A' || data?.name === 'restore B') { + assert.strictEqual(data.entryFile, undefined); + } + } + }); +}); diff --git a/test/parallel/test-runner-v8-deserializer.mjs b/test/parallel/test-runner-v8-deserializer.mjs index 5e50df441da59e..6959b93fb5918c 100644 --- a/test/parallel/test-runner-v8-deserializer.mjs +++ b/test/parallel/test-runner-v8-deserializer.mjs @@ -5,6 +5,7 @@ import { describe, it, beforeEach } from 'node:test'; import assert from 'node:assert'; import { finished } from 'node:stream/promises'; import { DefaultSerializer } from 'node:v8'; +import { resolve } from 'node:path'; import serializer from 'internal/test_runner/reporter/v8-serializer'; import runner from 'internal/test_runner/runner'; @@ -14,10 +15,15 @@ async function toArray(chunks) { return arr; } +const entryFile = resolve('filetest'); const diagnosticEvent = { type: 'test:diagnostic', data: { nesting: 0, details: {}, message: 'diagnostic' }, }; +const reportedDiagnosticEvent = { + type: 'test:diagnostic', + data: { ...diagnosticEvent.data, entryFile }, +}; const chunks = await toArray(serializer([diagnosticEvent])); const defaultSerializer = new DefaultSerializer(); defaultSerializer.writeHeader(); @@ -67,28 +73,28 @@ describe('v8 deserializer', common.mustCall(() => { it('should deserialize a chunk with no serialization', async () => { const reported = await collectReported([Buffer.from('unknown')]); assert.deepStrictEqual(reported, [ - { data: { __proto__: null, file: 'filetest', message: 'unknown' }, type: 'test:stdout' }, + { data: { __proto__: null, entryFile, file: 'filetest', message: 'unknown' }, type: 'test:stdout' }, ]); }); it('should deserialize a serialized chunk', async () => { const reported = await collectReported(chunks); - assert.deepStrictEqual(reported, [diagnosticEvent]); + assert.deepStrictEqual(reported, [reportedDiagnosticEvent]); }); it('should deserialize a serialized chunk after non-serialized chunk', async () => { const reported = await collectReported([Buffer.concat([Buffer.from('unknown'), ...chunks])]); assert.deepStrictEqual(reported, [ - { data: { __proto__: null, file: 'filetest', message: 'unknown' }, type: 'test:stdout' }, - diagnosticEvent, + { data: { __proto__: null, entryFile, file: 'filetest', message: 'unknown' }, type: 'test:stdout' }, + reportedDiagnosticEvent, ]); }); it('should deserialize a serialized chunk before non-serialized output', async () => { const reported = await collectReported([Buffer.concat([ ...chunks, Buffer.from('unknown')])]); assert.deepStrictEqual(reported, [ - diagnosticEvent, - { data: { __proto__: null, file: 'filetest', message: 'unknown' }, type: 'test:stdout' }, + reportedDiagnosticEvent, + { data: { __proto__: null, entryFile, file: 'filetest', message: 'unknown' }, type: 'test:stdout' }, ]); }); @@ -131,7 +137,7 @@ describe('v8 deserializer', common.mustCall(() => { oversizedLengthHeader, ...chunks, ]); - assert.deepStrictEqual(reported.at(-1), diagnosticEvent); + assert.deepStrictEqual(reported.at(-1), reportedDiagnosticEvent); assert.strictEqual(reported.filter((event) => event.type === 'test:diagnostic').length, 1); assert.strictEqual(collectStdout(reported), oversizedLengthStdout); }); @@ -152,7 +158,7 @@ describe('v8 deserializer', common.mustCall(() => { const data = chunks[0]; const reported = await collectReported([data.subarray(0, i), data.subarray(i)]); assert.deepStrictEqual(reported, [ - diagnosticEvent, + reportedDiagnosticEvent, ]); }); @@ -163,9 +169,9 @@ describe('v8 deserializer', common.mustCall(() => { Buffer.concat([data.subarray(i), Buffer.from('unknown')]), ]); assert.deepStrictEqual(reported, [ - { data: { __proto__: null, file: 'filetest', message: 'unknown' }, type: 'test:stdout' }, - diagnosticEvent, - { data: { __proto__: null, file: 'filetest', message: 'unknown' }, type: 'test:stdout' }, + { data: { __proto__: null, entryFile, file: 'filetest', message: 'unknown' }, type: 'test:stdout' }, + reportedDiagnosticEvent, + { data: { __proto__: null, entryFile, file: 'filetest', message: 'unknown' }, type: 'test:stdout' }, ]); } );