diff --git a/lib/internal/test_runner/test.js b/lib/internal/test_runner/test.js
index 1b9faad58a68b5..4c209c1943916a 100644
--- a/lib/internal/test_runner/test.js
+++ b/lib/internal/test_runner/test.js
@@ -2,6 +2,7 @@
const {
ArrayFrom,
ArrayPrototypeEvery,
+ ArrayPrototypeJoin,
ArrayPrototypePush,
ArrayPrototypePushApply,
ArrayPrototypeShift,
@@ -1668,6 +1669,17 @@ class Test extends AsyncResource {
details.passed_on_attempt = this.passedAttempt;
}
+ // Generate classname from suite hierarchy for JUnit reporter
+ if (this.parent && this.parent !== this.root) {
+ const parts = [];
+ for (let t = this.parent; t !== t.root; t = t.parent) {
+ ArrayPrototypeUnshift(parts, t.name);
+ }
+ if (parts.length > 0) {
+ details.classname = ArrayPrototypeJoin(parts, '.');
+ }
+ }
+
return { __proto__: null, details, directive };
}
diff --git a/lib/internal/test_runner/tests_stream.js b/lib/internal/test_runner/tests_stream.js
index 7fb514fb99e2f5..f8dd21152eeb4d 100644
--- a/lib/internal/test_runner/tests_stream.js
+++ b/lib/internal/test_runner/tests_stream.js
@@ -45,6 +45,7 @@ class TestsStream extends Readable {
parentId,
details,
tags: ArrayPrototypeSlice(tags),
+ ...(details.classname && { __proto__: null, classname: details.classname }),
...loc,
...directive,
});
@@ -60,6 +61,7 @@ class TestsStream extends Readable {
parentId,
details,
tags: ArrayPrototypeSlice(tags),
+ ...(details.classname && { __proto__: null, classname: details.classname }),
...loc,
...directive,
});
diff --git a/test/fixtures/test-runner/output/junit_classname_hierarchy.js b/test/fixtures/test-runner/output/junit_classname_hierarchy.js
new file mode 100644
index 00000000000000..8b634be39380e7
--- /dev/null
+++ b/test/fixtures/test-runner/output/junit_classname_hierarchy.js
@@ -0,0 +1,19 @@
+'use strict';
+require('../../../common');
+const { suite, test } = require('node:test');
+
+suite('Math', () => {
+ suite('Addition', () => {
+ test('adds positive numbers', () => {});
+ });
+
+ suite('Multiplication', () => {
+ test('multiplies positive numbers', () => {});
+ });
+});
+
+suite('String', () => {
+ test('concatenates strings', () => {});
+});
+
+test('standalone test', () => {});
diff --git a/test/fixtures/test-runner/output/junit_classname_hierarchy.snapshot b/test/fixtures/test-runner/output/junit_classname_hierarchy.snapshot
new file mode 100644
index 00000000000000..fd3806f531e0bf
--- /dev/null
+++ b/test/fixtures/test-runner/output/junit_classname_hierarchy.snapshot
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/fixtures/test-runner/output/junit_reporter.snapshot b/test/fixtures/test-runner/output/junit_reporter.snapshot
index cef5f0b52da186..5130996dd2fddd 100644
--- a/test/fixtures/test-runner/output/junit_reporter.snapshot
+++ b/test/fixtures/test-runner/output/junit_reporter.snapshot
@@ -129,7 +129,7 @@ true !== false
-
+
Error [ERR_TEST_FAILURE]: thrown from subtest sync throw fail
at TestContext.<anonymous> (/test/fixtures/test-runner/output/output.js:125:11)
@@ -152,15 +152,15 @@ Error [ERR_TEST_FAILURE]: thrown from subtest sync throw fail
-
-
-
-
+
+
+
+
-
+
-
+
@@ -267,9 +267,9 @@ Error [ERR_TEST_FAILURE]: thrown from callback async throw
-
-
-
+
+
+
@@ -289,7 +289,7 @@ Error [ERR_TEST_FAILURE]: thrown from callback async throw
-
+
Error [ERR_TEST_FAILURE]: thrown from subtest sync throw fails at first
at TestContext.<anonymous> (/test/fixtures/test-runner/output/output.js:334:11)
@@ -304,7 +304,7 @@ Error [ERR_TEST_FAILURE]: thrown from subtest sync throw fails at first
}
-
+
Error [ERR_TEST_FAILURE]: thrown from subtest sync throw fails at second
at TestContext.<anonymous> (/test/fixtures/test-runner/output/output.js:337:11) {
diff --git a/test/parallel/test-runner-reporters.js b/test/parallel/test-runner-reporters.js
index 7fed79d45b48fd..a2f6316a84fe6d 100644
--- a/test/parallel/test-runner-reporters.js
+++ b/test/parallel/test-runner-reporters.js
@@ -207,7 +207,7 @@ describe('node:test reporters', { concurrency: true }, () => {
assert.match(timestamp, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
assert.ok(!Number.isNaN(Date.parse(timestamp)), `expected a valid date, got ${timestamp}`);
assert.match(fileContents, /\s*/);
- assert.match(fileContents, //);
+ assert.match(fileContents, //);
assert.match(fileContents, //);
});
});
diff --git a/test/test-runner/test-output-junit-classname-hierarchy.mjs b/test/test-runner/test-output-junit-classname-hierarchy.mjs
new file mode 100644
index 00000000000000..737cefd89ecae2
--- /dev/null
+++ b/test/test-runner/test-output-junit-classname-hierarchy.mjs
@@ -0,0 +1,12 @@
+// Test that the output of test-runner/output/junit_classname_hierarchy.js matches
+// test-runner/output/junit_classname_hierarchy.snapshot
+import '../common/index.mjs';
+import * as fixtures from '../common/fixtures.mjs';
+import { spawnAndAssert, junitTransform, ensureCwdIsProjectRoot } from '../common/assertSnapshot.js';
+
+ensureCwdIsProjectRoot();
+await spawnAndAssert(
+ fixtures.path('test-runner/output/junit_classname_hierarchy.js'),
+ junitTransform,
+ { flags: ['--test-reporter=junit'] },
+);