diff --git a/lib/internal/test_runner/reporter/junit.js b/lib/internal/test_runner/reporter/junit.js
index 25f5667c59e273..130ff60af405ad 100644
--- a/lib/internal/test_runner/reporter/junit.js
+++ b/lib/internal/test_runner/reporter/junit.js
@@ -5,6 +5,9 @@ const {
ArrayPrototypeMap,
ArrayPrototypePush,
ArrayPrototypeSome,
+ Date,
+ DateNow,
+ DatePrototypeToISOString,
NumberPrototypeToFixed,
ObjectEntries,
RegExpPrototypeSymbolReplace,
@@ -112,6 +115,11 @@ module.exports = async function* junitReporter(source) {
currentTest.attrs.tests = nonCommentChildren.length;
currentTest.attrs.failures = ArrayPrototypeFilter(currentTest.children, isFailure).length;
currentTest.attrs.skipped = ArrayPrototypeFilter(currentTest.children, isSkipped).length;
+ // A suite's `test:start` is emitted lazily (when its first subtest
+ // reports), so derive the start time from the end minus the measured
+ // duration rather than stamping the (late) test:start moment.
+ currentTest.attrs.timestamp =
+ DatePrototypeToISOString(new Date(DateNow() - event.data.details.duration_ms));
currentTest.attrs.hostname = HOSTNAME;
} else {
currentTest.tag = 'testcase';
diff --git a/test/common/assertSnapshot.js b/test/common/assertSnapshot.js
index 9a7482020e098a..4fdd0be0ee32ae 100644
--- a/test/common/assertSnapshot.js
+++ b/test/common/assertSnapshot.js
@@ -226,6 +226,7 @@ function replaceJunitDuration(str) {
.replaceAll(/time="[0-9.]+"/g, 'time="*"')
.replaceAll(/duration_ms [0-9.]+/g, 'duration_ms *')
.replaceAll(`hostname="${hostname()}"`, 'hostname="HOSTNAME"')
+ .replaceAll(/timestamp="[^"]*"/g, 'timestamp="*"')
.replaceAll(/file="[^"]*"/g, 'file="*"');
}
diff --git a/test/fixtures/test-runner/output/junit_reporter.snapshot b/test/fixtures/test-runner/output/junit_reporter.snapshot
index 1142b5b31ff2e7..cef5f0b52da186 100644
--- a/test/fixtures/test-runner/output/junit_reporter.snapshot
+++ b/test/fixtures/test-runner/output/junit_reporter.snapshot
@@ -128,7 +128,7 @@ true !== false
-
+
Error [ERR_TEST_FAILURE]: thrown from subtest sync throw fail
@@ -151,15 +151,15 @@ Error [ERR_TEST_FAILURE]: thrown from subtest sync throw fail
[Error [ERR_TEST_FAILURE]: Symbol(thrown symbol from sync throw non-error fail)] { code: 'ERR_TEST_FAILURE', failureType: 'testCodeFailure', cause: Symbol(thrown symbol from sync throw non-error fail) }
-
+
-
+
-
+
@@ -266,7 +266,7 @@ Error [ERR_TEST_FAILURE]: thrown from callback async throw
-
+
@@ -288,7 +288,7 @@ Error [ERR_TEST_FAILURE]: thrown from callback async throw
}
-
+
Error [ERR_TEST_FAILURE]: thrown from subtest sync throw fails at first
diff --git a/test/parallel/test-runner-reporters.js b/test/parallel/test-runner-reporters.js
index 50a47578a1da7e..7fed79d45b48fd 100644
--- a/test/parallel/test-runner-reporters.js
+++ b/test/parallel/test-runner-reporters.js
@@ -200,6 +200,12 @@ describe('node:test reporters', { concurrency: true }, () => {
assert.strictEqual(child.stdout.toString(), '');
const fileContents = fs.readFileSync(file, 'utf8');
assert.match(fileContents, //);
+ // The exact timestamp format is intentionally not pinned here (still under
+ // discussion); assert only that the value is present and a real date.
+ const { 1: timestamp } = fileContents.match(/]*timestamp="([^"]+)"/) ?? [];
+ assert.ok(timestamp, 'testsuite should have a timestamp attribute');
+ 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, //);