diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ParameterizedInvocationNameFormatter.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ParameterizedInvocationNameFormatter.java index 4df551843e4d..453bcf49084c 100644 --- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ParameterizedInvocationNameFormatter.java +++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ParameterizedInvocationNameFormatter.java @@ -10,6 +10,7 @@ package org.junit.jupiter.params; +import static java.lang.Math.min; import static java.util.Objects.requireNonNull; import static java.util.stream.Collectors.joining; import static org.junit.jupiter.params.ParameterizedInvocationConstants.ARGUMENTS_PLACEHOLDER; @@ -24,7 +25,6 @@ import java.text.Format; import java.text.MessageFormat; import java.util.ArrayList; -import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -58,16 +58,16 @@ static ParameterizedInvocationNameFormatter create(ExtensionContext extensionCon String name = declarationContext.getDisplayNamePattern(); String pattern = name.equals(DEFAULT_DISPLAY_NAME) - ? extensionContext.getConfigurationParameter(DISPLAY_NAME_PATTERN_KEY) // - .orElse(DEFAULT_DISPLAY_NAME_PATTERN) + ? extensionContext.getConfigurationParameter(DISPLAY_NAME_PATTERN_KEY).orElse( + DEFAULT_DISPLAY_NAME_PATTERN) : name; pattern = Preconditions.notBlank(pattern.strip(), () -> "Configuration error: @%s on %s must be declared with a non-empty name.".formatted( declarationContext.getAnnotationName(), declarationContext.getResolverFacade().getIndexedParameterDeclarations().getSourceElementDescription())); - int argumentMaxLength = extensionContext.getConfigurationParameter(ARGUMENT_MAX_LENGTH_KEY, Integer::parseInt) // - .orElse(512); + int argumentMaxLength = extensionContext.getConfigurationParameter(ARGUMENT_MAX_LENGTH_KEY, + Integer::parseInt).orElse(512); return new ParameterizedInvocationNameFormatter(pattern, extensionContext.getDisplayName(), declarationContext, argumentMaxLength); @@ -101,7 +101,7 @@ String format(int invocationIndex, EvaluatedArgumentSet arguments) { private String formatSafely(int invocationIndex, EvaluatedArgumentSet arguments) { ArgumentsContext context = new ArgumentsContext(invocationIndex, arguments.getConsumedNames(), arguments.getName()); - StringBuffer result = new StringBuffer(); // used instead of StringBuilder so MessageFormat can append directly + StringBuffer result = new StringBuffer(); for (PartialFormatter partialFormatter : this.partialFormatters) { partialFormatter.append(context, result); } @@ -152,9 +152,8 @@ else if (minimum == null || index < minimum.index) { } private static PartialFormatter determineNonPlaceholderFormatter(String segment, int argumentMaxLength) { - return segment.contains("{") // - ? new MessageFormatPartialFormatter(segment, argumentMaxLength) // - : (context, result) -> result.append(segment); + return segment.contains("{") ? new MessageFormatPartialFormatter(segment, argumentMaxLength) + : (context, result) -> result.append(replaceNonPrintableCharacters(segment)); } private PartialFormatters createPartialFormatters(String displayName, @@ -169,14 +168,14 @@ private PartialFormatters createPartialFormatters(String displayName, PartialFormatters formatters = new PartialFormatters(); formatters.put(INDEX_PLACEHOLDER, PartialFormatter.INDEX); - formatters.put(DISPLAY_NAME_PLACEHOLDER, (context, result) -> result.append(displayName)); + formatters.put(DISPLAY_NAME_PLACEHOLDER, + (context, result) -> result.append(replaceNonPrintableCharacters(displayName))); formatters.put(ARGUMENT_SET_NAME_PLACEHOLDER, argumentSetNameFormatter); formatters.put(ARGUMENTS_WITH_NAMES_PLACEHOLDER, argumentsWithNamesFormatter); formatters.put(ARGUMENTS_PLACEHOLDER, new CachingByArgumentsLengthPartialFormatter( length -> new MessageFormatPartialFormatter(argumentsPattern(length), argumentMaxLength))); formatters.put(ARGUMENT_SET_NAME_OR_ARGUMENTS_WITH_NAMES_PLACEHOLDER, (context, result) -> { - PartialFormatter formatterToUse = context.argumentSetName.isPresent() // - ? argumentSetNameFormatter // + PartialFormatter formatterToUse = context.argumentSetName.isPresent() ? argumentSetNameFormatter : argumentsWithNamesFormatter; formatterToUse.append(context, result); }); @@ -185,16 +184,18 @@ private PartialFormatters createPartialFormatters(String displayName, private static String argumentsWithNamesPattern(int length, ParameterizedDeclarationContext declarationContext) { ResolverFacade resolverFacade = declarationContext.getResolverFacade(); - return IntStream.range(0, length) // - .mapToObj(index -> resolverFacade.getParameterName(index).map(name -> name + "=").orElse("") + "{" - + index + "}") // - .collect(joining(", ")); + return IntStream.range(0, length).mapToObj( + index -> resolverFacade.getParameterName(index).map(name -> name + "=").orElse("") + "{" + index + + "}").collect(joining(", ")); } private static String argumentsPattern(int length) { - return IntStream.range(0, length) // - .mapToObj(index -> "{" + index + "}") // - .collect(joining(", ")); + return IntStream.range(0, length).mapToObj(index -> "{" + index + "}").collect(joining(", ")); + } + + private static String replaceNonPrintableCharacters(String string) { + return string.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t").replaceAll( + "[\\p{Cc}\\p{Cf}\\p{Co}\\p{Cn}]", "?"); } private record PlaceholderPosition(int index, String placeholder) { @@ -206,19 +207,16 @@ private record ArgumentsContext(int invocationIndex, @Nullable Object[] consumed @FunctionalInterface private interface PartialFormatter { - PartialFormatter INDEX = (context, result) -> result.append(context.invocationIndex); void append(ArgumentsContext context, StringBuffer result); - } private record ArgumentSetNameFormatter(String annotationName) implements PartialFormatter { - @Override public void append(ArgumentsContext context, StringBuffer result) { if (context.argumentSetName.isPresent()) { - result.append(context.argumentSetName.get()); + result.append(replaceNonPrintableCharacters(context.argumentSetName.get())); return; } throw new ExtensionConfigurationException( @@ -228,7 +226,6 @@ public void append(ArgumentsContext context, StringBuffer result) { } private static class MessageFormatPartialFormatter implements PartialFormatter { - @SuppressWarnings("UnnecessaryUnicodeEscape") private static final char ELLIPSIS = '\u2026'; @@ -236,39 +233,48 @@ private static class MessageFormatPartialFormatter implements PartialFormatter { private final int argumentMaxLength; MessageFormatPartialFormatter(String pattern, int argumentMaxLength) { - this.messageFormat = new MessageFormat(pattern); + this.messageFormat = new MessageFormat(replaceNonPrintableCharacters(pattern)); this.argumentMaxLength = argumentMaxLength; } - // synchronized because MessageFormat is not thread-safe @Override public synchronized void append(ArgumentsContext context, StringBuffer result) { - this.messageFormat.format(makeReadable(context.consumedArguments), result, new FieldPosition(0)); + Object[] readableArguments = requireNonNull(makeReadable(context.consumedArguments)); + this.messageFormat.format(readableArguments, result, new FieldPosition(0)); } - private @Nullable Object[] makeReadable(@Nullable Object[] arguments) { - @Nullable - Format[] formats = messageFormat.getFormatsByArgumentIndex(); - @Nullable - Object[] result = Arrays.copyOf(arguments, Math.min(arguments.length, formats.length), Object[].class); - for (int i = 0; i < result.length; i++) { - if (formats[i] == null) { - result[i] = truncateIfExceedsMaxLength(StringUtils.nullSafeToString(arguments[i])); - } - } - return result; + @Nullable + private Object[] makeReadable(@Nullable Object[] arguments) { + return requireNonNull(makeReadable(arguments, messageFormat.getFormatsByArgumentIndex())).toArray(); } - private @Nullable String truncateIfExceedsMaxLength(@Nullable String argument) { - if (argument != null && argument.length() > this.argumentMaxLength) { - return argument.substring(0, this.argumentMaxLength - 1) + ELLIPSIS; + @Nullable + private List makeReadable(@Nullable Object[] arguments, Format[] formats) { + return IntStream.range(0, min(arguments.length, formats.length)).mapToObj( + i -> makeReadable(arguments, i, formats[i])).toList(); + } + + @Nullable + private Object makeReadable(@Nullable Object[] arguments, int index, @Nullable Format format) { + if (arguments == null) { + return ""; } - return argument; + Object arg = arguments[index]; + if (format != null) { + return arg; + } + String stringValue = StringUtils.nullSafeToString(arg); + return truncateIfExceedsMaxLength(replaceNonPrintableCharacters(stringValue)); + } + + private String truncateIfExceedsMaxLength(String argument) { + return argument.length() > this.argumentMaxLength + ? argument.substring(0, this.argumentMaxLength - 1) + ELLIPSIS + : argument; } } private static class CachingByArgumentsLengthPartialFormatter implements PartialFormatter { - private final ConcurrentMap cache = new ConcurrentHashMap<>(1); private final Function factory; @@ -283,7 +289,6 @@ public void append(ArgumentsContext context, StringBuffer result) { } private static class PartialFormatters { - private final Map formattersByPlaceholder = new LinkedHashMap<>(); private int minimumPlaceholderLength = Integer.MAX_VALUE; @@ -303,5 +308,4 @@ Set placeholders() { return formattersByPlaceholder.keySet(); } } - } 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 bee72ba81725..c110a1ee011d 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 @@ -323,6 +323,62 @@ void mixedTypesOfArgumentsImplementationsAndCustomDisplayNamePattern() { } + + @Nested + class replacesNonPrintableCharacters { + + +@Test +void replacesNonPrintableCharactersInArguments() { +var formatter = formatter(ARGUMENTS_PLACEHOLDER, "enigma"); + +Arguments args = arguments( + "\t", "\r", "\r\n", "\n", "\u200B" + ); + + assertEquals("\\t, \\r, \\r\\n, \\n, ?", format(formatter, 1, args)); + } + + @Test + void replacesNonPrintableCharactersInArgumentSetName() { + var formatter = formatter(ARGUMENT_SET_NAME_PLACEHOLDER, "IGNORED"); + + var formattedName = format(formatter, 1, + argumentSet("Test\tWith\nNewlines", "value")); + + assertEquals("Test\\tWith\\nNewlines", formattedName); + } + + @Test + void replacesNonPrintableCharactersInDisplayName() { + var formatter = formatter(DISPLAY_NAME_PLACEHOLDER, "Display\tName\nWith\nNewlines"); + + assertEquals("Display\\tName\\nWith\\nNewlines", format(formatter, 1, arguments())); + } + + @Test + void handlesMixedPrintableAndNonPrintableCharacters() { + var formatter = formatter("{0} {1}", "enigma"); + + Arguments args = arguments( + "Normal\tText", "Another\nLine" + ); + + assertEquals("Normal\\tText Another\\nLine", format(formatter, 1, args)); + } + + @Test + void doesNotReplacePrintableUnicodeCharacters() { + var formatter = formatter(ARGUMENTS_PLACEHOLDER, "enigma"); + + Arguments args = arguments( + "正常", "こんにちは", "안녕하세요" + ); + + assertEquals("正常, こんにちは, 안녕하세요", format(formatter, 1, args)); + } + } + // ------------------------------------------------------------------------- private static ParameterizedInvocationNameFormatter formatter(String pattern, String displayName) {