diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aa6ebe..0bc2f4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Add `timestamp` attribute ((#45)[https://github.com/cucumber/junit-xml-formatter/pull/45]) ## [0.6.0] - 2024-11-15 ### Added diff --git a/README.md b/README.md index 74b623c..9648625 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,10 @@ JUnit XML Formatter Writes Cucumber message into a JUnit XML report. -The JUnit XML report is a loose standard. We validate it against the -[Jenkins JUnit XML XSD](./jenkins-junit.xsd) so there should be a good -chance your CI will understand it. +The JUnit XML report is +[a de facto standard without an official specification](https://github.com/testmoapp/junitxml/tree/main). +But we validate it against the [Jenkins JUnit XML XSD](./jenkins-junit.xsd) so +there should be a good chance your CI will understand it. If not, please let us know in the issues! diff --git a/java/src/main/java/io/cucumber/junitxmlformatter/XmlReportData.java b/java/src/main/java/io/cucumber/junitxmlformatter/XmlReportData.java index 170bb1b..ce13d5e 100644 --- a/java/src/main/java/io/cucumber/junitxmlformatter/XmlReportData.java +++ b/java/src/main/java/io/cucumber/junitxmlformatter/XmlReportData.java @@ -1,11 +1,13 @@ package io.cucumber.junitxmlformatter; +import io.cucumber.messages.Convertor; import io.cucumber.messages.types.Envelope; import io.cucumber.messages.types.Feature; import io.cucumber.messages.types.Pickle; import io.cucumber.messages.types.PickleStep; import io.cucumber.messages.types.Step; import io.cucumber.messages.types.TestCaseStarted; +import io.cucumber.messages.types.TestRunStarted; import io.cucumber.messages.types.TestStep; import io.cucumber.messages.types.TestStepFinished; import io.cucumber.messages.types.TestStepResult; @@ -22,6 +24,7 @@ import java.util.Optional; import static io.cucumber.messages.types.TestStepResultStatus.PASSED; +import static java.time.format.DateTimeFormatter.ISO_INSTANT; import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.stream.Collectors.toList; @@ -125,4 +128,10 @@ TestStepResult getTestCaseStatus(TestCaseStarted testCaseStarted) { .orElse(SCENARIO_WITH_NO_STEPS); } -} \ No newline at end of file + public Optional getTestRunStartedAt() { + return query.findTestRunStarted() + .map(TestRunStarted::getTimestamp) + .map(Convertor::toInstant) + .map(ISO_INSTANT::format); + } +} diff --git a/java/src/main/java/io/cucumber/junitxmlformatter/XmlReportWriter.java b/java/src/main/java/io/cucumber/junitxmlformatter/XmlReportWriter.java index 51f077f..aa763d3 100644 --- a/java/src/main/java/io/cucumber/junitxmlformatter/XmlReportWriter.java +++ b/java/src/main/java/io/cucumber/junitxmlformatter/XmlReportWriter.java @@ -59,6 +59,11 @@ private void writeSuiteAttributes(EscapingXmlStreamWriter writer) throws XMLStre writer.writeAttribute("skipped", counts.get(SKIPPED).toString()); writer.writeAttribute("failures", String.valueOf(countFailures(counts))); writer.writeAttribute("errors", "0"); + + Optional testRunStartedAt = data.getTestRunStartedAt(); + if (testRunStartedAt.isPresent()) { + writer.writeAttribute("timestamp", testRunStartedAt.get()); + } } private static long countFailures(Map counts) { diff --git a/java/src/test/java/io/cucumber/junitxmlformatter/MessagesToJunitXmlWriterAcceptanceTest.java b/java/src/test/java/io/cucumber/junitxmlformatter/MessagesToJunitXmlWriterAcceptanceTest.java index 89e98ab..4866537 100644 --- a/java/src/test/java/io/cucumber/junitxmlformatter/MessagesToJunitXmlWriterAcceptanceTest.java +++ b/java/src/test/java/io/cucumber/junitxmlformatter/MessagesToJunitXmlWriterAcceptanceTest.java @@ -6,6 +6,7 @@ import org.junit.jupiter.api.Disabled; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; +import org.xmlunit.assertj.XmlAssert; import org.xmlunit.builder.Input; import org.xmlunit.validation.JAXPValidator; import org.xmlunit.validation.Languages; @@ -20,6 +21,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; import java.util.List; @@ -76,11 +78,16 @@ void validateAgainstSurefire(TestCase testCase) throws IOException { ByteArrayOutputStream bytes = writeJunitXmlReport(testCase, new ByteArrayOutputStream()); Source actual = Input.fromByteArray(bytes.toByteArray()).build(); Source surefireSchema = Input.fromPath(Paths.get("../surefire-test-report-3.0.xsd")).build(); - if (!testCasesWithMissingException.contains(testCase.name)) { - assertThat(actual).isValidAgainst(surefireSchema); - return; - } + JAXPValidator validator = new JAXPValidator(Languages.W3C_XML_SCHEMA_NS_URI); + validator.setSchemaSource(surefireSchema); + ValidationResult validationResult = validator.validateInstance(actual); + + List expectedProblems = new ArrayList<>(); + /* + * We add the timestamp attribute to all reports. + */ + expectedProblems.add("cvc-complex-type.3.2.2: Attribute 'timestamp' is not allowed to appear in element 'testsuite'."); /* This report tries to be compatible with the Jenkins XSD. The Surefire XSD is a bit stricter and generally assumes tests fail with an @@ -94,12 +101,11 @@ void validateAgainstSurefire(TestCase testCase) throws IOException { Since the Surefire XSD is also relatively popular we do check it and exclude the cases that don't pass selectively. */ - JAXPValidator validator = new JAXPValidator(Languages.W3C_XML_SCHEMA_NS_URI); - validator.setSchemaSource(surefireSchema); - ValidationResult validationResult = validator.validateInstance(actual); + if (testCasesWithMissingException.contains(testCase.name)) { + expectedProblems.add("cvc-complex-type.4: Attribute 'type' must appear on element 'failure'."); + } Iterable problems = validationResult.getProblems(); - Assertions.assertThat(problems).extracting(ValidationProblem::getMessage) - .containsOnly("cvc-complex-type.4: Attribute 'type' must appear on element 'failure'."); + Assertions.assertThat(problems).extracting(ValidationProblem::getMessage).containsAll(expectedProblems); } @ParameterizedTest diff --git a/java/src/test/java/io/cucumber/junitxmlformatter/MessagesToJunitXmlWriterTest.java b/java/src/test/java/io/cucumber/junitxmlformatter/MessagesToJunitXmlWriterTest.java index f00a7f1..09a5906 100644 --- a/java/src/test/java/io/cucumber/junitxmlformatter/MessagesToJunitXmlWriterTest.java +++ b/java/src/test/java/io/cucumber/junitxmlformatter/MessagesToJunitXmlWriterTest.java @@ -28,7 +28,7 @@ void it_writes_two_messages_to_xml() throws IOException { assertThat(html).isEqualTo("" + "\n" + - "\n" + + "\n" + "\n" ); } diff --git a/javascript/package-lock.json b/javascript/package-lock.json index 100e8c5..05480e2 100644 --- a/javascript/package-lock.json +++ b/javascript/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@cucumber/query": "^13.0.2", "@teppeis/multimaps": "^3.0.0", + "luxon": "^3.5.0", "xmlbuilder": "^15.1.1" }, "devDependencies": { @@ -19,6 +20,7 @@ "@types/chai": "^4.3.11", "@types/chai-almost": "^1.0.3", "@types/chai-xml": "^0.3.6", + "@types/luxon": "^3.4.2", "@types/mocha": "^10.0.6", "@types/node": "18.11.18", "@typescript-eslint/eslint-plugin": "5.48.1", @@ -309,6 +311,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/luxon": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", + "integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mocha": { "version": "10.0.9", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.9.tgz", @@ -3022,6 +3031,15 @@ "get-func-name": "^2.0.1" } }, + "node_modules/luxon": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", diff --git a/javascript/package.json b/javascript/package.json index cb42d78..ac2fe91 100644 --- a/javascript/package.json +++ b/javascript/package.json @@ -24,6 +24,7 @@ "dependencies": { "@cucumber/query": "^13.0.2", "@teppeis/multimaps": "^3.0.0", + "luxon": "^3.5.0", "xmlbuilder": "^15.1.1" }, "peerDependencies": { @@ -35,6 +36,7 @@ "@types/chai": "^4.3.11", "@types/chai-almost": "^1.0.3", "@types/chai-xml": "^0.3.6", + "@types/luxon": "^3.4.2", "@types/mocha": "^10.0.6", "@types/node": "18.11.18", "@typescript-eslint/eslint-plugin": "5.48.1", diff --git a/javascript/src/helpers.ts b/javascript/src/helpers.ts index 2712161..5d42f95 100644 --- a/javascript/src/helpers.ts +++ b/javascript/src/helpers.ts @@ -2,9 +2,11 @@ import { Duration, PickleStep, Step, + TestRunStarted, TestStepResultStatus, TimeConversion, } from '@cucumber/messages' +import { DateTime } from 'luxon' export function durationToSeconds(duration?: Duration) { if (!duration) { @@ -29,3 +31,13 @@ export function formatStep(step: Step, pickleStep: PickleStep, status: TestStepR } while (text.length < 76) return text + status.toLowerCase() } + +export function formatTimestamp(testRunStarted: TestRunStarted | undefined) { + if (!testRunStarted) { + return undefined + } + const millis = TimeConversion.timestampToMillisecondsSinceEpoch(testRunStarted.timestamp) + return DateTime.fromMillis(millis, { zone: 'UTC' }).toISO({ + suppressMilliseconds: true, + }) as string +} diff --git a/javascript/src/index.ts b/javascript/src/index.ts index 18bdc86..b94874d 100644 --- a/javascript/src/index.ts +++ b/javascript/src/index.ts @@ -31,6 +31,10 @@ export default { builder.att('failures', testSuite.failures) builder.att('errors', testSuite.errors) + if (testSuite.timestamp) { + builder.att('timestamp', testSuite.timestamp) + } + for (const testCase of testSuite.testCases) { const testcaseElement = builder.ele('testcase', { classname: testCase.classname, diff --git a/javascript/src/makeReport.ts b/javascript/src/makeReport.ts index c280a34..a4e57f2 100644 --- a/javascript/src/makeReport.ts +++ b/javascript/src/makeReport.ts @@ -9,7 +9,7 @@ import { Query, } from '@cucumber/query' -import { countStatuses, durationToSeconds, formatStep } from './helpers.js' +import { countStatuses, durationToSeconds, formatStep, formatTimestamp } from './helpers.js' const NAMING_STRATEGY = namingStrategy( NamingStrategyLength.LONG, @@ -24,6 +24,7 @@ interface ReportSuite { failures: number errors: number testCases: ReadonlyArray + timestamp?: string } interface ReportTestCase { @@ -53,6 +54,7 @@ export function makeReport(query: Query): ReportSuite { ), errors: 0, testCases: makeTestCases(query), + timestamp: formatTimestamp(query.findTestRunStarted()) } } diff --git a/testdata/attachments.feature.xml b/testdata/attachments.feature.xml index f496ca4..6c5bcf4 100644 --- a/testdata/attachments.feature.xml +++ b/testdata/attachments.feature.xml @@ -1,5 +1,5 @@ - + - + in my belly...............................passed diff --git a/testdata/data-tables.feature.xml b/testdata/data-tables.feature.xml index 3278ee4..9271551 100644 --- a/testdata/data-tables.feature.xml +++ b/testdata/data-tables.feature.xml @@ -1,5 +1,5 @@ - + - + diff --git a/testdata/examples-tables.feature.xml b/testdata/examples-tables.feature.xml index 76424af..6758fc1 100644 --- a/testdata/examples-tables.feature.xml +++ b/testdata/examples-tables.feature.xml @@ -1,5 +1,5 @@ - + - + - + - + - + - + diff --git a/testdata/retry.feature.xml b/testdata/retry.feature.xml index e0101e2..ab5c113 100644 --- a/testdata/retry.feature.xml +++ b/testdata/retry.feature.xml @@ -1,5 +1,5 @@ - + - + - + - + - + - +