Skip to content

Replace ISO control characters in display names #4813

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<LF>`. 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]]
Expand Down Expand Up @@ -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]]
Expand Down
29 changes: 27 additions & 2 deletions documentation/src/docs/asciidoc/user-guide/writing-tests.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<writing-tests-parameterized-tests-display-names-quoted-text>>
for details.
Any remaining ISO control characters in a display name will be replaced as follows.
[cols="25%,15%,60%"]
|===
| Original | Replacement | Description
| ```\r```
| ```<CR>```
| Textual representation of a carriage return
| ```\n```
| ```<LF>```
| Textual representation of a line feed
| Other control character
| ```�```
| Unicode replacement character (U+FFFD)
|===
====

[[writing-tests-display-name-generator]]
==== Display Name Generators

Expand Down Expand Up @@ -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]
====
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -66,6 +72,10 @@ public abstract class AbstractTestDescriptor implements TestDescriptor {
* Create a new {@code AbstractTestDescriptor} with the supplied
* {@link UniqueId} and display name.
*
* <p>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};
Expand All @@ -80,6 +90,17 @@ protected AbstractTestDescriptor(UniqueId uniqueId, String displayName) {
* Create a new {@code AbstractTestDescriptor} with the supplied
* {@link UniqueId}, display name, and source.
*
* <p>As of JUnit 6.0, ISO control characters in the provided display name
* will be replaced according to the following table.
*
* <table class="plain">
* <caption>Control Character Replacement</caption>
* <tr><th> Original </th><th> Replacement </th><th> Description </th></tr>
* <tr><td> {@code \r} </td><td> {@code <CR>} </td><td> Textual representation of a carriage return </td></tr>
* <tr><td> {@code \n} </td><td> {@code <LF>} </td><td> Textual representation of a line feed </td></tr>
* <tr><td> Other control character </td><td> &#xFFFD; </td><td> Unicode replacement character (U+FFFD) </td></tr>
* </table>
*
* @param uniqueId the unique ID of this {@code TestDescriptor}; never
* {@code null}
* @param displayName the display name for this {@code TestDescriptor};
Expand All @@ -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;
}

Expand Down Expand Up @@ -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' -> "<CR>";
case '\n' -> "<LF>";
default -> Character.isISOControl(ch) ? UNICODE_REPLACEMENT_CHARACTER : String.valueOf(ch);
};
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@
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;
import java.util.concurrent.atomic.AtomicInteger;

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;
Expand Down Expand Up @@ -166,6 +169,29 @@ private List<UniqueId> 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 <CR> return'
'line \n feed' -> 'line <LF> 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 {
Expand Down Expand Up @@ -193,3 +219,16 @@ public Type getType() {
}

}

class DemoDescriptor extends AbstractTestDescriptor {

DemoDescriptor(String displayName) {
super(mock(), displayName);
}

@Override
public Type getType() {
return Type.CONTAINER;
}

}
Loading