diff --git a/documentation/documentation.gradle.kts b/documentation/documentation.gradle.kts index 311a5a8418b4..d1e03115f2b2 100644 --- a/documentation/documentation.gradle.kts +++ b/documentation/documentation.gradle.kts @@ -54,6 +54,12 @@ val tools by sourceSets.creating val toolsImplementation by configurations.getting dependencies { + repositories { + // TODO: Remove + mavenLocal() + mavenCentral() + } + implementation(projects.junitJupiterApi) { because("Jupiter API is used in src/main/java") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7e2192981d24..c9cf168e9bc7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ junit4 = "4.13.2" junit4Min = "4.12" ktlint = "1.8.0" log4j = "2.25.3" -opentest4j = "1.3.0" +opentest4j = "1.4.0-SNAPSHOT" openTestReporting = "0.2.5" snapshotTests = "1.11.0" surefire = "3.5.5" @@ -67,6 +67,7 @@ openTestReporting-events = { module = "org.opentest4j.reporting:open-test-report openTestReporting-tooling-core = { module = "org.opentest4j.reporting:open-test-reporting-tooling-core", version.ref = "openTestReporting" } openTestReporting-tooling-spi = { module = "org.opentest4j.reporting:open-test-reporting-tooling-spi", version.ref = "openTestReporting" } picocli = { module = "info.picocli:picocli", version = "4.7.7" } +javadiffutils = { module = "io.github.java-diff-utils:java-diff-utils", version = "4.15" } roseau-cli = { module = "io.github.alien-tools:roseau-cli", version = "0.5.0" } slf4j-julBinding = { module = "org.slf4j:slf4j-jdk14", version = "2.0.17" } snapshotTests-junit5 = { module = "de.skuzzle.test:snapshot-tests-junit5", version.ref = "snapshotTests" } diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AssertionFailureBuilder.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AssertionFailureBuilder.java index 8b1d2e15f7a5..8f3702657057 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AssertionFailureBuilder.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AssertionFailureBuilder.java @@ -192,20 +192,37 @@ public void buildAndThrow() throws AssertionFailedError { */ public AssertionFailedError build() { String reason = nullSafeGet(this.reason); + String message = nullSafeGet(this.message); + var assertionFailedError = new AssertionFailedError( // + formatExceptionMessage(reason, message), // + formatReason(reason, message), // + expected, // + actual, // + cause // + ); + maybeTrimStackTrace(assertionFailedError); + return assertionFailedError; + } + + private @Nullable String formatReason(@Nullable String reason, @Nullable String message) { + if (mismatch) { + // TODO: The suffix is implicit due to mismatch, but how to make explicit? + reason = (reason == null ? "" : reason + ", ") + "expected did not match actual"; + } + if (reason != null) { + message = buildPrefix(message) + reason; + } + return message; + } + + private @Nullable String formatExceptionMessage(@Nullable String reason, @Nullable String message) { if (mismatch && includeValuesInMessage) { reason = (reason == null ? "" : reason + ", ") + formatValues(expected, actual); } - String message = nullSafeGet(this.message); if (reason != null) { message = buildPrefix(message) + reason; } - - var assertionFailedError = mismatch // - ? new AssertionFailedError(message, expected, actual, cause) // - : new AssertionFailedError(message, cause); - - maybeTrimStackTrace(assertionFailedError); - return assertionFailedError; + return message; } private void maybeTrimStackTrace(Throwable throwable) { diff --git a/junit-platform-console/junit-platform-console.gradle.kts b/junit-platform-console/junit-platform-console.gradle.kts index dd9568faf82f..061210879313 100644 --- a/junit-platform-console/junit-platform-console.gradle.kts +++ b/junit-platform-console/junit-platform-console.gradle.kts @@ -18,6 +18,8 @@ dependencies { compileOnlyApi(libs.jspecify) shadowed(libs.picocli) + // TODO: Sort out relocation, licence, ect + shadowed(libs.javadiffutils) osgiVerification(projects.junitJupiterEngine) osgiVerification(projects.junitPlatformLauncher) @@ -29,7 +31,8 @@ tasks { options.compilerArgs.addAll(listOf( "-Xlint:-module", // due to qualified exports "--add-modules", "info.picocli", - "--add-reads", "${javaModuleName}=info.picocli" + "--add-reads", "${javaModuleName}=info.picocli", + "-Xlint:-requires-automatic", // Java diff utils )) options.errorprone.nullaway { excludedFieldAnnotations.addAll( diff --git a/junit-platform-console/src/main/java/module-info.java b/junit-platform-console/src/main/java/module-info.java index 4ae45d47e8bf..3474905a0d12 100644 --- a/junit-platform-console/src/main/java/module-info.java +++ b/junit-platform-console/src/main/java/module-info.java @@ -23,6 +23,7 @@ requires org.junit.platform.engine; requires org.junit.platform.launcher; requires org.junit.platform.reporting; + requires io.github.javadiffutils; exports org.junit.platform.console.output to org.junit.start; diff --git a/junit-platform-console/src/main/java/org/junit/platform/console/output/RichDiffFormatter.java b/junit-platform-console/src/main/java/org/junit/platform/console/output/RichDiffFormatter.java new file mode 100644 index 000000000000..90ab1fb0eec3 --- /dev/null +++ b/junit-platform-console/src/main/java/org/junit/platform/console/output/RichDiffFormatter.java @@ -0,0 +1,72 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.console.output; + +import java.util.function.Function; + +import com.github.difflib.text.DiffRowGenerator; + +import org.opentest4j.AssertionFailedError; + +final class RichDiffFormatter { + public String format(AssertionFailedError assertionFailed) { + StringBuilder builder = new StringBuilder(); + + if (assertionFailed.isReasonDefined()) { + builder.append(": "); + builder.append(assertionFailed.getReason()); + } + builder.append(System.lineSeparator()); + + builder.append("+ actual - expected"); + builder.append(System.lineSeparator()); + + var generator = DiffRowGenerator.create() // + .lineNormalizer(Function.identity()) // Don't normalize lines + .showInlineDiffs(false) // + .build(); + + var diffRows = generator.generateDiffRows( // + assertionFailed.getExpected().getStringRepresentation().lines().toList(), // + assertionFailed.getActual().getStringRepresentation().lines().toList() // + ); + + diffRows.forEach(diffRow -> { + switch (diffRow.getTag()) { + case INSERT -> { + builder.append("+ "); + builder.append(diffRow.getNewLine()); + builder.append(System.lineSeparator()); + } + case DELETE -> { + builder.append("- "); + builder.append(diffRow.getOldLine()); + builder.append(System.lineSeparator()); + } + case CHANGE -> { + builder.append("+ "); + builder.append(diffRow.getOldLine()); + builder.append(System.lineSeparator()); + builder.append("- "); + builder.append(diffRow.getNewLine()); + builder.append(System.lineSeparator()); + } + case EQUAL -> { + builder.append(" "); + builder.append(diffRow.getNewLine()); + builder.append(System.lineSeparator()); + } + } + }); + + return builder.toString(); + } +} diff --git a/junit-platform-console/src/main/java/org/junit/platform/console/output/TreePrinter.java b/junit-platform-console/src/main/java/org/junit/platform/console/output/TreePrinter.java index 065ac69e108e..1be3e0b8ab61 100644 --- a/junit-platform-console/src/main/java/org/junit/platform/console/output/TreePrinter.java +++ b/junit-platform-console/src/main/java/org/junit/platform/console/output/TreePrinter.java @@ -22,6 +22,7 @@ import org.junit.platform.engine.TestExecutionResult.Status; import org.junit.platform.engine.reporting.FileEntry; import org.junit.platform.engine.reporting.ReportEntry; +import org.opentest4j.AssertionFailedError; /** * @since 1.0 @@ -123,11 +124,19 @@ private void printThrowable(String indent, TestExecutionResult result) { return; } Throwable throwable = result.getThrowable().get(); + String message = formatThrowable(throwable); + printMessage(Style.FAILED, indent, message); + } + + private static String formatThrowable(Throwable throwable) { + if (throwable instanceof AssertionFailedError assertionFailedError) { + return new RichDiffFormatter().format(assertionFailedError); + } String message = throwable.getMessage(); if (StringUtils.isBlank(message)) { message = throwable.toString(); } - printMessage(Style.FAILED, indent, message); + return message; } private void printReportEntry(String indent, ReportEntry reportEntry) { diff --git a/platform-tests/platform-tests.gradle.kts b/platform-tests/platform-tests.gradle.kts index 9ea2c04a8531..c04493009fce 100644 --- a/platform-tests/platform-tests.gradle.kts +++ b/platform-tests/platform-tests.gradle.kts @@ -30,6 +30,11 @@ val woodstoxRuntimeClasspath = configurations.resolvable("woodstoxRuntimeClasspa } dependencies { + repositories { + mavenLocal() + mavenCentral() + } + // --- Things we are testing -------------------------------------------------- testImplementation(projects.junitPlatformCommons) testImplementation(projects.junitPlatformConsole) diff --git a/platform-tests/src/test/java/org/junit/platform/console/output/RichDiffFormatterTests.java b/platform-tests/src/test/java/org/junit/platform/console/output/RichDiffFormatterTests.java new file mode 100644 index 000000000000..433f6bb106ed --- /dev/null +++ b/platform-tests/src/test/java/org/junit/platform/console/output/RichDiffFormatterTests.java @@ -0,0 +1,92 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.console.output; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; +import org.opentest4j.AssertionFailedError; + +class RichDiffFormatterTests { + + @Test + void lineAdded() { + assertRichDiffEquals(""" + { + "speaker": "world" + } + """, """ + { + "speaker": "world" + "message": "hello" + } + """, """ + expected did not match actual + + actual - expected + { + "speaker": "world" + + "message": "hello" + } + """); + } + + @Test + void lineRemoved() { + assertRichDiffEquals(""" + { + "speaker": "world" + "message": "hello" + } + """, """ + { + "speaker": "world" + } + """, """ + expected did not match actual + + actual - expected + { + "speaker": "world" + - "message": "hello" + } + """); + } + + @Test + void lineChanged() { + assertRichDiffEquals(""" + { + "speaker": "world" + "message": "hello" + } + """, """ + { + "speaker": "you" + "message": "hello" + } + """, """ + expected did not match actual + + actual - expected + { + + "speaker": "world" + - "speaker": "you" + "message": "hello" + } + """); + } + + private static void assertRichDiffEquals(String expected, String actual, String expectedDiff) { + var formatter = new RichDiffFormatter(); + var assertionFailed = assertThrows(AssertionFailedError.class, () -> assertEquals(expected, actual)); + assertEquals(expectedDiff, formatter.format(assertionFailed)); + } + +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 2cc5e2d14846..b888dfd44630 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,6 +15,8 @@ plugins { dependencyResolutionManagement { repositories { mavenCentral() + // TODO: Remove + mavenLocal() } }