Skip to content

Commit aa1608c

Browse files
Add timestamp attribute (#45)
While the timestamp attribute is not part of the JUnit or Surefire XSD in practice it seems to be a common enough property[1] that we can add it without expecting any of the popular tools to break. Closes: #44 1. https://github.com/testmoapp/junitxml Co-authored-by: David Goss <[email protected]>
1 parent 24a1200 commit aa1608c

27 files changed

+92
-31
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## [Unreleased]
9+
### Added
10+
- Add `timestamp` attribute ((#45)[https://github.com/cucumber/junit-xml-formatter/pull/45])
911

1012
## [0.6.0] - 2024-11-15
1113
### Added

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ JUnit XML Formatter
77

88
Writes Cucumber message into a JUnit XML report.
99

10-
The JUnit XML report is a loose standard. We validate it against the
11-
[Jenkins JUnit XML XSD](./jenkins-junit.xsd) so there should be a good
12-
chance your CI will understand it.
10+
The JUnit XML report is
11+
[a de facto standard without an official specification](https://github.com/testmoapp/junitxml/tree/main).
12+
But we validate it against the [Jenkins JUnit XML XSD](./jenkins-junit.xsd) so
13+
there should be a good chance your CI will understand it.
1314

1415
If not, please let us know in the issues!
1516

java/src/main/java/io/cucumber/junitxmlformatter/XmlReportData.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package io.cucumber.junitxmlformatter;
22

3+
import io.cucumber.messages.Convertor;
34
import io.cucumber.messages.types.Envelope;
45
import io.cucumber.messages.types.Feature;
56
import io.cucumber.messages.types.Pickle;
67
import io.cucumber.messages.types.PickleStep;
78
import io.cucumber.messages.types.Step;
89
import io.cucumber.messages.types.TestCaseStarted;
10+
import io.cucumber.messages.types.TestRunStarted;
911
import io.cucumber.messages.types.TestStep;
1012
import io.cucumber.messages.types.TestStepFinished;
1113
import io.cucumber.messages.types.TestStepResult;
@@ -22,6 +24,7 @@
2224
import java.util.Optional;
2325

2426
import static io.cucumber.messages.types.TestStepResultStatus.PASSED;
27+
import static java.time.format.DateTimeFormatter.ISO_INSTANT;
2528
import static java.util.concurrent.TimeUnit.SECONDS;
2629
import static java.util.stream.Collectors.toList;
2730

@@ -125,4 +128,10 @@ TestStepResult getTestCaseStatus(TestCaseStarted testCaseStarted) {
125128
.orElse(SCENARIO_WITH_NO_STEPS);
126129
}
127130

128-
}
131+
public Optional<String> getTestRunStartedAt() {
132+
return query.findTestRunStarted()
133+
.map(TestRunStarted::getTimestamp)
134+
.map(Convertor::toInstant)
135+
.map(ISO_INSTANT::format);
136+
}
137+
}

java/src/main/java/io/cucumber/junitxmlformatter/XmlReportWriter.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ private void writeSuiteAttributes(EscapingXmlStreamWriter writer) throws XMLStre
5959
writer.writeAttribute("skipped", counts.get(SKIPPED).toString());
6060
writer.writeAttribute("failures", String.valueOf(countFailures(counts)));
6161
writer.writeAttribute("errors", "0");
62+
63+
Optional<String> testRunStartedAt = data.getTestRunStartedAt();
64+
if (testRunStartedAt.isPresent()) {
65+
writer.writeAttribute("timestamp", testRunStartedAt.get());
66+
}
6267
}
6368

6469
private static long countFailures(Map<TestStepResultStatus, Long> counts) {

java/src/test/java/io/cucumber/junitxmlformatter/MessagesToJunitXmlWriterAcceptanceTest.java

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import org.junit.jupiter.api.Disabled;
77
import org.junit.jupiter.params.ParameterizedTest;
88
import org.junit.jupiter.params.provider.MethodSource;
9+
import org.xmlunit.assertj.XmlAssert;
910
import org.xmlunit.builder.Input;
1011
import org.xmlunit.validation.JAXPValidator;
1112
import org.xmlunit.validation.Languages;
@@ -20,6 +21,7 @@
2021
import java.nio.file.Files;
2122
import java.nio.file.Path;
2223
import java.nio.file.Paths;
24+
import java.util.ArrayList;
2325
import java.util.Arrays;
2426
import java.util.Comparator;
2527
import java.util.List;
@@ -76,11 +78,16 @@ void validateAgainstSurefire(TestCase testCase) throws IOException {
7678
ByteArrayOutputStream bytes = writeJunitXmlReport(testCase, new ByteArrayOutputStream());
7779
Source actual = Input.fromByteArray(bytes.toByteArray()).build();
7880
Source surefireSchema = Input.fromPath(Paths.get("../surefire-test-report-3.0.xsd")).build();
79-
if (!testCasesWithMissingException.contains(testCase.name)) {
80-
assertThat(actual).isValidAgainst(surefireSchema);
81-
return;
82-
}
8381

82+
JAXPValidator validator = new JAXPValidator(Languages.W3C_XML_SCHEMA_NS_URI);
83+
validator.setSchemaSource(surefireSchema);
84+
ValidationResult validationResult = validator.validateInstance(actual);
85+
86+
List<String> expectedProblems = new ArrayList<>();
87+
/*
88+
* We add the timestamp attribute to all reports.
89+
*/
90+
expectedProblems.add("cvc-complex-type.3.2.2: Attribute 'timestamp' is not allowed to appear in element 'testsuite'.");
8491
/*
8592
This report tries to be compatible with the Jenkins XSD. The Surefire
8693
XSD is a bit stricter and generally assumes tests fail with an
@@ -94,12 +101,11 @@ void validateAgainstSurefire(TestCase testCase) throws IOException {
94101
Since the Surefire XSD is also relatively popular we do check it and
95102
exclude the cases that don't pass selectively.
96103
*/
97-
JAXPValidator validator = new JAXPValidator(Languages.W3C_XML_SCHEMA_NS_URI);
98-
validator.setSchemaSource(surefireSchema);
99-
ValidationResult validationResult = validator.validateInstance(actual);
104+
if (testCasesWithMissingException.contains(testCase.name)) {
105+
expectedProblems.add("cvc-complex-type.4: Attribute 'type' must appear on element 'failure'.");
106+
}
100107
Iterable<ValidationProblem> problems = validationResult.getProblems();
101-
Assertions.assertThat(problems).extracting(ValidationProblem::getMessage)
102-
.containsOnly("cvc-complex-type.4: Attribute 'type' must appear on element 'failure'.");
108+
Assertions.assertThat(problems).extracting(ValidationProblem::getMessage).containsAll(expectedProblems);
103109
}
104110

105111
@ParameterizedTest

java/src/test/java/io/cucumber/junitxmlformatter/MessagesToJunitXmlWriterTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ void it_writes_two_messages_to_xml() throws IOException {
2828

2929
assertThat(html).isEqualTo("" +
3030
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
31-
"<testsuite name=\"Cucumber\" time=\"20\" tests=\"0\" skipped=\"0\" failures=\"0\" errors=\"0\">\n" +
31+
"<testsuite name=\"Cucumber\" time=\"20\" tests=\"0\" skipped=\"0\" failures=\"0\" errors=\"0\" timestamp=\"1970-01-01T00:00:10Z\">\n" +
3232
"</testsuite>\n"
3333
);
3434
}

javascript/package-lock.json

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

javascript/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"dependencies": {
2525
"@cucumber/query": "^13.0.2",
2626
"@teppeis/multimaps": "^3.0.0",
27+
"luxon": "^3.5.0",
2728
"xmlbuilder": "^15.1.1"
2829
},
2930
"peerDependencies": {
@@ -35,6 +36,7 @@
3536
"@types/chai": "^5.0.0",
3637
"@types/chai-almost": "^1.0.3",
3738
"@types/chai-xml": "^0.3.6",
39+
"@types/luxon": "^3.4.2",
3840
"@types/mocha": "^10.0.6",
3941
"@types/node": "22.9.0",
4042
"@typescript-eslint/eslint-plugin": "8.14.0",

javascript/src/helpers.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import {
22
Duration,
33
PickleStep,
44
Step,
5+
TestRunStarted,
56
TestStepResultStatus,
67
TimeConversion,
78
} from '@cucumber/messages'
9+
import { DateTime } from 'luxon'
810

911
export function durationToSeconds(duration?: Duration) {
1012
if (!duration) {
@@ -29,3 +31,13 @@ export function formatStep(step: Step, pickleStep: PickleStep, status: TestStepR
2931
} while (text.length < 76)
3032
return text + status.toLowerCase()
3133
}
34+
35+
export function formatTimestamp(testRunStarted: TestRunStarted | undefined) {
36+
if (!testRunStarted) {
37+
return undefined
38+
}
39+
const millis = TimeConversion.timestampToMillisecondsSinceEpoch(testRunStarted.timestamp)
40+
return DateTime.fromMillis(millis, { zone: 'UTC' }).toISO({
41+
suppressMilliseconds: true,
42+
}) as string
43+
}

javascript/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ export default {
3131
builder.att('failures', testSuite.failures)
3232
builder.att('errors', testSuite.errors)
3333

34+
if (testSuite.timestamp) {
35+
builder.att('timestamp', testSuite.timestamp)
36+
}
37+
3438
for (const testCase of testSuite.testCases) {
3539
const testcaseElement = builder.ele('testcase', {
3640
classname: testCase.classname,

0 commit comments

Comments
 (0)