diff --git a/gradle-witchcraft-logging/build.gradle b/gradle-witchcraft-logging/build.gradle index dd93c46e..b76eb2dc 100644 --- a/gradle-witchcraft-logging/build.gradle +++ b/gradle-witchcraft-logging/build.gradle @@ -9,6 +9,7 @@ dependencies { implementation project(':witchcraft-logging-formatting') implementation 'com.google.guava:guava' implementation 'com.palantir.gradle.idea-configuration:gradle-idea-configuration' + implementation 'org.apache.commons:commons-text' testImplementation gradleTestKit() testImplementation 'com.google.guava:guava' diff --git a/gradle-witchcraft-logging/src/main/groovy/com/palantir/witchcraft/java/logging/gradle/testreport/FormattingTestReporter.java b/gradle-witchcraft-logging/src/main/groovy/com/palantir/witchcraft/java/logging/gradle/testreport/FormattingTestReporter.java deleted file mode 100644 index 5ab9da0e..00000000 --- a/gradle-witchcraft-logging/src/main/groovy/com/palantir/witchcraft/java/logging/gradle/testreport/FormattingTestReporter.java +++ /dev/null @@ -1,175 +0,0 @@ -/* - * (c) Copyright 2021 Palantir Technologies Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.palantir.witchcraft.java.logging.gradle.testreport; - -// CHECKSTYLE:OFF - -import com.palantir.witchcraft.java.logging.format.LogFormatter; -import com.palantir.witchcraft.java.logging.format.LogParser; -import java.io.File; -import java.io.IOException; -import java.io.UncheckedIOException; -import java.io.Writer; -import java.util.function.BiConsumer; -import org.gradle.api.Action; -import org.gradle.api.internal.tasks.testing.junit.result.TestClassResult; -import org.gradle.api.internal.tasks.testing.junit.result.TestResultsProvider; -import org.gradle.api.internal.tasks.testing.report.HtmlTestReport; -import org.gradle.api.internal.tasks.testing.report.TestReporter; -import org.gradle.api.tasks.testing.TestOutputEvent; -// CHECKSTYLE:ON - -final class FormattingTestReporter implements TestReporter { - - private final Object delegate; - - FormattingTestReporter(Object delegate) { - this.delegate = delegate; - } - - @Override - public void generateReport(TestResultsProvider testResultsProvider, File file) { - if (delegate instanceof TestReporter) { - ((TestReporter) delegate).generateReport(new FormattingTestResultsProvider(testResultsProvider), file); - } else if (delegate instanceof HtmlTestReport) { - ((HtmlTestReport) delegate).generateReport(new FormattingTestResultsProvider(testResultsProvider), file); - } else { - throw new IllegalArgumentException("Unknown delegate class: " + delegate.getClass()); - } - } - - private static final class FormattingTestResultsProvider implements TestResultsProvider { - - private static final LogParser PARSER = new LogParser<>(TestLogFilter.INSTANCE.combineWith( - LogFormatter.INSTANCE, - (include, formatted) -> include - ? writer -> { - writer.write(formatted); - writer.write('\n'); - } - : Writable.NOP)); - private static final BiConsumer LINE_PROCESSOR = (line, outputWriter) -> { - try { - PARSER.tryParse(line) - .orElseGet(() -> out -> { - try { - out.write(line); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - }) - .write(outputWriter); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - }; - - private final TestResultsProvider delegate; - - FormattingTestResultsProvider(TestResultsProvider delegate) { - this.delegate = delegate; - } - - @Override - public void writeAllOutput(long classId, TestOutputEvent.Destination destination, Writer writer) { - if (destination == TestOutputEvent.Destination.StdErr - || destination == TestOutputEvent.Destination.StdOut) { - delegate.writeAllOutput(classId, destination, new LineProcessingWriter(writer, LINE_PROCESSOR)); - } else { - delegate.writeAllOutput(classId, destination, writer); - } - } - - private static class LineProcessingWriter extends Writer { - private final Writer delegate; - private final BiConsumer lineProcessor; - private final StringBuilder lineBuffer = new StringBuilder(); - - LineProcessingWriter(Writer delegate, BiConsumer lineProcessor) { - this.delegate = delegate; - this.lineProcessor = lineProcessor; - } - - @Override - public void write(char[] cbuf, int off, int len) throws IOException { - for (int i = off; i < off + len; i++) { - char ch = cbuf[i]; - if (ch == '\n') { - processLine(); - } else { - lineBuffer.append(ch); - } - } - } - - @Override - public void flush() throws IOException { - delegate.flush(); - } - - @Override - public void close() throws IOException { - if (!lineBuffer.isEmpty()) { - processLine(); - } - delegate.close(); - } - - private void processLine() throws IOException { - String line = lineBuffer.toString(); - lineProcessor.accept(line, delegate); - delegate.write('\n'); - lineBuffer.setLength(0); - } - } - - @Override - public void writeNonTestOutput(long classId, TestOutputEvent.Destination destination, Writer writer) { - delegate.writeNonTestOutput(classId, destination, writer); - } - - @Override - public void writeTestOutput(long classId, long testId, TestOutputEvent.Destination destination, Writer writer) { - delegate.writeTestOutput(classId, testId, destination, writer); - } - - @Override - public void visitClasses(Action visitor) { - delegate.visitClasses(visitor); - } - - @Override - public boolean hasOutput(long classId, TestOutputEvent.Destination destination) { - return delegate.hasOutput(classId, destination); - } - - @Override - public boolean hasOutput(long classId, long testId, TestOutputEvent.Destination destination) { - return delegate.hasOutput(classId, testId, destination); - } - - @Override - public boolean isHasResults() { - return delegate.isHasResults(); - } - - @Override - public void close() throws IOException { - delegate.close(); - } - } -} diff --git a/gradle-witchcraft-logging/src/main/groovy/com/palantir/witchcraft/java/logging/gradle/testreport/TestReportFormattingPlugin.java b/gradle-witchcraft-logging/src/main/groovy/com/palantir/witchcraft/java/logging/gradle/testreport/TestReportFormattingPlugin.java deleted file mode 100644 index 68454645..00000000 --- a/gradle-witchcraft-logging/src/main/groovy/com/palantir/witchcraft/java/logging/gradle/testreport/TestReportFormattingPlugin.java +++ /dev/null @@ -1,111 +0,0 @@ -/* - * (c) Copyright 2021 Palantir Technologies Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.palantir.witchcraft.java.logging.gradle.testreport; - -// CHECKSTYLE:OFF - -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import javax.inject.Inject; -import org.gradle.api.Plugin; -import org.gradle.api.Project; -import org.gradle.api.internal.tasks.testing.report.HtmlTestReport; -import org.gradle.api.internal.tasks.testing.report.TestReporter; -import org.gradle.api.tasks.testing.AbstractTestTask; -import org.gradle.internal.operations.BuildOperationExecutor; -import org.gradle.internal.operations.BuildOperationRunner; -import org.gradle.util.GradleVersion; -// CHECKSTYLE:ON - -/** - * In its current form, this plugin may generously be described as "a workaround". - * I've filed gradle#17966 - * upstream to find a better solution. - * We may be able to consume the xml test report and generate our own html based on that - * if the current approach becomes troublesome, that would allow us to color individual - * lines much like our intellij plugin. - */ -public abstract class TestReportFormattingPlugin implements Plugin { - - @Override - @SuppressWarnings("Slf4jLogsafeArgs") - public final void apply(Project project) { - project.getTasks().withType(AbstractTestTask.class).configureEach(task -> { - try { - Method method = AbstractTestTask.class.getDeclaredMethod("setTestReporter", TestReporter.class); - method.setAccessible(true); - - method.invoke(task, new FormattingTestReporter(getFormattingDelegate())); - } catch (ReflectiveOperationException e) { - project.getLogger() - .error( - "Failed to update task '{}' TestReporter to format structured logging output", - task.getName(), - e); - } - }); - } - - /** - * The internal gradle test report classes changed in 8.11. DefaultTestReport was renamed to HtmlTestReport and - * also stopped extending the TestReporter interface. So the delegate for the formatting reporter varies either - * an HtmlTestReport to be compatible with gradle 8.11+ or DefaultTestReport for lower versions. - */ - private Object getFormattingDelegate() { - boolean greaterThan8Point11 = GradleVersion.current().compareTo(GradleVersion.version("8.11")) >= 0; - if (greaterThan8Point11) { - return new HtmlTestReport(getBuildOperationRunner(), getBuildOperationExecutor()); - } else { - return createDefaultTestReport(); - } - } - - /** - * The constructor for DefaultTestReport changed in gradle 8.8. Dynamically invoke based on runtime version. - */ - private TestReporter createDefaultTestReport() { - boolean greaterThan8Point8 = GradleVersion.current().compareTo(GradleVersion.version("8.8")) >= 0; - - try { - Class defaultTestReporterClass = (Class) - Class.forName("org.gradle.api.internal.tasks.testing.report.DefaultTestReport"); - if (greaterThan8Point8) { - return defaultTestReporterClass - .getDeclaredConstructor(BuildOperationRunner.class, BuildOperationExecutor.class) - .newInstance(getBuildOperationRunner(), getBuildOperationExecutor()); - } else { - return defaultTestReporterClass - .getDeclaredConstructor(BuildOperationExecutor.class) - .newInstance(getBuildOperationExecutor()); - } - } catch (InstantiationException - | IllegalAccessException - | InvocationTargetException - | ClassNotFoundException - | NoSuchMethodException e) { - throw new RuntimeException(e); - } - } - - @Inject - @SuppressWarnings("DesignForExtension") - protected abstract BuildOperationExecutor getBuildOperationExecutor(); - - @Inject - @SuppressWarnings("DesignForExtension") - protected abstract BuildOperationRunner getBuildOperationRunner(); -} diff --git a/gradle-witchcraft-logging/src/main/groovy/com/palantir/witchcraft/java/logging/gradle/WitchcraftLoggingPlugin.java b/gradle-witchcraft-logging/src/main/java/com/palantir/witchcraft/java/logging/gradle/WitchcraftLoggingPlugin.java similarity index 86% rename from gradle-witchcraft-logging/src/main/groovy/com/palantir/witchcraft/java/logging/gradle/WitchcraftLoggingPlugin.java rename to gradle-witchcraft-logging/src/main/java/com/palantir/witchcraft/java/logging/gradle/WitchcraftLoggingPlugin.java index 1a1ab7d7..c80bc403 100644 --- a/gradle-witchcraft-logging/src/main/groovy/com/palantir/witchcraft/java/logging/gradle/WitchcraftLoggingPlugin.java +++ b/gradle-witchcraft-logging/src/main/java/com/palantir/witchcraft/java/logging/gradle/WitchcraftLoggingPlugin.java @@ -22,6 +22,7 @@ import java.util.Objects; import org.gradle.api.Plugin; import org.gradle.api.Project; +import org.gradle.util.GradleVersion; /** One-stop-shop for a fantastic developer experience with witchcraft-logging projects. */ public final class WitchcraftLoggingPlugin implements Plugin { @@ -32,6 +33,11 @@ public void apply(Project rootProject) { WitchcraftLoggingPlugin.class.getSimpleName() + " can only be applied to the root project."); } + if (GradleVersion.current().compareTo(GradleVersion.version("8.1")) < 0) { + throw new IllegalArgumentException(WitchcraftLoggingPlugin.class.getSimpleName() + + " can only be applied if using Gradle version 8.1 or newer."); + } + rootProject.getPluginManager().apply(IdeaConfigurationPlugin.class); IdeaConfigurationExtension extension = rootProject.getExtensions().getByType(IdeaConfigurationExtension.class); extension diff --git a/gradle-witchcraft-logging/src/main/java/com/palantir/witchcraft/java/logging/gradle/testreport/HtmlReportPostProcessor.java b/gradle-witchcraft-logging/src/main/java/com/palantir/witchcraft/java/logging/gradle/testreport/HtmlReportPostProcessor.java new file mode 100644 index 00000000..d6fc363c --- /dev/null +++ b/gradle-witchcraft-logging/src/main/java/com/palantir/witchcraft/java/logging/gradle/testreport/HtmlReportPostProcessor.java @@ -0,0 +1,101 @@ +/* + * (c) Copyright 2021 Palantir Technologies Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.palantir.witchcraft.java.logging.gradle.testreport; + +import com.palantir.witchcraft.java.logging.format.LogFormatter; +import com.palantir.witchcraft.java.logging.format.LogParser; +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.commons.text.StringEscapeUtils; + +/** + * Post-processes HTML test report files to apply witchcraft log formatting. + */ +final class HtmlReportPostProcessor { + + private static final LogParser> PARSER = new LogParser<>(TestLogFilter.INSTANCE.combineWith( + LogFormatter.INSTANCE, (include, formatted) -> include ? Optional.of(formatted) : Optional.empty())); + + // Match
 tags within stdout/stderr sections: 

standard output

...
...
+ private static final Pattern OUTPUT_SECTION_PATTERN = Pattern.compile( + "(

standard (?:output|error)

\\s*]*>\\s*]*>)(.*?)(
)", + Pattern.DOTALL | Pattern.CASE_INSENSITIVE); + + void processReportDirectory(File reportDir) { + Optional.ofNullable(reportDir).filter(File::isDirectory).ifPresent(this::walkAndProcessHtmlFiles); + } + + private void walkAndProcessHtmlFiles(File reportDir) { + try (Stream paths = Files.walk(reportDir.toPath())) { + paths.filter(path -> path.toString().endsWith(".html")).forEach(this::processHtmlFile); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private void processHtmlFile(Path htmlFile) { + try { + String content = Files.readString(htmlFile, StandardCharsets.UTF_8); + String processed = processHtmlContent(content); + + if (!content.equals(processed)) { + Files.writeString(htmlFile, processed, StandardCharsets.UTF_8); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private String processHtmlContent(String html) { + Matcher matcher = OUTPUT_SECTION_PATTERN.matcher(html); + StringBuilder result = new StringBuilder(); + + while (matcher.find()) { + String replacement = matcher.group(1) + formatPreContent(matcher.group(2)) + matcher.group(3); + matcher.appendReplacement(result, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(result); + + return result.toString(); + } + + private String formatPreContent(String content) { + return content.lines() + .map(this::formatLine) + .mapMulti(Optional::ifPresent) + .collect(Collectors.joining("\n")); + } + + /** + * @return formatted line (with trailing newline), empty if filtered out, + * or original if not a witchcraft log + */ + private Optional formatLine(String line) { + return PARSER.tryParse(StringEscapeUtils.unescapeHtml4(line)) + .map(opt -> opt.map(formatted -> StringEscapeUtils.escapeHtml4(formatted) + "\n")) + .orElseGet(() -> Optional.of(line)); + } +} diff --git a/gradle-witchcraft-logging/src/main/groovy/com/palantir/witchcraft/java/logging/gradle/testreport/TestLogFilter.java b/gradle-witchcraft-logging/src/main/java/com/palantir/witchcraft/java/logging/gradle/testreport/TestLogFilter.java similarity index 100% rename from gradle-witchcraft-logging/src/main/groovy/com/palantir/witchcraft/java/logging/gradle/testreport/TestLogFilter.java rename to gradle-witchcraft-logging/src/main/java/com/palantir/witchcraft/java/logging/gradle/testreport/TestLogFilter.java diff --git a/gradle-witchcraft-logging/src/main/java/com/palantir/witchcraft/java/logging/gradle/testreport/TestReportFormattingPlugin.java b/gradle-witchcraft-logging/src/main/java/com/palantir/witchcraft/java/logging/gradle/testreport/TestReportFormattingPlugin.java new file mode 100644 index 00000000..7ee32888 --- /dev/null +++ b/gradle-witchcraft-logging/src/main/java/com/palantir/witchcraft/java/logging/gradle/testreport/TestReportFormattingPlugin.java @@ -0,0 +1,71 @@ +/* + * (c) Copyright 2021 Palantir Technologies Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.palantir.witchcraft.java.logging.gradle.testreport; + +import java.io.File; +import javax.inject.Inject; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.flow.FlowAction; +import org.gradle.api.flow.FlowParameters; +import org.gradle.api.flow.FlowScope; +import org.gradle.api.provider.Property; +import org.gradle.api.reporting.DirectoryReport; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.testing.AbstractTestTask; + +/** + * Plugin that formats witchcraft structured logging output in HTML test reports. + * Post-processes generated HTML files to replace JSON log lines with formatted versions. + */ +public abstract class TestReportFormattingPlugin implements Plugin { + + @Inject + protected abstract FlowScope getFlowScope(); + + @Override + public final void apply(Project project) { + project.getTasks().withType(AbstractTestTask.class).configureEach(task -> { + getFlowScope().always(FormatReportAction.class, spec -> spec.getParameters() + .getReportDir() + .set(task.getReports().getHtml())); + }); + } + + public static final class FormatReportAction implements FlowAction { + interface Parameters extends FlowParameters { + @Input + Property getReportDir(); + } + + @Override + public void execute(Parameters parameters) { + File reportDirectory = parameters + .getReportDir() + .get() + .getOutputLocation() + .getAsFile() + .getOrNull(); + + if (reportDirectory == null || !reportDirectory.exists()) { + return; + } + + new HtmlReportPostProcessor().processReportDirectory(reportDirectory); + } + } +} diff --git a/gradle-witchcraft-logging/src/main/groovy/com/palantir/witchcraft/java/logging/gradle/testreport/Writable.java b/gradle-witchcraft-logging/src/main/java/com/palantir/witchcraft/java/logging/gradle/testreport/Writable.java similarity index 100% rename from gradle-witchcraft-logging/src/main/groovy/com/palantir/witchcraft/java/logging/gradle/testreport/Writable.java rename to gradle-witchcraft-logging/src/main/java/com/palantir/witchcraft/java/logging/gradle/testreport/Writable.java diff --git a/gradle-witchcraft-logging/src/test/java/com/palantir/witchcraft/java/logging/gradle/testreport/TestReportFormattingPluginIntegrationTest.java b/gradle-witchcraft-logging/src/test/java/com/palantir/witchcraft/java/logging/gradle/testreport/TestReportFormattingPluginIntegrationTest.java index 1facdf1d..8d12db40 100644 --- a/gradle-witchcraft-logging/src/test/java/com/palantir/witchcraft/java/logging/gradle/testreport/TestReportFormattingPluginIntegrationTest.java +++ b/gradle-witchcraft-logging/src/test/java/com/palantir/witchcraft/java/logging/gradle/testreport/TestReportFormattingPluginIntegrationTest.java @@ -21,6 +21,9 @@ import com.palantir.gradle.testing.execution.GradleInvoker; import com.palantir.gradle.testing.execution.InvocationResult; import com.palantir.gradle.testing.junit.GradlePluginTests; +import com.palantir.gradle.testing.junit.InjectByGradleVersion; +import com.palantir.gradle.testing.junit.ParameterizedByGradleVersion; +import com.palantir.gradle.testing.junit.ParameterizedByGradleVersion.WhenVersion; import com.palantir.gradle.testing.project.RootProject; import org.junit.jupiter.api.Test; @@ -64,7 +67,14 @@ public void simpleTest() { """; @Test - void formats_test_report_stdout_and_stderr(GradleInvoker gradle, RootProject rootProject) { + @ParameterizedByGradleVersion( + when = + @WhenVersion( + lessThan = "9.3.0", + stringValue = "reports/tests/test/classes/com.palantir.SimpleTest.html"), + otherwiseString = "reports/tests/test/com.palantir.SimpleTest/simpleTest.html") + void formats_test_report_stdout_and_stderr( + GradleInvoker gradle, RootProject rootProject, @InjectByGradleVersion String reportPath) { rootProject .buildGradle() .plugins() @@ -88,7 +98,9 @@ void formats_test_report_stdout_and_stderr(GradleInvoker gradle, RootProject roo testImplementation 'junit:junit:4.13.2' } - sourceCompatibility = 11 + java { + sourceCompatibility = 11 + } """); rootProject.testSourceSet().java().writeClass(SIMPLE_TEST_CLASS); @@ -101,7 +113,7 @@ void formats_test_report_stdout_and_stderr(GradleInvoker gradle, RootProject roo rootProject .buildDir() - .file("reports/tests/test/classes/com.palantir.SimpleTest.html") + .file(reportPath) .assertThat() .content() .contains("==Service==") diff --git a/gradle/gradle-test-versions.yml b/gradle/gradle-test-versions.yml index 6fe9aa9b..a98569bc 100644 --- a/gradle/gradle-test-versions.yml +++ b/gradle/gradle-test-versions.yml @@ -1,5 +1,4 @@ major-versions: + 9: 9.3.0 8: 8.14.3 - 7: 7.6.4 -extra-versions: - - 8.8 +extra-versions: [] diff --git a/versions.lock b/versions.lock index ad791703..e662603c 100644 --- a/versions.lock +++ b/versions.lock @@ -46,6 +46,10 @@ com.palantir.tritium:tritium-ids:0.117.0 (1 constraints: fc0f93a6) com.palantir.witchcraft.api:witchcraft-logging-api-objects:2.6.0 (1 constraints: 0a050736) +org.apache.commons:commons-lang3:3.20.0 (1 constraints: f70d9342) + +org.apache.commons:commons-text:1.15.0 (1 constraints: 3905383b) + org.eclipse.collections:eclipse-collections:13.0.0 (1 constraints: 1c1091a9) org.eclipse.collections:eclipse-collections-api:13.0.0 (2 constraints: fa22df26) diff --git a/versions.props b/versions.props index f5fba212..d9115918 100644 --- a/versions.props +++ b/versions.props @@ -11,6 +11,7 @@ org.mockito:* = 5.21.0 org.slf4j:* = 2.0.17 com.netflix.nebula:nebula-test = 10.6.2 com.palantir.gradle.idea-configuration:gradle-idea-configuration = 0.3.0 +org.apache.commons:commons-text = 1.15.0 # test com.palantir.conjure.java.runtime:* = 8.30.0