From a1fcfe4fd95e525ff465dd878adc42c0f19c5ff0 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sat, 7 Dec 2024 21:20:07 +0100 Subject: [PATCH 1/5] Add test task using Woodstox for XML serialization --- gradle/libs.versions.toml | 1 + platform-tests/platform-tests.gradle.kts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index da1b55fd7e50..5b5c6b20f57b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -68,6 +68,7 @@ univocity-parsers = { module = "com.sonofab1rd:univocity-parsers", version = "2. xmlunit-assertj = { module = "org.xmlunit:xmlunit-assertj3", version.ref = "xmlunit" } xmlunit-placeholders = { module = "org.xmlunit:xmlunit-placeholders", version.ref = "xmlunit" } testingAnnotations = { module = "com.gradle:develocity-testing-annotations", version = "2.0.1" } +woodstox = { module = "com.fasterxml.woodstox:woodstox-core", version = "7.0.0" } # Only declared here so Dependabot knows when to update the referenced versions asciidoctorj-pdf = { module = "org.asciidoctor:asciidoctorj-pdf", version.ref = "asciidoctorj-pdf" } diff --git a/platform-tests/platform-tests.gradle.kts b/platform-tests/platform-tests.gradle.kts index 4c1c6960898f..2da1a0dbb08a 100644 --- a/platform-tests/platform-tests.gradle.kts +++ b/platform-tests/platform-tests.gradle.kts @@ -22,6 +22,12 @@ java { } } +val woodstox = configurations.dependencyScope("woodstox") +val woodstoxRuntimeClasspath = configurations.resolvable("woodstoxRuntimeClasspath") { + extendsFrom(configurations.testRuntimeClasspath.get()) + extendsFrom(woodstox.get()) +} + dependencies { // --- Things we are testing -------------------------------------------------- testImplementation(projects.junitPlatformCommons) @@ -61,6 +67,7 @@ dependencies { testRuntimeOnly(libs.groovy4) { because("`ReflectionUtilsTests.findNestedClassesWithInvalidNestedClassFile` needs it") } + woodstox(libs.woodstox) // --- https://openjdk.java.net/projects/code-tools/jmh/ ---------------------- jmh(projects.junitJupiterApi) @@ -108,6 +115,16 @@ tasks { includeTags("junit4") } } + val testWoodstox by registering(Test::class) { + val test by testing.suites.existing(JvmTestSuite::class) + testClassesDirs = files(test.map { it.sources.output.classesDirs }) + classpath = files(sourceSets.main.map { it.output }) + files(test.map { it.sources.output }) + woodstoxRuntimeClasspath.get() + group = JavaBasePlugin.VERIFICATION_GROUP + setIncludes(listOf("**/org/junit/platform/reporting/**")) + } + check { + dependsOn(testWoodstox) + } named(processStarter.compileJavaTaskName).configure { options.release = javaLibrary.testJavaVersion.majorVersion.toInt() } From bd9706613bad11b75285901b248134163a26e2e5 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sat, 7 Dec 2024 21:22:33 +0100 Subject: [PATCH 2/5] Delete duplicate test relying on implementation details --- .../legacy/xml/XmlReportWriterTests.java | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/platform-tests/src/test/java/org/junit/platform/reporting/legacy/xml/XmlReportWriterTests.java b/platform-tests/src/test/java/org/junit/platform/reporting/legacy/xml/XmlReportWriterTests.java index 7f8a324c50de..8c2211ed7474 100644 --- a/platform-tests/src/test/java/org/junit/platform/reporting/legacy/xml/XmlReportWriterTests.java +++ b/platform-tests/src/test/java/org/junit/platform/reporting/legacy/xml/XmlReportWriterTests.java @@ -228,27 +228,6 @@ void escapesInvalidCharactersInSystemPropertiesAndExceptionMessages() throws Exc .contains("AssertionError: expected: but was: "); } - @Test - void doesNotReopenCDataWithinCDataContent() throws Exception { - var uniqueId = engineDescriptor.getUniqueId().append("test", "test"); - engineDescriptor.addChild(new TestDescriptorStub(uniqueId, "test")); - var testPlan = TestPlan.from(Set.of(engineDescriptor), configParams, dummyOutputDirectoryProvider()); - - var reportData = new XmlReportData(testPlan, Clock.systemDefaultZone()); - var assertionError = new AssertionError(""); - reportData.markFinished(testPlan.getTestIdentifier(uniqueId), failed(assertionError)); - Writer assertingWriter = new StringWriter() { - - @SuppressWarnings("NullableProblems") - @Override - public void write(char[] buffer, int off, int len) { - assertThat(new String(buffer, off, len)).doesNotContain("]]> Date: Sat, 7 Dec 2024 21:27:25 +0100 Subject: [PATCH 3/5] Use Unicode replacement character for illegal characters Rather than double-escaped character references. --- .../reporting/legacy/xml/XmlReportWriter.java | 14 +++++++----- .../legacy/xml/XmlReportWriterTests.java | 22 ++++++++++--------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/junit-platform-reporting/src/main/java/org/junit/platform/reporting/legacy/xml/XmlReportWriter.java b/junit-platform-reporting/src/main/java/org/junit/platform/reporting/legacy/xml/XmlReportWriter.java index e81d3abbd10c..6fa11f044fdb 100644 --- a/junit-platform-reporting/src/main/java/org/junit/platform/reporting/legacy/xml/XmlReportWriter.java +++ b/junit-platform-reporting/src/main/java/org/junit/platform/reporting/legacy/xml/XmlReportWriter.java @@ -68,6 +68,8 @@ */ class XmlReportWriter { + static final char ILLEGAL_CHARACTER_REPLACEMENT = '\uFFFD'; + // Using zero-width assertions in the split pattern simplifies the splitting process: All split parts // (including the first and last one) can be used directly, without having to re-add separator characters. private static final Pattern CDATA_SPLIT_PATTERN = Pattern.compile("(?<=]])(?=>)"); @@ -328,16 +330,16 @@ private void writeOutputElement(String elementName, String content, XMLStreamWri } private void writeAttributeSafely(XMLStreamWriter writer, String name, String value) throws XMLStreamException { - writer.writeAttribute(name, escapeIllegalChars(value)); + writer.writeAttribute(name, replaceIllegalCharacters(value)); } private void writeCDataSafely(XMLStreamWriter writer, String data) throws XMLStreamException { - for (String safeDataPart : CDATA_SPLIT_PATTERN.split(escapeIllegalChars(data))) { + for (String safeDataPart : CDATA_SPLIT_PATTERN.split(replaceIllegalCharacters(data))) { writer.writeCData(safeDataPart); } } - static String escapeIllegalChars(String text) { + static String replaceIllegalCharacters(String text) { if (text.codePoints().allMatch(XmlReportWriter::isAllowedXmlCharacter)) { return text; } @@ -346,14 +348,14 @@ static String escapeIllegalChars(String text) { if (isAllowedXmlCharacter(codePoint)) { result.appendCodePoint(codePoint); } - else { // use a Character Reference (cf. https://www.w3.org/TR/xml/#NT-CharRef) - result.append("&#").append(codePoint).append(';'); + else { + result.append(ILLEGAL_CHARACTER_REPLACEMENT); } }); return result.toString(); } - private static boolean isAllowedXmlCharacter(int codePoint) { + static boolean isAllowedXmlCharacter(int codePoint) { // source: https://www.w3.org/TR/xml/#charsets return codePoint == 0x9 // || codePoint == 0xA // diff --git a/platform-tests/src/test/java/org/junit/platform/reporting/legacy/xml/XmlReportWriterTests.java b/platform-tests/src/test/java/org/junit/platform/reporting/legacy/xml/XmlReportWriterTests.java index 8c2211ed7474..5e1ed3504226 100644 --- a/platform-tests/src/test/java/org/junit/platform/reporting/legacy/xml/XmlReportWriterTests.java +++ b/platform-tests/src/test/java/org/junit/platform/reporting/legacy/xml/XmlReportWriterTests.java @@ -21,6 +21,7 @@ import static org.junit.platform.launcher.LauncherConstants.STDOUT_REPORT_ENTRY_KEY; import static org.junit.platform.launcher.core.OutputDirectoryProviders.dummyOutputDirectoryProvider; import static org.junit.platform.reporting.legacy.xml.XmlReportAssertions.assertValidAccordingToJenkinsSchema; +import static org.junit.platform.reporting.legacy.xml.XmlReportWriter.ILLEGAL_CHARACTER_REPLACEMENT; import static org.mockito.Mockito.mock; import java.io.StringReader; @@ -220,29 +221,30 @@ void escapesInvalidCharactersInSystemPropertiesAndExceptionMessages() throws Exc assertValidAccordingToJenkinsSchema(testsuite.document()); assertThat(testsuite.find("property").matchAttr("name", "foo\\.bar").attr("value")) // - .isEqualTo(""); + .isEqualTo(String.valueOf(ILLEGAL_CHARACTER_REPLACEMENT)); var failure = testsuite.find("failure"); assertThat(failure.attr("message")) // - .isEqualTo("expected: but was: "); + .isEqualTo("expected: but was: "); assertThat(failure.text()) // - .contains("AssertionError: expected: but was: "); + .contains("AssertionError: expected: but was: "); } - @ParameterizedTest(name = "{index}") + @ParameterizedTest(name = "[{index}]") @MethodSource("stringPairs") - void escapesIllegalChars(String input, String output) { - assertEquals(output, XmlReportWriter.escapeIllegalChars(input)); + void replacesIllegalCharacters(String input, String output) { + assertEquals(output, XmlReportWriter.replaceIllegalCharacters(input)); } static Stream stringPairs() { return Stream.of( // - arguments("\0", "�"), // - arguments("\1", ""), // + arguments("\0", String.valueOf(ILLEGAL_CHARACTER_REPLACEMENT)), // + arguments("\1", String.valueOf(ILLEGAL_CHARACTER_REPLACEMENT)), // arguments("\t", "\t"), // arguments("\r", "\r"), // arguments("\n", "\n"), // - arguments("\u001f", ""), // - arguments("\u0020", "\u0020"), // + arguments("\u001f", String.valueOf(ILLEGAL_CHARACTER_REPLACEMENT)), // + arguments("✅", "✅"), // + arguments(" ", " "), // arguments("foo!", "foo!"), // arguments("\uD801\uDC00", "\uD801\uDC00") // ); From 92e1ffeb28c30067b1660c7a89d64d7fb36b9bca Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sat, 7 Dec 2024 21:30:06 +0100 Subject: [PATCH 4/5] Add test for whitespace escaping --- .../legacy/xml/XmlReportWriterTests.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/platform-tests/src/test/java/org/junit/platform/reporting/legacy/xml/XmlReportWriterTests.java b/platform-tests/src/test/java/org/junit/platform/reporting/legacy/xml/XmlReportWriterTests.java index 5e1ed3504226..3f43326329cd 100644 --- a/platform-tests/src/test/java/org/junit/platform/reporting/legacy/xml/XmlReportWriterTests.java +++ b/platform-tests/src/test/java/org/junit/platform/reporting/legacy/xml/XmlReportWriterTests.java @@ -10,6 +10,7 @@ package org.junit.platform.reporting.legacy.xml; +import static java.util.stream.Collectors.joining; import static org.assertj.core.api.Assertions.assertThat; import static org.joox.JOOX.$; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -30,6 +31,7 @@ import java.time.Clock; import java.util.Map; import java.util.Set; +import java.util.stream.IntStream; import java.util.stream.Stream; import org.joox.Match; @@ -235,6 +237,31 @@ void replacesIllegalCharacters(String input, String output) { assertEquals(output, XmlReportWriter.replaceIllegalCharacters(input)); } + @Test + void writesValidXmlForExceptionMessagesContainingLineBreaks() throws Exception { + var uniqueId = engineDescriptor.getUniqueId().append("test", "test"); + engineDescriptor.addChild(new TestDescriptorStub(uniqueId, "test")); + var testPlan = TestPlan.from(Set.of(engineDescriptor), configParams, dummyOutputDirectoryProvider()); + + var allWhitespaceCharacters = IntStream.range(0, 0x10000) // + .filter(Character::isWhitespace) // + .filter(XmlReportWriter::isAllowedXmlCharacter) // + .mapToObj(Character::toString) // + .collect(joining()); + + var message = "a" + allWhitespaceCharacters + " b<&>"; + var reportData = new XmlReportData(testPlan, Clock.systemDefaultZone()); + var assertionError = new AssertionError(message); + reportData.markFinished(testPlan.getTestIdentifier(uniqueId), failed(assertionError)); + + var testsuite = writeXmlReport(testPlan, reportData); + + assertValidAccordingToJenkinsSchema(testsuite.document()); + + var attributeValue = testsuite.find("failure").attr("message"); + assertThat(attributeValue).isEqualTo(message); + } + static Stream stringPairs() { return Stream.of( // arguments("\0", String.valueOf(ILLEGAL_CHARACTER_REPLACEMENT)), // From 355eaf9573250a459d1460c2745f7661dee74d94 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sun, 8 Dec 2024 13:08:11 +0100 Subject: [PATCH 5/5] Implement workaround for escaping whitespace chars in attribute values While XML attribute values may contain whitespace such as line breaks, the XML spec [1] dictates that XML processors must replace them with spaces which causes downstream tools to misrepresent the original value. [1] w3.org/TR/xml#AVNormalize Resolves #4174. --- .../release-notes/release-notes-5.11.4.adoc | 5 +- .../reporting/legacy/xml/XmlReportWriter.java | 526 +++++++++++------- 2 files changed, 341 insertions(+), 190 deletions(-) diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-5.11.4.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-5.11.4.adoc index ee08a53eed41..1e2d0eab31cc 100644 --- a/documentation/src/docs/asciidoc/release-notes/release-notes-5.11.4.adoc +++ b/documentation/src/docs/asciidoc/release-notes/release-notes-5.11.4.adoc @@ -16,7 +16,10 @@ JUnit repository on GitHub. [[release-notes-5.11.4-junit-platform-bug-fixes]] ==== Bug Fixes -* ❓ +* Escape whitespace characters (such as line breaks) in XML attribute values (such as + exception messages) in the legacy XML report generated by the Console Launcher. This + change ensures the resulting XML files can be processed by downstream tools while + preserving whitespace characters. [[release-notes-5.11.4-junit-platform-deprecations-and-breaking-changes]] ==== Deprecations and Breaking Changes diff --git a/junit-platform-reporting/src/main/java/org/junit/platform/reporting/legacy/xml/XmlReportWriter.java b/junit-platform-reporting/src/main/java/org/junit/platform/reporting/legacy/xml/XmlReportWriter.java index 6fa11f044fdb..b9d7ea13aff3 100644 --- a/junit-platform-reporting/src/main/java/org/junit/platform/reporting/legacy/xml/XmlReportWriter.java +++ b/junit-platform-reporting/src/main/java/org/junit/platform/reporting/legacy/xml/XmlReportWriter.java @@ -13,6 +13,7 @@ import static java.text.MessageFormat.format; import static java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME; import static java.util.Collections.emptyList; +import static java.util.Collections.unmodifiableMap; import static java.util.Comparator.naturalOrder; import static java.util.function.Function.identity; import static java.util.stream.Collectors.counting; @@ -30,6 +31,7 @@ import static org.junit.platform.reporting.legacy.xml.XmlReportWriter.AggregatedTestResult.Type.SKIPPED; import static org.junit.platform.reporting.legacy.xml.XmlReportWriter.AggregatedTestResult.Type.SUCCESS; +import java.io.IOException; import java.io.Writer; import java.net.InetAddress; import java.net.UnknownHostException; @@ -38,6 +40,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.EnumSet; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; @@ -70,6 +73,15 @@ class XmlReportWriter { static final char ILLEGAL_CHARACTER_REPLACEMENT = '\uFFFD'; + private static final Map REPLACEMENTS_IN_ATTRIBUTE_VALUES; + static { + Map tmp = new HashMap<>(3); + tmp.put('\n', " "); + tmp.put('\r', " "); + tmp.put('\t', " "); + REPLACEMENTS_IN_ATTRIBUTE_VALUES = unmodifiableMap(tmp); + } + // Using zero-width assertions in the split pattern simplifies the splitting process: All split parts // (including the first and last one) can be used directly, without having to re-add separator characters. private static final Pattern CDATA_SPLIT_PATTERN = Pattern.compile("(?<=]])(?=>)"); @@ -103,239 +115,265 @@ private boolean shouldInclude(TestPlan testPlan, TestIdentifier testIdentifier) private void writeXmlReport(TestIdentifier testIdentifier, Map tests, Writer out) throws XMLStreamException { - XMLOutputFactory factory = XMLOutputFactory.newInstance(); - XMLStreamWriter xmlWriter = factory.createXMLStreamWriter(out); - xmlWriter.writeStartDocument("UTF-8", "1.0"); - newLine(xmlWriter); - writeTestsuite(testIdentifier, tests, xmlWriter); - xmlWriter.writeEndDocument(); - xmlWriter.flush(); - xmlWriter.close(); + new XmlReport(out).write(testIdentifier, tests); } - private void writeTestsuite(TestIdentifier testIdentifier, Map tests, - XMLStreamWriter writer) throws XMLStreamException { + class XmlReport implements AutoCloseable { - // NumberFormat is not thread-safe. Thus, we instantiate it here and pass it to - // writeTestcase instead of using a constant - NumberFormat numberFormat = NumberFormat.getInstance(Locale.US); + private final XMLStreamWriter xml; + private final ReplacingWriter out; - writer.writeStartElement("testsuite"); + XmlReport(Writer out) throws XMLStreamException { + this.out = new ReplacingWriter(out); + XMLOutputFactory factory = XMLOutputFactory.newInstance(); + this.xml = factory.createXMLStreamWriter(this.out); + } - writeSuiteAttributes(testIdentifier, tests.values(), numberFormat, writer); + void write(TestIdentifier testIdentifier, Map tests) + throws XMLStreamException { + xml.writeStartDocument("UTF-8", "1.0"); + newLine(); + writeTestsuite(testIdentifier, tests); + xml.writeEndDocument(); + } - newLine(writer); - writeSystemProperties(writer); + private void writeTestsuite(TestIdentifier testIdentifier, Map tests) + throws XMLStreamException { - for (Entry entry : tests.entrySet()) { - writeTestcase(entry.getKey(), entry.getValue(), numberFormat, writer); - } + // NumberFormat is not thread-safe. Thus, we instantiate it here and pass it to + // writeTestcase instead of using a constant + NumberFormat numberFormat = NumberFormat.getInstance(Locale.US); - writeOutputElement("system-out", formatNonStandardAttributesAsString(testIdentifier), writer); + xml.writeStartElement("testsuite"); - writer.writeEndElement(); - newLine(writer); - } + writeSuiteAttributes(testIdentifier, tests.values(), numberFormat); - private void writeSuiteAttributes(TestIdentifier testIdentifier, Collection testResults, - NumberFormat numberFormat, XMLStreamWriter writer) throws XMLStreamException { + newLine(); + writeSystemProperties(); - writeAttributeSafely(writer, "name", testIdentifier.getDisplayName()); - writeTestCounts(testResults, writer); - writeAttributeSafely(writer, "time", getTime(testIdentifier, numberFormat)); - writeAttributeSafely(writer, "hostname", getHostname().orElse("")); - writeAttributeSafely(writer, "timestamp", ISO_LOCAL_DATE_TIME.format(getCurrentDateTime())); - } + for (Entry entry : tests.entrySet()) { + writeTestcase(entry.getKey(), entry.getValue(), numberFormat); + } - private void writeTestCounts(Collection testResults, XMLStreamWriter writer) - throws XMLStreamException { - Map counts = testResults.stream().map(it -> it.type).collect(groupingBy(identity(), counting())); - long total = counts.values().stream().mapToLong(Long::longValue).sum(); - writeAttributeSafely(writer, "tests", String.valueOf(total)); - writeAttributeSafely(writer, "skipped", counts.getOrDefault(SKIPPED, 0L).toString()); - writeAttributeSafely(writer, "failures", counts.getOrDefault(FAILURE, 0L).toString()); - writeAttributeSafely(writer, "errors", counts.getOrDefault(ERROR, 0L).toString()); - } + writeOutputElement("system-out", formatNonStandardAttributesAsString(testIdentifier)); - private void writeSystemProperties(XMLStreamWriter writer) throws XMLStreamException { - writer.writeStartElement("properties"); - newLine(writer); - Properties systemProperties = System.getProperties(); - for (String propertyName : new TreeSet<>(systemProperties.stringPropertyNames())) { - writer.writeEmptyElement("property"); - writeAttributeSafely(writer, "name", propertyName); - writeAttributeSafely(writer, "value", systemProperties.getProperty(propertyName)); - newLine(writer); - } - writer.writeEndElement(); - newLine(writer); - } + xml.writeEndElement(); + newLine(); + } - private void writeTestcase(TestIdentifier testIdentifier, AggregatedTestResult testResult, - NumberFormat numberFormat, XMLStreamWriter writer) throws XMLStreamException { + private void writeSuiteAttributes(TestIdentifier testIdentifier, Collection testResults, + NumberFormat numberFormat) throws XMLStreamException { - writer.writeStartElement("testcase"); + writeAttributeSafely("name", testIdentifier.getDisplayName()); + writeTestCounts(testResults); + writeAttributeSafely("time", getTime(testIdentifier, numberFormat)); + writeAttributeSafely("hostname", getHostname().orElse("")); + writeAttributeSafely("timestamp", ISO_LOCAL_DATE_TIME.format(getCurrentDateTime())); + } - writeAttributeSafely(writer, "name", getName(testIdentifier)); - writeAttributeSafely(writer, "classname", getClassName(testIdentifier)); - writeAttributeSafely(writer, "time", getTime(testIdentifier, numberFormat)); - newLine(writer); + private void writeTestCounts(Collection testResults) throws XMLStreamException { + Map counts = testResults.stream().map(it -> it.type).collect( + groupingBy(identity(), counting())); + long total = counts.values().stream().mapToLong(Long::longValue).sum(); + writeAttributeSafely("tests", String.valueOf(total)); + writeAttributeSafely("skipped", counts.getOrDefault(SKIPPED, 0L).toString()); + writeAttributeSafely("failures", counts.getOrDefault(FAILURE, 0L).toString()); + writeAttributeSafely("errors", counts.getOrDefault(ERROR, 0L).toString()); + } - writeSkippedOrErrorOrFailureElement(testIdentifier, testResult, writer); + private void writeSystemProperties() throws XMLStreamException { + xml.writeStartElement("properties"); + newLine(); + Properties systemProperties = System.getProperties(); + for (String propertyName : new TreeSet<>(systemProperties.stringPropertyNames())) { + xml.writeEmptyElement("property"); + writeAttributeSafely("name", propertyName); + writeAttributeSafely("value", systemProperties.getProperty(propertyName)); + newLine(); + } + xml.writeEndElement(); + newLine(); + } - List systemOutElements = new ArrayList<>(); - List systemErrElements = new ArrayList<>(); - systemOutElements.add(formatNonStandardAttributesAsString(testIdentifier)); - collectReportEntries(testIdentifier, systemOutElements, systemErrElements); - writeOutputElements("system-out", systemOutElements, writer); - writeOutputElements("system-err", systemErrElements, writer); + private void writeTestcase(TestIdentifier testIdentifier, AggregatedTestResult testResult, + NumberFormat numberFormat) throws XMLStreamException { - writer.writeEndElement(); - newLine(writer); - } + xml.writeStartElement("testcase"); - private String getName(TestIdentifier testIdentifier) { - return testIdentifier.getLegacyReportingName(); - } + writeAttributeSafely("name", getName(testIdentifier)); + writeAttributeSafely("classname", getClassName(testIdentifier)); + writeAttributeSafely("time", getTime(testIdentifier, numberFormat)); + newLine(); - private String getClassName(TestIdentifier testIdentifier) { - return LegacyReportingUtils.getClassName(this.reportData.getTestPlan(), testIdentifier); - } + writeSkippedOrErrorOrFailureElement(testIdentifier, testResult); + + List systemOutElements = new ArrayList<>(); + List systemErrElements = new ArrayList<>(); + systemOutElements.add(formatNonStandardAttributesAsString(testIdentifier)); + collectReportEntries(testIdentifier, systemOutElements, systemErrElements); + writeOutputElements("system-out", systemOutElements); + writeOutputElements("system-err", systemErrElements); + + xml.writeEndElement(); + newLine(); + } - private void writeSkippedOrErrorOrFailureElement(TestIdentifier testIdentifier, AggregatedTestResult testResult, - XMLStreamWriter writer) throws XMLStreamException { + private String getName(TestIdentifier testIdentifier) { + return testIdentifier.getLegacyReportingName(); + } - if (testResult.type == SKIPPED) { - writeSkippedElement(this.reportData.getSkipReason(testIdentifier), writer); + private String getClassName(TestIdentifier testIdentifier) { + return LegacyReportingUtils.getClassName(reportData.getTestPlan(), testIdentifier); } - else { - Map>> throwablesByType = testResult.getThrowablesByType(); - for (Type type : EnumSet.of(FAILURE, ERROR)) { - for (Optional throwable : throwablesByType.getOrDefault(type, emptyList())) { - writeErrorOrFailureElement(type, throwable.orElse(null), writer); + + private void writeSkippedOrErrorOrFailureElement(TestIdentifier testIdentifier, AggregatedTestResult testResult) + throws XMLStreamException { + + if (testResult.type == SKIPPED) { + writeSkippedElement(reportData.getSkipReason(testIdentifier), xml); + } + else { + Map>> throwablesByType = testResult.getThrowablesByType(); + for (Type type : EnumSet.of(FAILURE, ERROR)) { + for (Optional throwable : throwablesByType.getOrDefault(type, emptyList())) { + writeErrorOrFailureElement(type, throwable.orElse(null), xml); + } } } } - } - private void writeSkippedElement(String reason, XMLStreamWriter writer) throws XMLStreamException { - if (isNotBlank(reason)) { - writer.writeStartElement("skipped"); - writeCDataSafely(writer, reason); - writer.writeEndElement(); - } - else { - writer.writeEmptyElement("skipped"); + private void writeSkippedElement(String reason, XMLStreamWriter writer) throws XMLStreamException { + if (isNotBlank(reason)) { + writer.writeStartElement("skipped"); + writeCDataSafely(reason); + writer.writeEndElement(); + } + else { + writer.writeEmptyElement("skipped"); + } + newLine(); } - newLine(writer); - } - private void writeErrorOrFailureElement(Type type, Throwable throwable, XMLStreamWriter writer) - throws XMLStreamException { + private void writeErrorOrFailureElement(Type type, Throwable throwable, XMLStreamWriter writer) + throws XMLStreamException { - String elementName = type == FAILURE ? "failure" : "error"; - if (throwable != null) { - writer.writeStartElement(elementName); - writeFailureAttributesAndContent(throwable, writer); - writer.writeEndElement(); - } - else { - writer.writeEmptyElement(elementName); + String elementName = type == FAILURE ? "failure" : "error"; + if (throwable != null) { + writer.writeStartElement(elementName); + writeFailureAttributesAndContent(throwable); + writer.writeEndElement(); + } + else { + writer.writeEmptyElement(elementName); + } + newLine(); } - newLine(writer); - } - private void writeFailureAttributesAndContent(Throwable throwable, XMLStreamWriter writer) - throws XMLStreamException { + private void writeFailureAttributesAndContent(Throwable throwable) throws XMLStreamException { - if (throwable.getMessage() != null) { - writeAttributeSafely(writer, "message", throwable.getMessage()); + if (throwable.getMessage() != null) { + writeAttributeSafely("message", throwable.getMessage()); + } + writeAttributeSafely("type", throwable.getClass().getName()); + writeCDataSafely(readStackTrace(throwable)); } - writeAttributeSafely(writer, "type", throwable.getClass().getName()); - writeCDataSafely(writer, readStackTrace(throwable)); - } - private void collectReportEntries(TestIdentifier testIdentifier, List systemOutElements, - List systemErrElements) { - List entries = this.reportData.getReportEntries(testIdentifier); - if (!entries.isEmpty()) { - List systemOutElementsForCapturedOutput = new ArrayList<>(); - StringBuilder formattedReportEntries = new StringBuilder(); - for (int i = 0; i < entries.size(); i++) { - ReportEntry reportEntry = entries.get(i); - Map keyValuePairs = new LinkedHashMap<>(reportEntry.getKeyValuePairs()); - removeIfPresentAndAddAsSeparateElement(keyValuePairs, STDOUT_REPORT_ENTRY_KEY, - systemOutElementsForCapturedOutput); - removeIfPresentAndAddAsSeparateElement(keyValuePairs, STDERR_REPORT_ENTRY_KEY, systemErrElements); - if (!keyValuePairs.isEmpty()) { - buildReportEntryDescription(reportEntry.getTimestamp(), keyValuePairs, i + 1, - formattedReportEntries); + private void collectReportEntries(TestIdentifier testIdentifier, List systemOutElements, + List systemErrElements) { + List entries = reportData.getReportEntries(testIdentifier); + if (!entries.isEmpty()) { + List systemOutElementsForCapturedOutput = new ArrayList<>(); + StringBuilder formattedReportEntries = new StringBuilder(); + for (int i = 0; i < entries.size(); i++) { + ReportEntry reportEntry = entries.get(i); + Map keyValuePairs = new LinkedHashMap<>(reportEntry.getKeyValuePairs()); + removeIfPresentAndAddAsSeparateElement(keyValuePairs, STDOUT_REPORT_ENTRY_KEY, + systemOutElementsForCapturedOutput); + removeIfPresentAndAddAsSeparateElement(keyValuePairs, STDERR_REPORT_ENTRY_KEY, systemErrElements); + if (!keyValuePairs.isEmpty()) { + buildReportEntryDescription(reportEntry.getTimestamp(), keyValuePairs, i + 1, + formattedReportEntries); + } } + systemOutElements.add(formattedReportEntries.toString().trim()); + systemOutElements.addAll(systemOutElementsForCapturedOutput); } - systemOutElements.add(formattedReportEntries.toString().trim()); - systemOutElements.addAll(systemOutElementsForCapturedOutput); } - } - private void removeIfPresentAndAddAsSeparateElement(Map keyValuePairs, String key, - List elements) { - String value = keyValuePairs.remove(key); - if (value != null) { - elements.add(value); + private void removeIfPresentAndAddAsSeparateElement(Map keyValuePairs, String key, + List elements) { + String value = keyValuePairs.remove(key); + if (value != null) { + elements.add(value); + } } - } - private void buildReportEntryDescription(LocalDateTime timestamp, Map keyValuePairs, - int entryNumber, StringBuilder result) { - result.append( - format("Report Entry #{0} (timestamp: {1})\n", entryNumber, ISO_LOCAL_DATE_TIME.format(timestamp))); - keyValuePairs.forEach((key, value) -> result.append(format("\t- {0}: {1}\n", key, value))); - } + private void buildReportEntryDescription(LocalDateTime timestamp, Map keyValuePairs, + int entryNumber, StringBuilder result) { + result.append( + format("Report Entry #{0} (timestamp: {1})\n", entryNumber, ISO_LOCAL_DATE_TIME.format(timestamp))); + keyValuePairs.forEach((key, value) -> result.append(format("\t- {0}: {1}\n", key, value))); + } - private String getTime(TestIdentifier testIdentifier, NumberFormat numberFormat) { - return numberFormat.format(this.reportData.getDurationInSeconds(testIdentifier)); - } + private String getTime(TestIdentifier testIdentifier, NumberFormat numberFormat) { + return numberFormat.format(reportData.getDurationInSeconds(testIdentifier)); + } - private Optional getHostname() { - try { - return Optional.ofNullable(InetAddress.getLocalHost().getHostName()); + private Optional getHostname() { + try { + return Optional.ofNullable(InetAddress.getLocalHost().getHostName()); + } + catch (UnknownHostException e) { + return Optional.empty(); + } } - catch (UnknownHostException e) { - return Optional.empty(); + + private LocalDateTime getCurrentDateTime() { + return LocalDateTime.now(reportData.getClock()).withNano(0); } - } - private LocalDateTime getCurrentDateTime() { - return LocalDateTime.now(this.reportData.getClock()).withNano(0); - } + private String formatNonStandardAttributesAsString(TestIdentifier testIdentifier) { + return "unique-id: " + testIdentifier.getUniqueId() // + + "\ndisplay-name: " + testIdentifier.getDisplayName(); + } - private String formatNonStandardAttributesAsString(TestIdentifier testIdentifier) { - return "unique-id: " + testIdentifier.getUniqueId() // - + "\ndisplay-name: " + testIdentifier.getDisplayName(); - } + private void writeOutputElements(String elementName, List elements) throws XMLStreamException { + for (String content : elements) { + writeOutputElement(elementName, content); + } + } - private void writeOutputElements(String elementName, List elements, XMLStreamWriter writer) - throws XMLStreamException { - for (String content : elements) { - writeOutputElement(elementName, content, writer); + private void writeOutputElement(String elementName, String content) throws XMLStreamException { + xml.writeStartElement(elementName); + writeCDataSafely("\n" + content + "\n"); + xml.writeEndElement(); + newLine(); } - } - private void writeOutputElement(String elementName, String content, XMLStreamWriter writer) - throws XMLStreamException { - writer.writeStartElement(elementName); - writeCDataSafely(writer, "\n" + content + "\n"); - writer.writeEndElement(); - newLine(writer); - } + private void writeAttributeSafely(String name, String value) throws XMLStreamException { + // Workaround for XMLStreamWriter implementations that don't escape + // '\n', '\r', and '\t' characters in attribute values + xml.flush(); + out.setWhitespaceReplacingEnabled(true); + xml.writeAttribute(name, replaceIllegalCharacters(value)); + xml.flush(); + out.setWhitespaceReplacingEnabled(false); + } - private void writeAttributeSafely(XMLStreamWriter writer, String name, String value) throws XMLStreamException { - writer.writeAttribute(name, replaceIllegalCharacters(value)); - } + private void writeCDataSafely(String data) throws XMLStreamException { + for (String safeDataPart : CDATA_SPLIT_PATTERN.split(replaceIllegalCharacters(data))) { + xml.writeCData(safeDataPart); + } + } - private void writeCDataSafely(XMLStreamWriter writer, String data) throws XMLStreamException { - for (String safeDataPart : CDATA_SPLIT_PATTERN.split(replaceIllegalCharacters(data))) { - writer.writeCData(safeDataPart); + private void newLine() throws XMLStreamException { + xml.writeCharacters("\n"); + } + + @Override + public void close() throws XMLStreamException { + xml.flush(); + xml.close(); } } @@ -365,15 +403,6 @@ static boolean isAllowedXmlCharacter(int codePoint) { || (codePoint >= 0x10000 && codePoint <= 0x10FFFF); } - private void newLine(XMLStreamWriter xmlWriter) throws XMLStreamException { - xmlWriter.writeCharacters("\n"); - } - - private static boolean isFailure(TestExecutionResult result) { - Optional throwable = result.getThrowable(); - return throwable.isPresent() && throwable.get() instanceof AssertionError; - } - static class AggregatedTestResult { private static final AggregatedTestResult SKIPPED_RESULT = new AggregatedTestResult(SKIPPED, emptyList()); @@ -413,6 +442,125 @@ private static Type from(TestExecutionResult executionResult) { } return SUCCESS; } + + private static boolean isFailure(TestExecutionResult result) { + Optional throwable = result.getThrowable(); + return throwable.isPresent() && throwable.get() instanceof AssertionError; + } + } + } + + private static class ReplacingWriter extends Writer { + + private final Writer delegate; + private boolean whitespaceReplacingEnabled; + + ReplacingWriter(Writer delegate) { + this.delegate = delegate; + } + + void setWhitespaceReplacingEnabled(boolean whitespaceReplacingEnabled) { + this.whitespaceReplacingEnabled = whitespaceReplacingEnabled; + } + + @Override + public void write(char[] cbuf, int off, int len) throws IOException { + if (!whitespaceReplacingEnabled) { + delegate.write(cbuf, off, len); + return; + } + StringBuilder stringBuilder = new StringBuilder(len * 2); + for (int i = off; i < off + len; i++) { + char c = cbuf[i]; + String replacement = REPLACEMENTS_IN_ATTRIBUTE_VALUES.get(c); + if (replacement != null) { + stringBuilder.append(replacement); + } + else { + stringBuilder.append(c); + } + } + delegate.write(stringBuilder.toString()); + } + + @Override + public void write(int c) throws IOException { + if (whitespaceReplacingEnabled) { + super.write(c); + } + else { + delegate.write(c); + } + } + + @Override + public void write(char[] cbuf) throws IOException { + if (whitespaceReplacingEnabled) { + super.write(cbuf); + } + else { + delegate.write(cbuf); + } + } + + @Override + public void write(String str) throws IOException { + if (whitespaceReplacingEnabled) { + super.write(str); + } + else { + delegate.write(str); + } + } + + @Override + public void write(String str, int off, int len) throws IOException { + if (whitespaceReplacingEnabled) { + super.write(str, off, len); + } + else { + delegate.write(str, off, len); + } + } + + @Override + public Writer append(CharSequence csq) throws IOException { + if (whitespaceReplacingEnabled) { + return super.append(csq); + } + else { + return delegate.append(csq); + } + } + + @Override + public Writer append(CharSequence csq, int start, int end) throws IOException { + if (whitespaceReplacingEnabled) { + return super.append(csq, start, end); + } + else { + return delegate.append(csq, start, end); + } + } + + @Override + public Writer append(char c) throws IOException { + if (whitespaceReplacingEnabled) { + return super.append(c); + } + else { + return delegate.append(c); + } + } + + @Override + public void flush() throws IOException { + delegate.flush(); + } + + @Override + public void close() throws IOException { + delegate.close(); } }