Repository: junit-team/junit5 (language: java)

Issue:
Legacy XML reports should preserve new line characters in message attributes
Hello!

After switching test execution from using gradle with junit platform to console launcher I noticed that 
value of message attribute in JUnit xml report loosing new line characters.
e.g.
Using gradle xml :
```xml
<error message="line 1&#10; line 2&#10; line 3&#10;"...
```
Using Console launcher xml:
```xml
<error message="
line 1
line 2
line 3
"
```

## Steps to reproduce

1. Run tests using gradle with junit platform. Throw multiline exception in test.
2. Run tests using console launcher, throw multiline exception in test.

## Context

`org.junit.platform:junit-platform-console-standalone:1.11.3`
`org.junit.jupiter:junit-jupiter-engine:5.11.3`
`JRE 23`

As I understand console launcher uses new open/xml report generator and gradle platform launcher uses legacy report generator, right?




Discussion / hints:
@gtach2o I'm not sure what you mean. In your example, there are newline characters in the attribute value. While Gradle seems to replace `\n` using `&#10;`, the Console Launcher includes them as-is. This is well-formed XML. Does it cause any issues downstream?
> [@gtach2o](https://github.com/gtach2o) I'm not sure what you mean. In your example, there are newline characters in the attribute value. While Gradle seems to replace `\n` using `&#10;`, the Console Launcher includes them as-is. This is well-formed XML. Does it cause any issues downstream?

Well, that xml is processed by some other services and message value extracted with `javax.xml.bind.annotation.XmlAttribute` and stored in DB, to be later shown on html dashboard.
There is no problem with tests executed with gradle and their message is displayed correctly in the browser, with newlines. The message from tests executed with the console launcher does not displayed with line breaks. It's just one long string because there is no `\n` in the original value. 

> While Gradle seems to replace `\n` using `&#10;`  

Can console launcher do this as well? 
I've looked into it in #4180. However, the closest I can get with the current STaX-based infrastructure is this:

```xml
<failure message="line 1&amp;#10; line 2">...</failure>
```

That's because when passing `"line 1&#10; line 2"` to `javax.xml.stream.XMLStreamWriter#writeAttribute(String, String)` it will replace all `&` characters with `&amp;`.

@gtach2o Could you please check whether that kind of "double-escaping" works with your `XmlAttribute`-based processing?
> That's because when passing `"line 1&#10; line 2"` to `javax.xml.stream.XMLStreamWriter#writeAttribute(String, String)` it will replace all `&` characters with `&amp;`.

I think we only need to replace `\n` with `&#10;`. 
Thrown exception (error or failure) contains `\n` not `&#10;` (it is usually just a text with a stacktrace) after processing with `XMLStreamWriter` xml will have 
```xml
<failure message="line 1&#10; line 2&#10;">...</failure>
```

One more note: if message contains quotes `"` they already escaped with `&quot;` so there is some escaping mechanism applied. Just need add `&#10;` for `\n`
How gradle with
```groovy
test {
    useJUnitPlatform()
}
```
does it?
Maybe some double-escape avoiding mechanism can be applied 
```java
String escapeXmlAttribute(String input) {
        StringBuilder result = new StringBuilder();
        int length = input.length();
        for (int i = 0; i < length; i++) {
            char c = input.charAt(i);
            if (c == '&' && input.startsWith("&#10;", i)) {
               result.append("&#10;");
                i += 4;
            } else if (c == '\n') {
              result.append("&#10;");
            } else {                

result.append(StringEscapeUtils.escapeXml10(String.valueOf(c)));
            }
        }
        return result.toString();
}
```
or something like that, but to not reinvent the wheel would be better to look up how it s done during `useJUnitPlatform()`

Fix patch:
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/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/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..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;
@@ -68,6 +71,17 @@
  */
 class XmlReportWriter {
 
+	static final char ILLEGAL_CHARACTER_REPLACEMENT = '\uFFFD';
+
+	private static final Map<Character, String> REPLACEMENTS_IN_ATTRIBUTE_VALUES;
+	static {
+		Map<Character, String> tmp = new HashMap<>(3);
+		tmp.put('\n', "&#10;");
+		tmp.put('\r', "&#13;");
+		tmp.put('\t', "&#9;");
+		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("(?<=]])(?=>)");
@@ -101,243 +115,269 @@ private boolean shouldInclude(TestPlan testPlan, TestIdentifier testIdentifier)
 	private void writeXmlReport(TestIdentifier testIdentifier, Map<TestIdentifier, AggregatedTestResult> 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<TestIdentifier, AggregatedTestResult> 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<TestIdentifier, AggregatedTestResult> 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<TestIdentifier, AggregatedTestResult> tests)
+				throws XMLStreamException {
 
-		for (Entry<TestIdentifier, AggregatedTestResult> 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<AggregatedTestResult> 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("<unknown host>"));
-		writeAttributeSafely(writer, "timestamp", ISO_LOCAL_DATE_TIME.format(getCurrentDateTime()));
-	}
+			for (Entry<TestIdentifier, AggregatedTestResult> entry : tests.entrySet()) {
+				writeTestcase(entry.getKey(), entry.getValue(), numberFormat);
+			}
 
-	private void writeTestCounts(Collection<AggregatedTestResult> testResults, XMLStreamWriter writer)
-			throws XMLStreamException {
-		Map<Type, Long> 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 writeSuiteAttributes(TestIdentifier testIdentifier, Collection<AggregatedTestResult> testResults,
+				NumberFormat numberFormat) throws XMLStreamException {
+
+			writeAttributeSafely("name", testIdentifier.getDisplayName());
+			writeTestCounts(testResults);
+			writeAttributeSafely("time", getTime(testIdentifier, numberFormat));
+			writeAttributeSafely("hostname", getHostname().orElse("<unknown host>"));
+			writeAttributeSafely("timestamp", ISO_LOCAL_DATE_TIME.format(getCurrentDateTime()));
+		}
 
-	private void writeTestcase(TestIdentifier testIdentifier, AggregatedTestResult testResult,
-			NumberFormat numberFormat, XMLStreamWriter writer) throws XMLStreamException {
+		private void writeTestCounts(Collection<AggregatedTestResult> testResults) throws XMLStreamException {
+			Map<Type, Long> 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());
+		}
 
-		writer.writeStartElement("testcase");
+		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();
+		}
 
-		writeAttributeSafely(writer, "name", getName(testIdentifier));
-		writeAttributeSafely(writer, "classname", getClassName(testIdentifier));
-		writeAttributeSafely(writer, "time", getTime(testIdentifier, numberFormat));
-		newLine(writer);
+		private void writeTestcase(TestIdentifier testIdentifier, AggregatedTestResult testResult,
+				NumberFormat numberFormat) throws XMLStreamException {
 
-		writeSkippedOrErrorOrFailureElement(testIdentifier, testResult, writer);
+			xml.writeStartElement("testcase");
 
-		List<String> systemOutElements = new ArrayList<>();
-		List<String> systemErrElements = new ArrayList<>();
-		systemOutElements.add(formatNonStandardAttributesAsString(testIdentifier));
-		collectReportEntries(testIdentifier, systemOutElements, systemErrElements);
-		writeOutputElements("system-out", systemOutElements, writer);
-		writeOutputElements("system-err", systemErrElements, writer);
+			writeAttributeSafely("name", getName(testIdentifier));
+			writeAttributeSafely("classname", getClassName(testIdentifier));
+			writeAttributeSafely("time", getTime(testIdentifier, numberFormat));
+			newLine();
 
-		writer.writeEndElement();
-		newLine(writer);
-	}
+			writeSkippedOrErrorOrFailureElement(testIdentifier, testResult);
 
-	private String getName(TestIdentifier testIdentifier) {
-		return testIdentifier.getLegacyReportingName();
-	}
+			List<String> systemOutElements = new ArrayList<>();
+			List<String> systemErrElements = new ArrayList<>();
+			systemOutElements.add(formatNonStandardAttributesAsString(testIdentifier));
+			collectReportEntries(testIdentifier, systemOutElements, systemErrElements);
+			writeOutputElements("system-out", systemOutElements);
+			writeOutputElements("system-err", systemErrElements);
 
-	private String getClassName(TestIdentifier testIdentifier) {
-		return LegacyReportingUtils.getClassName(this.reportData.getTestPlan(), testIdentifier);
-	}
+			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<Type, List<Optional<Throwable>>> throwablesByType = testResult.getThrowablesByType();
-			for (Type type : EnumSet.of(FAILURE, ERROR)) {
-				for (Optional<Throwable> 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<Type, List<Optional<Throwable>>> throwablesByType = testResult.getThrowablesByType();
+				for (Type type : EnumSet.of(FAILURE, ERROR)) {
+					for (Optional<Throwable> 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<String> systemOutElements,
-			List<String> systemErrElements) {
-		List<ReportEntry> entries = this.reportData.getReportEntries(testIdentifier);
-		if (!entries.isEmpty()) {
-			List<String> systemOutElementsForCapturedOutput = new ArrayList<>();
-			StringBuilder formattedReportEntries = new StringBuilder();
-			for (int i = 0; i < entries.size(); i++) {
-				ReportEntry reportEntry = entries.get(i);
-				Map<String, String> 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<String> systemOutElements,
+				List<String> systemErrElements) {
+			List<ReportEntry> entries = reportData.getReportEntries(testIdentifier);
+			if (!entries.isEmpty()) {
+				List<String> systemOutElementsForCapturedOutput = new ArrayList<>();
+				StringBuilder formattedReportEntries = new StringBuilder();
+				for (int i = 0; i < entries.size(); i++) {
+					ReportEntry reportEntry = entries.get(i);
+					Map<String, String> 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<String, String> keyValuePairs, String key,
-			List<String> elements) {
-		String value = keyValuePairs.remove(key);
-		if (value != null) {
-			elements.add(value);
+		private void removeIfPresentAndAddAsSeparateElement(Map<String, String> keyValuePairs, String key,
+				List<String> elements) {
+			String value = keyValuePairs.remove(key);
+			if (value != null) {
+				elements.add(value);
+			}
 		}
-	}
 
-	private void buildReportEntryDescription(LocalDateTime timestamp, Map<String, String> 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<String, String> 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<String> getHostname() {
-		try {
-			return Optional.ofNullable(InetAddress.getLocalHost().getHostName());
+		private Optional<String> 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<String> elements) throws XMLStreamException {
+			for (String content : elements) {
+				writeOutputElement(elementName, content);
+			}
+		}
 
-	private void writeOutputElements(String elementName, List<String> 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, escapeIllegalChars(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(escapeIllegalChars(data))) {
-			writer.writeCData(safeDataPart);
+		private void newLine() throws XMLStreamException {
+			xml.writeCharacters("\n");
+		}
+
+		@Override
+		public void close() throws XMLStreamException {
+			xml.flush();
+			xml.close();
 		}
 	}
 
-	static String escapeIllegalChars(String text) {
+	static String replaceIllegalCharacters(String text) {
 		if (text.codePoints().allMatch(XmlReportWriter::isAllowedXmlCharacter)) {
 			return text;
 		}
@@ -346,14 +386,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 //
@@ -363,15 +403,6 @@ private 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> throwable = result.getThrowable();
-		return throwable.isPresent() && throwable.get() instanceof AssertionError;
-	}
-
 	static class AggregatedTestResult {
 
 		private static final AggregatedTestResult SKIPPED_RESULT = new AggregatedTestResult(SKIPPED, emptyList());
@@ -411,6 +442,125 @@ private static Type from(TestExecutionResult executionResult) {
 				}
 				return SUCCESS;
 			}
+
+			private static boolean isFailure(TestExecutionResult result) {
+				Optional<Throwable> 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();
 		}
 	}
 


Test patch:
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<JavaCompile>(processStarter.compileJavaTaskName).configure {
 		options.release = javaLibrary.testJavaVersion.majorVersion.toInt()
 	}
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..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;
@@ -21,6 +22,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;
@@ -29,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;
@@ -220,50 +223,55 @@ void escapesInvalidCharactersInSystemPropertiesAndExceptionMessages() throws Exc
 
 		assertValidAccordingToJenkinsSchema(testsuite.document());
 		assertThat(testsuite.find("property").matchAttr("name", "foo\\.bar").attr("value")) //
-				.isEqualTo("&#1;");
+				.isEqualTo(String.valueOf(ILLEGAL_CHARACTER_REPLACEMENT));
 		var failure = testsuite.find("failure");
 		assertThat(failure.attr("message")) //
-				.isEqualTo("expected: <A> but was: <B&#0;>");
+				.isEqualTo("expected: <A> but was: <B" + ILLEGAL_CHARACTER_REPLACEMENT + ">");
 		assertThat(failure.text()) //
-				.contains("AssertionError: expected: <A> but was: <B&#0;>");
+				.contains("AssertionError: expected: <A> but was: <B" + ILLEGAL_CHARACTER_REPLACEMENT + ">");
+	}
+
+	@ParameterizedTest(name = "[{index}]")
+	@MethodSource("stringPairs")
+	void replacesIllegalCharacters(String input, String output) {
+		assertEquals(output, XmlReportWriter.replaceIllegalCharacters(input));
 	}
 
 	@Test
-	void doesNotReopenCDataWithinCDataContent() throws Exception {
+	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("<foo><![CDATA[bar]]></foo>");
+		var assertionError = new AssertionError(message);
 		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("]]><![CDATA[");
-			}
-		};
+		var testsuite = writeXmlReport(testPlan, reportData);
 
-		writeXmlReport(testPlan, reportData, assertingWriter);
-	}
+		assertValidAccordingToJenkinsSchema(testsuite.document());
 
-	@ParameterizedTest(name = "{index}")
-	@MethodSource("stringPairs")
-	void escapesIllegalChars(String input, String output) {
-		assertEquals(output, XmlReportWriter.escapeIllegalChars(input));
+		var attributeValue = testsuite.find("failure").attr("message");
+		assertThat(attributeValue).isEqualTo(message);
 	}
 
 	static Stream<Arguments> stringPairs() {
 		return Stream.of( //
-			arguments("\0", "&#0;"), //
-			arguments("\1", "&#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", "&#31;"), //
-			arguments("\u0020", "\u0020"), //
+			arguments("\u001f", String.valueOf(ILLEGAL_CHARACTER_REPLACEMENT)), //
+			arguments("✅", "✅"), //
+			arguments(" ", " "), //
 			arguments("foo!", "foo!"), //
 			arguments("\uD801\uDC00", "\uD801\uDC00") //
 		);