diff --git a/junit-platform-console/src/main/java/org/junit/platform/console/options/TestConsoleOutputOptions.java b/junit-platform-console/src/main/java/org/junit/platform/console/options/TestConsoleOutputOptions.java index c0ff2aa5b77d..66574850d1a2 100644 --- a/junit-platform-console/src/main/java/org/junit/platform/console/options/TestConsoleOutputOptions.java +++ b/junit-platform-console/src/main/java/org/junit/platform/console/options/TestConsoleOutputOptions.java @@ -32,6 +32,8 @@ public class TestConsoleOutputOptions { private boolean isSingleColorPalette; private Details details = DEFAULT_DETAILS; private Theme theme = DEFAULT_THEME; + private Path stdoutPath; + private Path stderrPath; public boolean isAnsiColorOutputDisabled() { return this.ansiColorOutputDisabled; @@ -73,4 +75,20 @@ public void setTheme(Theme theme) { this.theme = theme; } + public Path getStdoutPath() { + return this.stdoutPath; + } + + public void setStdoutPath(Path stdoutPath) { + this.stdoutPath = stdoutPath; + } + + public Path getStderrPath() { + return this.stderrPath; + } + + public void setStderrPath(Path stderrPath) { + this.stderrPath = stderrPath; + } + } diff --git a/junit-platform-console/src/main/java/org/junit/platform/console/options/TestConsoleOutputOptionsMixin.java b/junit-platform-console/src/main/java/org/junit/platform/console/options/TestConsoleOutputOptionsMixin.java index 98e3e0cc5560..c2a45f46cf09 100644 --- a/junit-platform-console/src/main/java/org/junit/platform/console/options/TestConsoleOutputOptionsMixin.java +++ b/junit-platform-console/src/main/java/org/junit/platform/console/options/TestConsoleOutputOptionsMixin.java @@ -51,11 +51,25 @@ static class ConsoleOutputOptions { @Option(names = "-details-theme", hidden = true) private Theme theme2 = DEFAULT_THEME; + @Option(names = "--redirect-stdout", paramLabel = "FILE", description = "Redirect tests stdout to a file.") + private Path stdout; + + @Option(names = "-redirect-stdout", hidden = true) + private Path stdout2; + + @Option(names = "--redirect-stderr", paramLabel = "FILE", description = "Redirect tests stderr to a file.") + private Path stderr; + + @Option(names = "-redirect-stderr", hidden = true) + private Path stderr2; + private void applyTo(TestConsoleOutputOptions result) { result.setColorPalettePath(choose(colorPalette, colorPalette2, null)); result.setSingleColorPalette(singleColorPalette || singleColorPalette2); result.setDetails(choose(details, details2, DEFAULT_DETAILS)); result.setTheme(choose(theme, theme2, DEFAULT_THEME)); + result.setStdoutPath(choose(stdout, stdout2, null)); + result.setStderrPath(choose(stderr, stderr2, null)); } } diff --git a/junit-platform-console/src/main/java/org/junit/platform/console/tasks/ConsoleTestExecutor.java b/junit-platform-console/src/main/java/org/junit/platform/console/tasks/ConsoleTestExecutor.java index 1cdddf814e10..2da7c75a233d 100644 --- a/junit-platform-console/src/main/java/org/junit/platform/console/tasks/ConsoleTestExecutor.java +++ b/junit-platform-console/src/main/java/org/junit/platform/console/tasks/ConsoleTestExecutor.java @@ -17,7 +17,9 @@ import java.net.URLClassLoader; import java.nio.file.Path; import java.util.EnumSet; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.function.Supplier; @@ -29,6 +31,7 @@ import org.junit.platform.console.options.TestDiscoveryOptions; import org.junit.platform.console.options.Theme; import org.junit.platform.launcher.Launcher; +import org.junit.platform.launcher.LauncherConstants; import org.junit.platform.launcher.LauncherDiscoveryRequest; import org.junit.platform.launcher.TestExecutionListener; import org.junit.platform.launcher.TestPlan; @@ -98,6 +101,24 @@ private TestExecutionSummary executeTests(PrintWriter out, Optional report Launcher launcher = launcherSupplier.get(); SummaryGeneratingListener summaryListener = registerListeners(out, reportsDir, launcher); + if (isSameFile(outputOptions.getStdoutPath(), outputOptions.getStderrPath())) { + captureMergedStandardStreams(); + } + else { + if (outputOptions.getStdoutPath() != null) { + captureStdout(); + } + if (outputOptions.getStderrPath() != null) { + captureStderr(); + } + } + + if (outputOptions.getStdoutPath() != null || outputOptions.getStderrPath() != null) { + TestExecutionListener redirectionListener = new RedirectStdoutAndStderrListener( + outputOptions.getStdoutPath(), outputOptions.getStderrPath(), out); + launcher.registerTestExecutionListeners(redirectionListener); + } + LauncherDiscoveryRequest discoveryRequest = new DiscoveryRequestCreator().toDiscoveryRequest(discoveryOptions); launcher.execute(discoveryRequest); @@ -135,7 +156,7 @@ private SummaryGeneratingListener registerListeners(PrintWriter out, Optional configParameters = new HashMap<>(discoveryOptions.getConfigurationParameters()); + configParameters.put(LauncherConstants.CAPTURE_STDOUT_PROPERTY_NAME, "true"); + discoveryOptions.setConfigurationParameters(configParameters); + } + + private void captureStderr() { + Map configParameters = new HashMap<>(discoveryOptions.getConfigurationParameters()); + configParameters.put(LauncherConstants.CAPTURE_STDERR_PROPERTY_NAME, "true"); + discoveryOptions.setConfigurationParameters(configParameters); + } + + private void captureMergedStandardStreams() { + Map configParameters = new HashMap<>(discoveryOptions.getConfigurationParameters()); + configParameters.put(LauncherConstants.CAPTURE_MERGED_STANDARD_STREAMS_PROPERTY_NAME, "true"); + discoveryOptions.setConfigurationParameters(configParameters); + } + @FunctionalInterface public interface Factory { ConsoleTestExecutor create(TestDiscoveryOptions discoveryOptions, TestConsoleOutputOptions outputOptions); diff --git a/junit-platform-console/src/main/java/org/junit/platform/console/tasks/RedirectStdoutAndStderrListener.java b/junit-platform-console/src/main/java/org/junit/platform/console/tasks/RedirectStdoutAndStderrListener.java new file mode 100644 index 000000000000..20275b3451d1 --- /dev/null +++ b/junit-platform-console/src/main/java/org/junit/platform/console/tasks/RedirectStdoutAndStderrListener.java @@ -0,0 +1,105 @@ +/* + * Copyright 2015-2024 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.tasks; + +import static org.apiguardian.api.API.Status.INTERNAL; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.io.Writer; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.apiguardian.api.API; +import org.junit.platform.engine.reporting.ReportEntry; +import org.junit.platform.launcher.LauncherConstants; +import org.junit.platform.launcher.TestExecutionListener; +import org.junit.platform.launcher.TestIdentifier; +import org.junit.platform.launcher.TestPlan; + +@API(status = INTERNAL, since = "5.11") +public class RedirectStdoutAndStderrListener implements TestExecutionListener { + private final Path stdoutOutputPath; + private final Path stderrOutputPath; + private final StringWriter stdoutBuffer; + private final StringWriter stderrBuffer; + private final PrintWriter out; + + public RedirectStdoutAndStderrListener(Path stdoutOutputPath, Path stderrOutputPath, PrintWriter out) { + this.stdoutOutputPath = stdoutOutputPath; + this.stderrOutputPath = stderrOutputPath; + this.stdoutBuffer = new StringWriter(); + this.stderrBuffer = new StringWriter(); + this.out = out; + } + + public void reportingEntryPublished(TestIdentifier testIdentifier, ReportEntry entry) { + if (testIdentifier.isTest()) { + String redirectedStdoutContent = entry.getKeyValuePairs().get(LauncherConstants.STDOUT_REPORT_ENTRY_KEY); + String redirectedStderrContent = entry.getKeyValuePairs().get(LauncherConstants.STDERR_REPORT_ENTRY_KEY); + + if (redirectedStdoutContent != null && !redirectedStdoutContent.isEmpty()) { + this.stdoutBuffer.append(redirectedStdoutContent); + } + if (redirectedStderrContent != null && !redirectedStderrContent.isEmpty()) { + this.stderrBuffer.append(redirectedStderrContent); + } + } + } + + public void testPlanExecutionFinished(TestPlan testPlan) { + if (stdoutBuffer.getBuffer().length() > 0) { + flushBufferedOutputToFile(this.stdoutOutputPath, this.stdoutBuffer); + } + if (stderrBuffer.getBuffer().length() > 0) { + flushBufferedOutputToFile(this.stderrOutputPath, this.stderrBuffer); + } + } + + private void flushBufferedOutputToFile(Path file, StringWriter buffer) { + deleteFile(file); + createFile(file); + writeContentToFile(file, buffer.toString()); + } + + private void writeContentToFile(Path file, String buffer) { + try (Writer fileWriter = Files.newBufferedWriter(file)) { + fileWriter.write(buffer); + } + catch (IOException e) { + printException("Failed to write content to file: " + file, e); + } + } + + private void deleteFile(Path file) { + try { + Files.deleteIfExists(file); + } + catch (IOException e) { + printException("Failed to delete file: " + file, e); + } + } + + private void createFile(Path file) { + try { + Files.createFile(file); + } + catch (IOException e) { + printException("Failed to create file: " + file, e); + } + } + + private void printException(String message, Exception exception) { + out.println(message); + exception.printStackTrace(out); + } +} diff --git a/platform-tests/src/test/java/org/junit/platform/console/options/CommandLineOptionsParsingTests.java b/platform-tests/src/test/java/org/junit/platform/console/options/CommandLineOptionsParsingTests.java index 0dd2f08502ae..17ea158dcdf4 100644 --- a/platform-tests/src/test/java/org/junit/platform/console/options/CommandLineOptionsParsingTests.java +++ b/platform-tests/src/test/java/org/junit/platform/console/options/CommandLineOptionsParsingTests.java @@ -15,6 +15,7 @@ import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.platform.engine.discovery.ClassNameFilter.STANDARD_INCLUDE_PATTERN; @@ -56,23 +57,25 @@ void parseNoArguments() { // @formatter:off assertAll( () -> assertFalse(options.output.isAnsiColorOutputDisabled()), - () -> assertEquals(TestConsoleOutputOptions.DEFAULT_DETAILS, options.output.getDetails()), - () -> assertFalse(options.discovery.isScanClasspath()), - () -> assertEquals(List.of(STANDARD_INCLUDE_PATTERN), options.discovery.getIncludedClassNamePatterns()), - () -> assertEquals(List.of(), options.discovery.getExcludedClassNamePatterns()), - () -> assertEquals(List.of(), options.discovery.getIncludedPackages()), - () -> assertEquals(List.of(), options.discovery.getExcludedPackages()), - () -> assertEquals(List.of(), options.discovery.getIncludedTagExpressions()), - () -> assertEquals(List.of(), options.discovery.getExcludedTagExpressions()), - () -> assertEquals(List.of(), options.discovery.getAdditionalClasspathEntries()), - () -> assertEquals(List.of(), options.discovery.getSelectedUris()), - () -> assertEquals(List.of(), options.discovery.getSelectedFiles()), - () -> assertEquals(List.of(), options.discovery.getSelectedDirectories()), - () -> assertEquals(List.of(), options.discovery.getSelectedModules()), - () -> assertEquals(List.of(), options.discovery.getSelectedPackages()), - () -> assertEquals(List.of(), options.discovery.getSelectedMethods()), - () -> assertEquals(List.of(), options.discovery.getSelectedClasspathEntries()), - () -> assertEquals(Map.of(), options.discovery.getConfigurationParameters()) + () -> assertNull(options.output.getStdoutPath()), + () -> assertNull(options.output.getStderrPath()), + () -> assertEquals(TestConsoleOutputOptions.DEFAULT_DETAILS, options.output.getDetails()), + () -> assertFalse(options.discovery.isScanClasspath()), + () -> assertEquals(List.of(STANDARD_INCLUDE_PATTERN), options.discovery.getIncludedClassNamePatterns()), + () -> assertEquals(List.of(), options.discovery.getExcludedClassNamePatterns()), + () -> assertEquals(List.of(), options.discovery.getIncludedPackages()), + () -> assertEquals(List.of(), options.discovery.getExcludedPackages()), + () -> assertEquals(List.of(), options.discovery.getIncludedTagExpressions()), + () -> assertEquals(List.of(), options.discovery.getExcludedTagExpressions()), + () -> assertEquals(List.of(), options.discovery.getAdditionalClasspathEntries()), + () -> assertEquals(List.of(), options.discovery.getSelectedUris()), + () -> assertEquals(List.of(), options.discovery.getSelectedFiles()), + () -> assertEquals(List.of(), options.discovery.getSelectedDirectories()), + () -> assertEquals(List.of(), options.discovery.getSelectedModules()), + () -> assertEquals(List.of(), options.discovery.getSelectedPackages()), + () -> assertEquals(List.of(), options.discovery.getSelectedMethods()), + () -> assertEquals(List.of(), options.discovery.getSelectedClasspathEntries()), + () -> assertEquals(Map.of(), options.discovery.getConfigurationParameters()) ); // @formatter:on } @@ -541,6 +544,48 @@ void parseInvalidConfigurationParameters() { assertOptionWithMissingRequiredArgumentThrowsException("-config", "--config"); } + @ParameterizedTest + @EnumSource + void parseValidStdoutRedirectionFile(ArgsType type) { + var file = Paths.get("foo.txt"); + // @formatter:off + assertAll( + () -> assertNull(type.parseArgLine("").output.getStdoutPath()), + () -> assertEquals(file, type.parseArgLine("--redirect-stdout=foo.txt").output.getStdoutPath()), + () -> assertEquals(file, type.parseArgLine("-redirect-stdout=foo.txt").output.getStdoutPath()), + () -> assertEquals(file, type.parseArgLine("--redirect-stdout foo.txt").output.getStdoutPath()), + () -> assertEquals(file, type.parseArgLine("-redirect-stdout foo.txt").output.getStdoutPath()), + () -> assertEquals(file, type.parseArgLine("-redirect-stdout bar.txt -redirect-stdout foo.txt").output.getStdoutPath()) + ); + // @formatter:on + } + + @Test + void parseInvalidStdoutRedirectionFile() { + assertOptionWithMissingRequiredArgumentThrowsException("--redirect-stdout", "-redirect-stdout"); + } + + @ParameterizedTest + @EnumSource + void parseValidStderrRedirectionFile(ArgsType type) { + var file = Paths.get("foo.txt"); + // @formatter:off + assertAll( + () -> assertNull(type.parseArgLine("").output.getStderrPath()), + () -> assertEquals(file, type.parseArgLine("--redirect-stderr=foo.txt").output.getStderrPath()), + () -> assertEquals(file, type.parseArgLine("-redirect-stderr=foo.txt").output.getStderrPath()), + () -> assertEquals(file, type.parseArgLine("--redirect-stderr foo.txt").output.getStderrPath()), + () -> assertEquals(file, type.parseArgLine("-redirect-stderr foo.txt").output.getStderrPath()), + () -> assertEquals(file, type.parseArgLine("-redirect-stderr bar.txt -redirect-stderr foo.txt").output.getStderrPath()) + ); + // @formatter:on + } + + @Test + void parseInvalidStderrRedirectionFile() { + assertOptionWithMissingRequiredArgumentThrowsException("--redirect-stderr", "-redirect-stderr"); + } + @ParameterizedTest @EnumSource void parseInvalidConfigurationParametersWithDuplicateKey(ArgsType type) {