diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-6.0.0-RC1.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-6.0.0-RC1.adoc index 01591fa288da..bceaa8c724c6 100644 --- a/documentation/src/docs/asciidoc/release-notes/release-notes-6.0.0-RC1.adoc +++ b/documentation/src/docs/asciidoc/release-notes/release-notes-6.0.0-RC1.adoc @@ -32,6 +32,11 @@ repository on GitHub. * Convention-based conversion in `ConversionSupport` now supports factory methods and factory constructors that accept a single `CharSequence` argument in addition to the existing support for factories that accept a single `String` argument. +* Non-printable control characters in display names are now replaced with alternative + representations. For example, `\n` is replaced with ``. This applies to all display + names in JUnit Jupiter, `@SuiteDisplayName`, and any other test engines that subclass + `AbstractTestDescriptor`. Please refer to the + <<../user-guide/index.adoc#writing-tests-display-names, User Guide>> for details. [[release-notes-6.0.0-RC1-junit-jupiter]] @@ -64,6 +69,9 @@ repository on GitHub. Fallback String-to-Object Conversion>> for parameterized tests now supports factory methods and factory constructors that accept a single `CharSequence` argument in addition to the existing support for factories that accept a single `String` argument. +* Non-printable control characters in display names are now replaced with alternative + representations. Please refer to the + <<../user-guide/index.adoc#writing-tests-display-names, User Guide>> for details. [[release-notes-6.0.0-RC1-junit-vintage]] diff --git a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc index 6252c77d010b..32a533d3b372 100644 --- a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc +++ b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc @@ -307,6 +307,32 @@ by test runners and IDEs. include::{testDir}/example/DisplayNameDemo.java[tags=user_guide] ---- +[NOTE] +==== +Control characters in text-based arguments in display names for parameterized tests are +escaped by default. See <> +for details. + +Any remaining ISO control characters in a display name will be replaced as follows. + +[cols="25%,15%,60%"] +|=== +| Original | Replacement | Description + +| ```\r``` +| `````` +| Textual representation of a carriage return + +| ```\n``` +| `````` +| Textual representation of a line feed + +| Other control character +| ```�``` +| Unicode replacement character (U+FFFD) +|=== +==== + [[writing-tests-display-name-generator]] ==== Display Name Generators @@ -2778,8 +2804,7 @@ is considered text. A `CharSequence` is wrapped in double quotes (`"`), and a `C is wrapped in single quotes (`'`). Special characters will be escaped in the quoted text. For example, carriage returns and -line feeds will be escaped as `\\r` and `\\n`, respectively. In addition, any ISO control -character will be represented as a question mark (`?`) in the quoted text. +line feeds will be escaped as `\\r` and `\\n`, respectively. [TIP] ==== diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/QuoteUtils.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/QuoteUtils.java index a280daa9712e..474bd333b188 100644 --- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/QuoteUtils.java +++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/QuoteUtils.java @@ -48,7 +48,7 @@ private static String escape(char ch, boolean withinString) { case '\t' -> "\\t"; case '\r' -> "\\r"; case '\n' -> "\\n"; - default -> Character.isISOControl(ch) ? "?" : String.valueOf(ch); + default -> String.valueOf(ch); }; } diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/descriptor/AbstractTestDescriptor.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/descriptor/AbstractTestDescriptor.java index 645b835eb95b..a00fc3e92af9 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/descriptor/AbstractTestDescriptor.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/descriptor/AbstractTestDescriptor.java @@ -41,6 +41,12 @@ @API(status = STABLE, since = "1.0") public abstract class AbstractTestDescriptor implements TestDescriptor { + /** + * The Unicode replacement character, often displayed as a black diamond with + * a white question mark in it: {@value} + */ + private static final String UNICODE_REPLACEMENT_CHARACTER = "\uFFFD"; + private final UniqueId uniqueId; private final String displayName; @@ -66,6 +72,10 @@ public abstract class AbstractTestDescriptor implements TestDescriptor { * Create a new {@code AbstractTestDescriptor} with the supplied * {@link UniqueId} and display name. * + *

As of JUnit 6.0, ISO control characters in the provided display name + * will be replaced. See {@link #AbstractTestDescriptor(UniqueId, String, TestSource)} + * for details. + * * @param uniqueId the unique ID of this {@code TestDescriptor}; never * {@code null} * @param displayName the display name for this {@code TestDescriptor}; @@ -80,6 +90,17 @@ protected AbstractTestDescriptor(UniqueId uniqueId, String displayName) { * Create a new {@code AbstractTestDescriptor} with the supplied * {@link UniqueId}, display name, and source. * + *

As of JUnit 6.0, ISO control characters in the provided display name + * will be replaced according to the following table. + * + * + * + * + * + * + * + *
Control Character Replacement
Original Replacement Description
{@code \r} {@code } Textual representation of a carriage return
{@code \n} {@code } Textual representation of a line feed
Other control character Unicode replacement character (U+FFFD)
+ * * @param uniqueId the unique ID of this {@code TestDescriptor}; never * {@code null} * @param displayName the display name for this {@code TestDescriptor}; @@ -90,7 +111,8 @@ protected AbstractTestDescriptor(UniqueId uniqueId, String displayName) { */ protected AbstractTestDescriptor(UniqueId uniqueId, String displayName, @Nullable TestSource source) { this.uniqueId = Preconditions.notNull(uniqueId, "UniqueId must not be null"); - this.displayName = Preconditions.notBlank(displayName, "displayName must not be null or blank"); + this.displayName = replaceControlCharacters( + Preconditions.notBlank(displayName, "displayName must not be null or blank")); this.source = source; } @@ -207,4 +229,20 @@ public String toString() { return getClass().getSimpleName() + ": " + getUniqueId(); } + private static String replaceControlCharacters(String text) { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < text.length(); i++) { + builder.append(replaceControlCharacter(text.charAt(i))); + } + return builder.toString(); + } + + private static String replaceControlCharacter(char ch) { + return switch (ch) { + case '\r' -> ""; + case '\n' -> ""; + default -> Character.isISOControl(ch) ? UNICODE_REPLACEMENT_CHARACTER : String.valueOf(ch); + }; + } + } diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedInvocationNameFormatterTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedInvocationNameFormatterTests.java index b6e798196ae6..5d5d2803244a 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedInvocationNameFormatterTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedInvocationNameFormatterTests.java @@ -342,7 +342,7 @@ class QuotedTextTests { ' \t ' -> ' \\t ' '\b' -> \\b '\f' -> \\f - '\u0007' -> ? + '\u0007' -> '\u0007' """) void quotedStrings(String argument, String expected) { var formatter = formatter(DEFAULT_DISPLAY_NAME, "IGNORED"); @@ -364,7 +364,7 @@ void quotedStrings(String argument, String expected) { "\t" -> \\t "\b" -> \\b "\f" -> \\f - "\u0007" -> ? + "\u0007" -> "\u0007" """) void quotedCharacters(char argument, String expected) { var formatter = formatter(DEFAULT_DISPLAY_NAME, "IGNORED"); diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedTestIntegrationTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedTestIntegrationTests.java index cb7fda26c9ff..18f7ec9dd6ae 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedTestIntegrationTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedTestIntegrationTests.java @@ -270,6 +270,30 @@ void executesWithCsvSource() { .haveExactly(1, event(test(), displayName("[2] argument = bar"), finishedWithFailure(message("bar")))); } + /** + * @since 6.0 + */ + @Test + void executesWithCsvSourceAndSpecialCharacters() { + // @formatter:off + execute("testWithCsvSourceAndSpecialCharacters", String.class) + .testEvents() + .started() + .assertEventsMatchExactly( + displayName(quoted("üñåé")), + displayName(quoted("\\n")), + displayName(quoted("\\r")), + displayName(quoted("\uFFFD")), + displayName(quoted("😱")), + displayName(quoted("Zero\u200BWidth\u200BSpaces")) + ); + // @formatter:on + } + + private static String quoted(String text) { + return '"' + text + '"'; + } + @Test void executesWithCustomName() { var results = execute("testWithCustomName", String.class, int.class); @@ -1453,6 +1477,11 @@ void testWithCsvSource(String argument) { fail(argument); } + @ParameterizedTest(name = "{0}") + @CsvSource({ "'üñåé'", "'\n'", "'\r'", "'\u0007'", "😱", "'Zero\u200BWidth\u200BSpaces'" }) + void testWithCsvSourceAndSpecialCharacters(String argument) { + } + @ParameterizedTest(quoteTextArguments = false, name = "{0} and {1}") @CsvSource({ "foo, 23", "bar, 42" }) void testWithCustomName(String argument, int i) { diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/descriptor/AbstractTestDescriptorTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/descriptor/AbstractTestDescriptorTests.java index 9f2954636e74..456781ed4646 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/descriptor/AbstractTestDescriptorTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/descriptor/AbstractTestDescriptorTests.java @@ -16,6 +16,7 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; import java.util.ArrayList; import java.util.List; @@ -23,6 +24,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.platform.commons.JUnitException; import org.junit.platform.engine.TestDescriptor; import org.junit.platform.engine.UniqueId; @@ -166,6 +169,29 @@ private List getAncestorsUniqueIds(TestDescriptor descriptor) { return descriptor.getAncestors().stream().map(TestDescriptor::getUniqueId).toList(); } + @ParameterizedTest(name = "{0} \u27A1 {1}") + // NOTE: "\uFFFD" is the Unicode replacement character: � + @CsvSource(delimiterString = "->", textBlock = """ + 'carriage \r return' -> 'carriage return' + 'line \n feed' -> 'line feed' + 'form \f feed' -> 'form \uFFFD feed' + 'back \b space' -> 'back \uFFFD space' + 'tab \t tab' -> 'tab \uFFFD tab' + # Latin-1 + 'üñåé' -> 'üñåé' + # "hello" in Japanese + 'こんにちは' -> 'こんにちは' + # 'hello world' in Thai + 'สวัสดีชาวโลก' -> 'สวัสดีชาวโลก' + # bell sound/character + 'ding \u0007 dong' -> 'ding \uFFFD dong' + 'Munch 😱 emoji' -> 'Munch 😱 emoji' + 'Zero\u200BWidth\u200BSpaces' -> 'Zero\u200BWidth\u200BSpaces' + """) + void specialCharactersInDisplayNamesAreEscaped(String input, String expected) { + assertThat(new DemoDescriptor(input).getDisplayName()).isEqualTo(expected); + } + } class GroupDescriptor extends AbstractTestDescriptor { @@ -193,3 +219,16 @@ public Type getType() { } } + +class DemoDescriptor extends AbstractTestDescriptor { + + DemoDescriptor(String displayName) { + super(mock(), displayName); + } + + @Override + public Type getType() { + return Type.CONTAINER; + } + +}