Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions doc/api/test.md
Comment thread
MoLow marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
26 changes: 19 additions & 7 deletions lib/internal/test_runner/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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();

Expand All @@ -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();
}

Expand Down Expand Up @@ -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--;
}

Expand Down Expand Up @@ -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++) {
Expand All @@ -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() {
Expand Down
18 changes: 12 additions & 6 deletions lib/internal/test_runner/tests_stream.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
}
Expand Down
21 changes: 21 additions & 0 deletions test/fixtures/test-runner/test-id-fixture.js
Original file line number Diff line number Diff line change
@@ -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));
});
76 changes: 76 additions & 0 deletions test/parallel/test-runner-test-id.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading