From 9c7ce26aee043c8f3abc163563f4ab909d4cff4f Mon Sep 17 00:00:00 2001 From: Moshe Atlow Date: Thu, 16 Apr 2026 12:54:04 +0300 Subject: [PATCH] test_runner: add `testId` to test events Signed-off-by: Moshe Atlow --- doc/api/test.md | 18 +++++ lib/internal/test_runner/test.js | 26 +++++-- lib/internal/test_runner/tests_stream.js | 18 +++-- test/fixtures/test-runner/test-id-fixture.js | 21 ++++++ test/parallel/test-runner-test-id.js | 76 ++++++++++++++++++++ 5 files changed, 146 insertions(+), 13 deletions(-) create mode 100644 test/fixtures/test-runner/test-id-fixture.js create mode 100644 test/parallel/test-runner-test-id.js diff --git a/doc/api/test.md b/doc/api/test.md index b458c3ccd98471..c58d59d6717812 100644 --- a/doc/api/test.md +++ b/doc/api/test.md @@ -3431,6 +3431,9 @@ Emitted when code coverage is enabled and all tests have completed. `undefined` if the test was run through the REPL. * `name` {string} The test name. * `nesting` {number} The nesting level of the test. + * `testId` {number} A numeric identifier for this test instance, unique + within the test file's process. Consistent across all events for the same + test instance, enabling reliable correlation in custom reporters. * `testNumber` {number} The ordinal number of the test. * `todo` {string|boolean|undefined} Present if [`context.todo`][] is called * `skip` {string|boolean|undefined} Present if [`context.skip`][] is called @@ -3451,6 +3454,9 @@ The corresponding declaration ordered events are `'test:pass'` and `'test:fail'` `undefined` if the test was run through the REPL. * `name` {string} The test name. * `nesting` {number} The nesting level of the test. + * `testId` {number} A numeric identifier for this test instance, unique + within the test file's process. Consistent across all events for the same + test instance, enabling reliable correlation in custom reporters. * `type` {string} The test type. Either `'suite'` or `'test'`. Emitted when a test is dequeued, right before it is executed. @@ -3489,6 +3495,9 @@ defined. `undefined` if the test was run through the REPL. * `name` {string} The test name. * `nesting` {number} The nesting level of the test. + * `testId` {number} A numeric identifier for this test instance, unique + within the test file's process. Consistent across all events for the same + test instance, enabling reliable correlation in custom reporters. * `type` {string} The test type. Either `'suite'` or `'test'`. Emitted when a test is enqueued for execution. @@ -3512,6 +3521,9 @@ Emitted when a test is enqueued for execution. `undefined` if the test was run through the REPL. * `name` {string} The test name. * `nesting` {number} The nesting level of the test. + * `testId` {number} A numeric identifier for this test instance, unique + within the test file's process. Consistent across all events for the same + test instance, enabling reliable correlation in custom reporters. * `testNumber` {number} The ordinal number of the test. * `todo` {string|boolean|undefined} Present if [`context.todo`][] is called * `skip` {string|boolean|undefined} Present if [`context.skip`][] is called @@ -3568,6 +3580,9 @@ since the parent runner only knows about file-level tests. When using `undefined` if the test was run through the REPL. * `name` {string} The test name. * `nesting` {number} The nesting level of the test. + * `testId` {number} A numeric identifier for this test instance, unique + within the test file's process. Consistent across all events for the same + test instance, enabling reliable correlation in custom reporters. * `testNumber` {number} The ordinal number of the test. * `todo` {string|boolean|undefined} Present if [`context.todo`][] is called * `skip` {string|boolean|undefined} Present if [`context.skip`][] is called @@ -3604,6 +3619,9 @@ defined. `undefined` if the test was run through the REPL. * `name` {string} The test name. * `nesting` {number} The nesting level of the test. + * `testId` {number} A numeric identifier for this test instance, unique + within the test file's process. Consistent across all events for the same + test instance, enabling reliable correlation in custom reporters. Emitted when a test starts reporting its own and its subtests status. This event is guaranteed to be emitted in the same order as the tests are diff --git a/lib/internal/test_runner/test.js b/lib/internal/test_runner/test.js index 2b8f12a043e3d5..ca23a94000c4b8 100644 --- a/lib/internal/test_runner/test.js +++ b/lib/internal/test_runner/test.js @@ -590,6 +590,8 @@ class Test extends AsyncResource { this.timeout = kDefaultTimeout; this.entryFile = entryFile; this.testDisambiguator = new SafeMap(); + this.nextTestId = 1; + this.testId = 0; } else { const nesting = parent.parent === null ? parent.nesting : parent.nesting + 1; @@ -606,6 +608,7 @@ class Test extends AsyncResource { this.childNumber = parent.subtests.length + 1; this.timeout = parent.timeout; this.entryFile = parent.entryFile; + this.testId = this.root.nextTestId++; if (isFilteringByName) { this.filteredByName = this.willBeFilteredByName(); @@ -884,7 +887,7 @@ class Test extends AsyncResource { const deferred = this.dequeuePendingSubtest(); const test = deferred.test; this.assignReportOrder(test); - test.reporter.dequeue(test.nesting, test.loc, test.name, this.reportedType); + test.reporter.dequeue(test.nesting, test.loc, test.name, this.reportedType, test.testId); await test.run(); deferred.resolve(); } @@ -1141,7 +1144,7 @@ class Test extends AsyncResource { // it. Otherwise, return a Promise to the caller and mark the test as // pending for later execution. this.parent.unfinishedSubtests.add(this); - this.reporter.enqueue(this.nesting, this.loc, this.name, this.reportedType); + this.reporter.enqueue(this.nesting, this.loc, this.name, this.reportedType, this.testId); if (this.root.harness.buildPromise || !this.parent.hasConcurrency()) { const deferred = PromiseWithResolvers(); @@ -1164,7 +1167,7 @@ class Test extends AsyncResource { } this.parent.assignReportOrder(this); - this.reporter.dequeue(this.nesting, this.loc, this.name, this.reportedType); + this.reporter.dequeue(this.nesting, this.loc, this.name, this.reportedType, this.testId); return this.run(); } @@ -1426,7 +1429,10 @@ class Test extends AsyncResource { const report = this.getReportDetails(); report.details.passed = this.passed; this.testNumber ||= ++this.parent.outputSubtestCount; - this.reporter.complete(this.nesting, this.loc, this.testNumber, this.name, report.details, report.directive); + this.reporter.complete( + this.nesting, this.loc, this.testNumber, this.name, + report.details, report.directive, this.testId, + ); this.parent.activeSubtests--; } @@ -1579,9 +1585,15 @@ class Test extends AsyncResource { const report = this.getReportDetails(); if (this.passed) { - this.reporter.ok(this.nesting, this.loc, this.testNumber, this.name, report.details, report.directive); + this.reporter.ok( + this.nesting, this.loc, this.testNumber, this.name, + report.details, report.directive, this.testId, + ); } else { - this.reporter.fail(this.nesting, this.loc, this.testNumber, this.name, report.details, report.directive); + this.reporter.fail( + this.nesting, this.loc, this.testNumber, this.name, + report.details, report.directive, this.testId, + ); } for (let i = 0; i < this.diagnostics.length; i++) { @@ -1595,7 +1607,7 @@ class Test extends AsyncResource { } this.#reportedSubtest = true; this.parent.reportStarted(); - this.reporter.start(this.nesting, this.loc, this.name); + this.reporter.start(this.nesting, this.loc, this.name, this.testId); } clearExecutionTime() { diff --git a/lib/internal/test_runner/tests_stream.js b/lib/internal/test_runner/tests_stream.js index 17b6890b5fc5df..073845829451c3 100644 --- a/lib/internal/test_runner/tests_stream.js +++ b/lib/internal/test_runner/tests_stream.js @@ -34,36 +34,39 @@ class TestsStream extends Readable { } } - fail(nesting, loc, testNumber, name, details, directive) { + fail(nesting, loc, testNumber, name, details, directive, testId) { this[kEmitMessage]('test:fail', { __proto__: null, name, nesting, testNumber, + testId, details, ...loc, ...directive, }); } - ok(nesting, loc, testNumber, name, details, directive) { + ok(nesting, loc, testNumber, name, details, directive, testId) { this[kEmitMessage]('test:pass', { __proto__: null, name, nesting, testNumber, + testId, details, ...loc, ...directive, }); } - complete(nesting, loc, testNumber, name, details, directive) { + complete(nesting, loc, testNumber, name, details, directive, testId) { this[kEmitMessage]('test:complete', { __proto__: null, name, nesting, testNumber, + testId, details, ...loc, ...directive, @@ -91,31 +94,34 @@ class TestsStream extends Readable { return { __proto__: null, expectFailure: expectation ?? true }; } - enqueue(nesting, loc, name, type) { + enqueue(nesting, loc, name, type, testId) { this[kEmitMessage]('test:enqueue', { __proto__: null, nesting, name, type, + testId, ...loc, }); } - dequeue(nesting, loc, name, type) { + dequeue(nesting, loc, name, type, testId) { this[kEmitMessage]('test:dequeue', { __proto__: null, nesting, name, type, + testId, ...loc, }); } - start(nesting, loc, name) { + start(nesting, loc, name, testId) { this[kEmitMessage]('test:start', { __proto__: null, nesting, name, + testId, ...loc, }); } diff --git a/test/fixtures/test-runner/test-id-fixture.js b/test/fixtures/test-runner/test-id-fixture.js new file mode 100644 index 00000000000000..d3a6548207b2bc --- /dev/null +++ b/test/fixtures/test-runner/test-id-fixture.js @@ -0,0 +1,21 @@ +'use strict'; +const { describe, it } = require('node:test'); +const assert = require('node:assert'); + +// Factory that creates subtests at the SAME source location. +// Multiple concurrent `it` blocks calling this will have subtests +// sharing file:line:column — but each should get a distinct testId. +function makeSubtest(shouldFail) { + return async function(t) { + await t.test('e2e', async () => { + if (shouldFail) assert.fail('intentional'); + }); + }; +} + +describe('suite', { concurrency: 10_000 }, () => { + it('test-A (passes)', makeSubtest(false)); + it('test-B (passes)', makeSubtest(false)); + it('test-C (fails)', makeSubtest(true)); + it('test-D (passes)', makeSubtest(false)); +}); diff --git a/test/parallel/test-runner-test-id.js b/test/parallel/test-runner-test-id.js new file mode 100644 index 00000000000000..2c9572e2cb1b16 --- /dev/null +++ b/test/parallel/test-runner-test-id.js @@ -0,0 +1,76 @@ +'use strict'; +require('../common'); +const assert = require('node:assert'); +const { run } = require('node:test'); +const fixtures = require('../common/fixtures'); + +async function collectEvents() { + const events = []; + const stream = run({ + files: [fixtures.path('test-runner/test-id-fixture.js')], + isolation: 'none', + }); + for await (const event of stream) { + events.push(event); + } + return events; +} + +async function main() { + const events = await collectEvents(); + + // 1. Every per-test event should have a numeric testId. + const perTestTypes = new Set([ + 'test:start', 'test:complete', 'test:fail', + 'test:pass', 'test:enqueue', 'test:dequeue', + ]); + for (const event of events) { + if (perTestTypes.has(event.type)) { + assert.strictEqual(typeof event.data.testId, 'number', + `${event.type} for "${event.data.name}" should have numeric testId`); + } + } + + // 2. test:start and test:fail for the same instance should share testId. + const failEvent = events.find( + (e) => e.type === 'test:fail' && e.data.name === 'e2e', + ); + assert.ok(failEvent, 'should have a test:fail for "e2e"'); + + const startEvent = events.find( + (e) => e.type === 'test:start' && + e.data.testId === failEvent.data.testId, + ); + assert.ok(startEvent, 'should have a test:start with matching testId'); + assert.strictEqual(startEvent.data.name, 'e2e'); + + // 3. Concurrent instances at the same source location get distinct testIds. + const e2eStarts = events.filter( + (e) => e.type === 'test:start' && e.data.name === 'e2e', + ); + assert.strictEqual(e2eStarts.length, 4); + + const testIds = e2eStarts.map((e) => e.data.testId); + const uniqueIds = new Set(testIds); + assert.strictEqual(uniqueIds.size, 4, + `all 4 "e2e" instances should have distinct testIds, got: ${testIds}`); + + // 4. test:complete for the same instance shares testId with test:start. + const completeEvents = events.filter( + (e) => e.type === 'test:complete' && e.data.name === 'e2e', + ); + for (const complete of completeEvents) { + const matchingStart = e2eStarts.find( + (s) => s.data.testId === complete.data.testId, + ); + assert.ok(matchingStart, + `test:complete (testId=${complete.data.testId}) should match a test:start`); + } + + console.log('All testId assertions passed'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});