diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-5.13.0-M1.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-5.13.0-M1.adoc index 3c513d5f7ab8..8c3a8e535760 100644 --- a/documentation/src/docs/asciidoc/release-notes/release-notes-5.13.0-M1.adoc +++ b/documentation/src/docs/asciidoc/release-notes/release-notes-5.13.0-M1.adoc @@ -26,7 +26,8 @@ repository on GitHub. [[release-notes-5.13.0-M1-junit-platform-new-features-and-improvements]] ==== New Features and Improvements -* ❓ +* New optional CLI options `--redirect-stdout` and `--redirect-stderr` to redirect stdout + and stderr outputs to a file. [[release-notes-5.13.0-M1-junit-jupiter]] diff --git a/documentation/src/docs/asciidoc/user-guide/advanced-topics/junit-platform-reporting.adoc b/documentation/src/docs/asciidoc/user-guide/advanced-topics/junit-platform-reporting.adoc index 2e5b4d7369f1..453b015e85c7 100644 --- a/documentation/src/docs/asciidoc/user-guide/advanced-topics/junit-platform-reporting.adoc +++ b/documentation/src/docs/asciidoc/user-guide/advanced-topics/junit-platform-reporting.adoc @@ -154,6 +154,21 @@ $ java -jar junit-platform-console-standalone-{platform-version}.jar \ --config-resource=configuration.properties ---- +You can redirect standard output and standard error using the `--redirect-stdout` and +`--redirect-stderr` options: + +[source,console,subs=attributes+] +---- +$ java -jar junit-platform-console-standalone-{platform-version}.jar \ + --redirect-stdout=foo.txt \ + --redirect-stderr=bar.txt +---- + +If both the `--redirect-stdout` and `--redirect-stderr` parameters point to the same +file, the output will be merged into that file. + +The default charset is used for writing to the files. + [[junit-platform-reporting-legacy-xml]] ==== Legacy XML format 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 06067a1022f5..73f4fc4a2416 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,24 @@ public void setTheme(Theme theme) { this.theme = theme; } + @API(status = INTERNAL, since = "1.13") + public Path getStdoutPath() { + return this.stdoutPath; + } + + @API(status = INTERNAL, since = "1.13") + public void setStdoutPath(Path stdoutPath) { + this.stdoutPath = stdoutPath; + } + + @API(status = INTERNAL, since = "1.13") + public Path getStderrPath() { + return this.stderrPath; + } + + @API(status = INTERNAL, since = "1.13") + 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 44ee07588e4a..81089ddc8f23 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,19 @@ static class ConsoleOutputOptions { @Option(names = "-details-theme", hidden = true) private final Theme theme2 = DEFAULT_THEME; + @Option(names = "--redirect-stdout", paramLabel = "FILE", description = "Redirect test output to stdout to a file.") + private Path stdout; + + @Option(names = "--redirect-stderr", paramLabel = "FILE", description = "Redirect test output to stderr to a file.") + private Path stderr; + 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(stdout); + result.setStderrPath(stderr); } } 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 ab64005eded8..9ff9ccdf6396 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 @@ -14,6 +14,7 @@ import static org.junit.platform.console.tasks.DiscoveryRequestCreator.toDiscoveryRequestBuilder; import static org.junit.platform.launcher.LauncherConstants.OUTPUT_DIR_PROPERTY_NAME; +import java.io.PrintStream; import java.io.PrintWriter; import java.net.URL; import java.net.URLClassLoader; @@ -101,10 +102,16 @@ private TestExecutionSummary executeTests(PrintWriter out, Optional report Launcher launcher = launcherSupplier.get(); SummaryGeneratingListener summaryListener = registerListeners(out, reportsDir, launcher); - LauncherDiscoveryRequestBuilder discoveryRequestBuilder = toDiscoveryRequestBuilder(discoveryOptions); - reportsDir.ifPresent(dir -> discoveryRequestBuilder.configurationParameter(OUTPUT_DIR_PROPERTY_NAME, - dir.toAbsolutePath().toString())); - launcher.execute(discoveryRequestBuilder.build()); + PrintStream originalOut = System.out; + PrintStream originalErr = System.err; + try (StdStreamHandler stdStreamHandler = new StdStreamHandler()) { + stdStreamHandler.redirectStdStreams(outputOptions.getStdoutPath(), outputOptions.getStderrPath()); + launchTests(launcher, reportsDir); + } + finally { + System.setOut(originalOut); + System.setErr(originalErr); + } TestExecutionSummary summary = summaryListener.getSummary(); if (summary.getTotalFailureCount() > 0 || outputOptions.getDetails() != Details.NONE) { @@ -114,6 +121,13 @@ private TestExecutionSummary executeTests(PrintWriter out, Optional report return summary; } + private void launchTests(Launcher launcher, Optional reportsDir) { + LauncherDiscoveryRequestBuilder discoveryRequestBuilder = toDiscoveryRequestBuilder(discoveryOptions); + reportsDir.ifPresent(dir -> discoveryRequestBuilder.configurationParameter(OUTPUT_DIR_PROPERTY_NAME, + dir.toAbsolutePath().toString())); + launcher.execute(discoveryRequestBuilder.build()); + } + private Optional createCustomClassLoader() { List additionalClasspathEntries = discoveryOptions.getExistingAdditionalClasspathEntries(); if (!additionalClasspathEntries.isEmpty()) { diff --git a/junit-platform-console/src/main/java/org/junit/platform/console/tasks/StdStreamHandler.java b/junit-platform-console/src/main/java/org/junit/platform/console/tasks/StdStreamHandler.java new file mode 100644 index 000000000000..cf6c2105a7c0 --- /dev/null +++ b/junit-platform-console/src/main/java/org/junit/platform/console/tasks/StdStreamHandler.java @@ -0,0 +1,93 @@ +/* + * Copyright 2015-2025 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 java.io.IOException; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.platform.commons.JUnitException; + +class StdStreamHandler implements AutoCloseable { + private PrintStream stdout; + private PrintStream stderr; + + public StdStreamHandler() { + } + + private boolean isSameFile(Path path1, Path path2) { + if (path1 == null || path2 == null) + return false; + return (path1.normalize().toAbsolutePath().equals(path2.normalize().toAbsolutePath())); + } + + /** + * Redirects standard output (stdout) and standard error (stderr) to the specified file paths. + * If the paths are the same, both streams are redirected to the same file. + * The default charset is used for writing to the files. + * + * @param stdoutPath The file path for standard output. {@code null} means no redirection. + * @param stderrPath The file path for standard error. {@code null} means no redirection. + */ + public void redirectStdStreams(Path stdoutPath, Path stderrPath) { + if (isSameFile(stdoutPath, stderrPath)) { + try { + PrintStream commonStream = new PrintStream(Files.newOutputStream(stdoutPath), true); + this.stdout = commonStream; + this.stderr = commonStream; + } + catch (IOException e) { + throw new JUnitException("Error setting up stream for Stdout and Stderr at path: " + stdoutPath, e); + } + } + else { + if (stdoutPath != null) { + try { + this.stdout = new PrintStream(Files.newOutputStream(stdoutPath), true); + } + catch (IOException e) { + throw new JUnitException("Error setting up stream for Stdout at path: " + stdoutPath, e); + } + } + + if (stderrPath != null) { + try { + this.stderr = new PrintStream(Files.newOutputStream(stderrPath), true); + } + catch (IOException e) { + throw new JUnitException("Error setting up stream for Stderr at path: " + stderrPath, e); + } + } + } + + if (stdout != null) { + System.setOut(stdout); + } + if (stderr != null) { + System.setErr(stderr); + } + } + + @Override + public void close() { + try { + if (stdout != null) { + stdout.close(); + } + } + finally { + if (stderr != null) { + stderr.close(); + } + } + } +} diff --git a/platform-tests/src/test/java/org/junit/platform/console/ConsoleLauncherIntegrationTests.java b/platform-tests/src/test/java/org/junit/platform/console/ConsoleLauncherIntegrationTests.java index a9f0be73a810..c4acff2d3964 100644 --- a/platform-tests/src/test/java/org/junit/platform/console/ConsoleLauncherIntegrationTests.java +++ b/platform-tests/src/test/java/org/junit/platform/console/ConsoleLauncherIntegrationTests.java @@ -14,10 +14,20 @@ import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; +import org.junit.platform.console.options.StdStreamTestCase; /** * @since 1.0 @@ -92,4 +102,35 @@ void executeScanModules(final String line) { assertEquals(0, new ConsoleLauncherWrapper().execute(args1).getTestsFoundCount()); } + private static Stream redirectStreamParams() { + return Stream.of(Arguments.of("--redirect-stdout", StdStreamTestCase.getStdoutOutputFileSize()), + Arguments.of("--redirect-stderr", StdStreamTestCase.getStderrOutputFileSize())); + } + + @ParameterizedTest + @MethodSource("redirectStreamParams") + void executeWithRedirectedStdStream(String redirectedStream, int outputFileSize, @TempDir Path tempDir) + throws IOException { + Path outputFile = tempDir.resolve("output.txt"); + var line = String.format("execute -e junit-jupiter --select-class %s %s %s", StdStreamTestCase.class.getName(), + redirectedStream, outputFile); + var args = line.split(" "); + new ConsoleLauncherWrapper().execute(args); + + assertTrue(Files.exists(outputFile), "File does not exist."); + assertEquals(outputFileSize, Files.size(outputFile), "Invalid file size."); + } + + @Test + void executeWithRedirectedStdStreamsToSameFile(@TempDir Path tempDir) throws IOException { + Path outputFile = tempDir.resolve("output.txt"); + var line = String.format("execute -e junit-jupiter --select-class %s --redirect-stdout %s --redirect-stderr %s", + StdStreamTestCase.class.getName(), outputFile, outputFile); + var args = line.split(" "); + new ConsoleLauncherWrapper().execute(args); + + assertTrue(Files.exists(outputFile), "File does not exist."); + assertEquals(StdStreamTestCase.getStdoutOutputFileSize() + StdStreamTestCase.getStderrOutputFileSize(), + Files.size(outputFile), "Invalid file size."); + } } 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 0745f27a914a..67573f23d1eb 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; @@ -60,6 +61,8 @@ void parseNoArguments() { // @formatter:off assertAll( () -> assertFalse(options.output.isAnsiColorOutputDisabled()), + () -> 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()), @@ -632,6 +635,44 @@ 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 bar.txt --redirect-stdout foo.txt").output.getStdoutPath()) + ); + // @formatter:on + } + + @Test + void parseInvalidStdoutRedirectionFile() { + assertOptionWithMissingRequiredArgumentThrowsException("--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 bar.txt --redirect-stderr foo.txt").output.getStderrPath()) + ); + // @formatter:on + } + + @Test + void parseInvalidStderrRedirectionFile() { + assertOptionWithMissingRequiredArgumentThrowsException("--redirect-stderr"); + } + @Test void parseInvalidConfigurationParametersResource() { assertOptionWithMissingRequiredArgumentThrowsException("--config-resource"); diff --git a/platform-tests/src/test/java/org/junit/platform/console/options/StdStreamTestCase.java b/platform-tests/src/test/java/org/junit/platform/console/options/StdStreamTestCase.java new file mode 100644 index 000000000000..e3e14ceb1322 --- /dev/null +++ b/platform-tests/src/test/java/org/junit/platform/console/options/StdStreamTestCase.java @@ -0,0 +1,33 @@ +/* + * Copyright 2015-2025 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.options; + +import org.junit.jupiter.api.Test; + +public class StdStreamTestCase { + + private static final String STDOUT_DATA = "Writing to STDOUT..."; + private static final String STDERR_DATA = "Writing to STDERR..."; + + public static int getStdoutOutputFileSize() { + return STDOUT_DATA.length(); + } + + public static int getStderrOutputFileSize() { + return STDERR_DATA.length(); + } + + @Test + void printTest() { + System.out.print(STDOUT_DATA); + System.err.print(STDERR_DATA); + } +} diff --git a/platform-tooling-support-tests/src/archUnit/java/platform/tooling/support/tests/ArchUnitTests.java b/platform-tooling-support-tests/src/archUnit/java/platform/tooling/support/tests/ArchUnitTests.java index 1d51755ede76..005c5e1b5fec 100644 --- a/platform-tooling-support-tests/src/archUnit/java/platform/tooling/support/tests/ArchUnitTests.java +++ b/platform-tooling-support-tests/src/archUnit/java/platform/tooling/support/tests/ArchUnitTests.java @@ -104,6 +104,7 @@ void avoidAccessingStandardStreams(JavaClasses classes) { // ConsoleLauncher, StreamInterceptor, Picocli et al... var subset = classes // .that(are(not(name("org.junit.platform.console.ConsoleLauncher")))) // + .that(are(not(name("org.junit.platform.console.tasks.ConsoleTestExecutor")))) // .that(are(not(name("org.junit.platform.launcher.core.StreamInterceptor")))) // .that(are(not(name("org.junit.platform.runner.JUnitPlatformRunnerListener")))) // .that(are(not(name("org.junit.platform.testkit.engine.Events")))) //