Skip to content

Commit f071de7

Browse files
authored
Add ConsoleLauncher options for redirecting output to files
Add new CLI options `--redirect-stdout` and `--redirect-stderr` to redirect stdout and stderr, respectively, to a file. If both are set to the same file, stdout and stderr will be merged. Resolves #3166.
1 parent af6afb6 commit f071de7

File tree

10 files changed

+274
-5
lines changed

10 files changed

+274
-5
lines changed

documentation/src/docs/asciidoc/release-notes/release-notes-5.13.0-M1.adoc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ repository on GitHub.
2626
[[release-notes-5.13.0-M1-junit-platform-new-features-and-improvements]]
2727
==== New Features and Improvements
2828

29-
* ❓
29+
* New optional CLI options `--redirect-stdout` and `--redirect-stderr` to redirect stdout
30+
and stderr outputs to a file.
3031

3132

3233
[[release-notes-5.13.0-M1-junit-jupiter]]

documentation/src/docs/asciidoc/user-guide/advanced-topics/junit-platform-reporting.adoc

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,21 @@ $ java -jar junit-platform-console-standalone-{platform-version}.jar <OPTIONS> \
154154
--config-resource=configuration.properties
155155
----
156156

157+
You can redirect standard output and standard error using the `--redirect-stdout` and
158+
`--redirect-stderr` options:
159+
160+
[source,console,subs=attributes+]
161+
----
162+
$ java -jar junit-platform-console-standalone-{platform-version}.jar <OPTIONS> \
163+
--redirect-stdout=foo.txt \
164+
--redirect-stderr=bar.txt
165+
----
166+
167+
If both the `--redirect-stdout` and `--redirect-stderr` parameters point to the same
168+
file, the output will be merged into that file.
169+
170+
The default charset is used for writing to the files.
171+
157172
[[junit-platform-reporting-legacy-xml]]
158173
==== Legacy XML format
159174

junit-platform-console/src/main/java/org/junit/platform/console/options/TestConsoleOutputOptions.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ public class TestConsoleOutputOptions {
3232
private boolean isSingleColorPalette;
3333
private Details details = DEFAULT_DETAILS;
3434
private Theme theme = DEFAULT_THEME;
35+
private Path stdoutPath;
36+
private Path stderrPath;
3537

3638
public boolean isAnsiColorOutputDisabled() {
3739
return this.ansiColorOutputDisabled;
@@ -73,4 +75,24 @@ public void setTheme(Theme theme) {
7375
this.theme = theme;
7476
}
7577

78+
@API(status = INTERNAL, since = "1.13")
79+
public Path getStdoutPath() {
80+
return this.stdoutPath;
81+
}
82+
83+
@API(status = INTERNAL, since = "1.13")
84+
public void setStdoutPath(Path stdoutPath) {
85+
this.stdoutPath = stdoutPath;
86+
}
87+
88+
@API(status = INTERNAL, since = "1.13")
89+
public Path getStderrPath() {
90+
return this.stderrPath;
91+
}
92+
93+
@API(status = INTERNAL, since = "1.13")
94+
public void setStderrPath(Path stderrPath) {
95+
this.stderrPath = stderrPath;
96+
}
97+
7698
}

junit-platform-console/src/main/java/org/junit/platform/console/options/TestConsoleOutputOptionsMixin.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,19 @@ static class ConsoleOutputOptions {
5151
@Option(names = "-details-theme", hidden = true)
5252
private final Theme theme2 = DEFAULT_THEME;
5353

54+
@Option(names = "--redirect-stdout", paramLabel = "FILE", description = "Redirect test output to stdout to a file.")
55+
private Path stdout;
56+
57+
@Option(names = "--redirect-stderr", paramLabel = "FILE", description = "Redirect test output to stderr to a file.")
58+
private Path stderr;
59+
5460
private void applyTo(TestConsoleOutputOptions result) {
5561
result.setColorPalettePath(choose(colorPalette, colorPalette2, null));
5662
result.setSingleColorPalette(singleColorPalette || singleColorPalette2);
5763
result.setDetails(choose(details, details2, DEFAULT_DETAILS));
5864
result.setTheme(choose(theme, theme2, DEFAULT_THEME));
65+
result.setStdoutPath(stdout);
66+
result.setStderrPath(stderr);
5967
}
6068
}
6169

junit-platform-console/src/main/java/org/junit/platform/console/tasks/ConsoleTestExecutor.java

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import static org.junit.platform.console.tasks.DiscoveryRequestCreator.toDiscoveryRequestBuilder;
1515
import static org.junit.platform.launcher.LauncherConstants.OUTPUT_DIR_PROPERTY_NAME;
1616

17+
import java.io.PrintStream;
1718
import java.io.PrintWriter;
1819
import java.net.URL;
1920
import java.net.URLClassLoader;
@@ -101,10 +102,16 @@ private TestExecutionSummary executeTests(PrintWriter out, Optional<Path> report
101102
Launcher launcher = launcherSupplier.get();
102103
SummaryGeneratingListener summaryListener = registerListeners(out, reportsDir, launcher);
103104

104-
LauncherDiscoveryRequestBuilder discoveryRequestBuilder = toDiscoveryRequestBuilder(discoveryOptions);
105-
reportsDir.ifPresent(dir -> discoveryRequestBuilder.configurationParameter(OUTPUT_DIR_PROPERTY_NAME,
106-
dir.toAbsolutePath().toString()));
107-
launcher.execute(discoveryRequestBuilder.build());
105+
PrintStream originalOut = System.out;
106+
PrintStream originalErr = System.err;
107+
try (StdStreamHandler stdStreamHandler = new StdStreamHandler()) {
108+
stdStreamHandler.redirectStdStreams(outputOptions.getStdoutPath(), outputOptions.getStderrPath());
109+
launchTests(launcher, reportsDir);
110+
}
111+
finally {
112+
System.setOut(originalOut);
113+
System.setErr(originalErr);
114+
}
108115

109116
TestExecutionSummary summary = summaryListener.getSummary();
110117
if (summary.getTotalFailureCount() > 0 || outputOptions.getDetails() != Details.NONE) {
@@ -114,6 +121,13 @@ private TestExecutionSummary executeTests(PrintWriter out, Optional<Path> report
114121
return summary;
115122
}
116123

124+
private void launchTests(Launcher launcher, Optional<Path> reportsDir) {
125+
LauncherDiscoveryRequestBuilder discoveryRequestBuilder = toDiscoveryRequestBuilder(discoveryOptions);
126+
reportsDir.ifPresent(dir -> discoveryRequestBuilder.configurationParameter(OUTPUT_DIR_PROPERTY_NAME,
127+
dir.toAbsolutePath().toString()));
128+
launcher.execute(discoveryRequestBuilder.build());
129+
}
130+
117131
private Optional<ClassLoader> createCustomClassLoader() {
118132
List<Path> additionalClasspathEntries = discoveryOptions.getExistingAdditionalClasspathEntries();
119133
if (!additionalClasspathEntries.isEmpty()) {
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/*
2+
* Copyright 2015-2025 the original author or authors.
3+
*
4+
* All rights reserved. This program and the accompanying materials are
5+
* made available under the terms of the Eclipse Public License v2.0 which
6+
* accompanies this distribution and is available at
7+
*
8+
* https://www.eclipse.org/legal/epl-v20.html
9+
*/
10+
11+
package org.junit.platform.console.tasks;
12+
13+
import java.io.IOException;
14+
import java.io.PrintStream;
15+
import java.nio.file.Files;
16+
import java.nio.file.Path;
17+
18+
import org.junit.platform.commons.JUnitException;
19+
20+
class StdStreamHandler implements AutoCloseable {
21+
private PrintStream stdout;
22+
private PrintStream stderr;
23+
24+
public StdStreamHandler() {
25+
}
26+
27+
private boolean isSameFile(Path path1, Path path2) {
28+
if (path1 == null || path2 == null)
29+
return false;
30+
return (path1.normalize().toAbsolutePath().equals(path2.normalize().toAbsolutePath()));
31+
}
32+
33+
/**
34+
* Redirects standard output (stdout) and standard error (stderr) to the specified file paths.
35+
* If the paths are the same, both streams are redirected to the same file.
36+
* The default charset is used for writing to the files.
37+
*
38+
* @param stdoutPath The file path for standard output. {@code null} means no redirection.
39+
* @param stderrPath The file path for standard error. {@code null} means no redirection.
40+
*/
41+
public void redirectStdStreams(Path stdoutPath, Path stderrPath) {
42+
if (isSameFile(stdoutPath, stderrPath)) {
43+
try {
44+
PrintStream commonStream = new PrintStream(Files.newOutputStream(stdoutPath), true);
45+
this.stdout = commonStream;
46+
this.stderr = commonStream;
47+
}
48+
catch (IOException e) {
49+
throw new JUnitException("Error setting up stream for Stdout and Stderr at path: " + stdoutPath, e);
50+
}
51+
}
52+
else {
53+
if (stdoutPath != null) {
54+
try {
55+
this.stdout = new PrintStream(Files.newOutputStream(stdoutPath), true);
56+
}
57+
catch (IOException e) {
58+
throw new JUnitException("Error setting up stream for Stdout at path: " + stdoutPath, e);
59+
}
60+
}
61+
62+
if (stderrPath != null) {
63+
try {
64+
this.stderr = new PrintStream(Files.newOutputStream(stderrPath), true);
65+
}
66+
catch (IOException e) {
67+
throw new JUnitException("Error setting up stream for Stderr at path: " + stderrPath, e);
68+
}
69+
}
70+
}
71+
72+
if (stdout != null) {
73+
System.setOut(stdout);
74+
}
75+
if (stderr != null) {
76+
System.setErr(stderr);
77+
}
78+
}
79+
80+
@Override
81+
public void close() {
82+
try {
83+
if (stdout != null) {
84+
stdout.close();
85+
}
86+
}
87+
finally {
88+
if (stderr != null) {
89+
stderr.close();
90+
}
91+
}
92+
}
93+
}

platform-tests/src/test/java/org/junit/platform/console/ConsoleLauncherIntegrationTests.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,20 @@
1414
import static org.junit.jupiter.api.Assertions.assertAll;
1515
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
1616
import static org.junit.jupiter.api.Assertions.assertEquals;
17+
import static org.junit.jupiter.api.Assertions.assertTrue;
18+
19+
import java.io.IOException;
20+
import java.nio.file.Files;
21+
import java.nio.file.Path;
22+
import java.util.stream.Stream;
1723

1824
import org.junit.jupiter.api.Test;
25+
import org.junit.jupiter.api.io.TempDir;
1926
import org.junit.jupiter.params.ParameterizedTest;
27+
import org.junit.jupiter.params.provider.Arguments;
28+
import org.junit.jupiter.params.provider.MethodSource;
2029
import org.junit.jupiter.params.provider.ValueSource;
30+
import org.junit.platform.console.options.StdStreamTestCase;
2131

2232
/**
2333
* @since 1.0
@@ -92,4 +102,35 @@ void executeScanModules(final String line) {
92102
assertEquals(0, new ConsoleLauncherWrapper().execute(args1).getTestsFoundCount());
93103
}
94104

105+
private static Stream<Arguments> redirectStreamParams() {
106+
return Stream.of(Arguments.of("--redirect-stdout", StdStreamTestCase.getStdoutOutputFileSize()),
107+
Arguments.of("--redirect-stderr", StdStreamTestCase.getStderrOutputFileSize()));
108+
}
109+
110+
@ParameterizedTest
111+
@MethodSource("redirectStreamParams")
112+
void executeWithRedirectedStdStream(String redirectedStream, int outputFileSize, @TempDir Path tempDir)
113+
throws IOException {
114+
Path outputFile = tempDir.resolve("output.txt");
115+
var line = String.format("execute -e junit-jupiter --select-class %s %s %s", StdStreamTestCase.class.getName(),
116+
redirectedStream, outputFile);
117+
var args = line.split(" ");
118+
new ConsoleLauncherWrapper().execute(args);
119+
120+
assertTrue(Files.exists(outputFile), "File does not exist.");
121+
assertEquals(outputFileSize, Files.size(outputFile), "Invalid file size.");
122+
}
123+
124+
@Test
125+
void executeWithRedirectedStdStreamsToSameFile(@TempDir Path tempDir) throws IOException {
126+
Path outputFile = tempDir.resolve("output.txt");
127+
var line = String.format("execute -e junit-jupiter --select-class %s --redirect-stdout %s --redirect-stderr %s",
128+
StdStreamTestCase.class.getName(), outputFile, outputFile);
129+
var args = line.split(" ");
130+
new ConsoleLauncherWrapper().execute(args);
131+
132+
assertTrue(Files.exists(outputFile), "File does not exist.");
133+
assertEquals(StdStreamTestCase.getStdoutOutputFileSize() + StdStreamTestCase.getStderrOutputFileSize(),
134+
Files.size(outputFile), "Invalid file size.");
135+
}
95136
}

platform-tests/src/test/java/org/junit/platform/console/options/CommandLineOptionsParsingTests.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import static org.junit.jupiter.api.Assertions.assertAll;
1616
import static org.junit.jupiter.api.Assertions.assertEquals;
1717
import static org.junit.jupiter.api.Assertions.assertFalse;
18+
import static org.junit.jupiter.api.Assertions.assertNull;
1819
import static org.junit.jupiter.api.Assertions.assertThrows;
1920
import static org.junit.jupiter.api.Assertions.assertTrue;
2021
import static org.junit.platform.engine.discovery.ClassNameFilter.STANDARD_INCLUDE_PATTERN;
@@ -60,6 +61,8 @@ void parseNoArguments() {
6061
// @formatter:off
6162
assertAll(
6263
() -> assertFalse(options.output.isAnsiColorOutputDisabled()),
64+
() -> assertNull(options.output.getStdoutPath()),
65+
() -> assertNull(options.output.getStderrPath()),
6366
() -> assertEquals(TestConsoleOutputOptions.DEFAULT_DETAILS, options.output.getDetails()),
6467
() -> assertFalse(options.discovery.isScanClasspath()),
6568
() -> assertEquals(List.of(STANDARD_INCLUDE_PATTERN), options.discovery.getIncludedClassNamePatterns()),
@@ -632,6 +635,44 @@ void parseInvalidConfigurationParameters() {
632635
assertOptionWithMissingRequiredArgumentThrowsException("-config", "--config");
633636
}
634637

638+
@ParameterizedTest
639+
@EnumSource
640+
void parseValidStdoutRedirectionFile(ArgsType type) {
641+
var file = Paths.get("foo.txt");
642+
// @formatter:off
643+
assertAll(
644+
() -> assertNull(type.parseArgLine("").output.getStdoutPath()),
645+
() -> assertEquals(file, type.parseArgLine("--redirect-stdout=foo.txt").output.getStdoutPath()),
646+
() -> assertEquals(file, type.parseArgLine("--redirect-stdout foo.txt").output.getStdoutPath()),
647+
() -> assertEquals(file, type.parseArgLine("--redirect-stdout bar.txt --redirect-stdout foo.txt").output.getStdoutPath())
648+
);
649+
// @formatter:on
650+
}
651+
652+
@Test
653+
void parseInvalidStdoutRedirectionFile() {
654+
assertOptionWithMissingRequiredArgumentThrowsException("--redirect-stdout");
655+
}
656+
657+
@ParameterizedTest
658+
@EnumSource
659+
void parseValidStderrRedirectionFile(ArgsType type) {
660+
var file = Paths.get("foo.txt");
661+
// @formatter:off
662+
assertAll(
663+
() -> assertNull(type.parseArgLine("").output.getStderrPath()),
664+
() -> assertEquals(file, type.parseArgLine("--redirect-stderr=foo.txt").output.getStderrPath()),
665+
() -> assertEquals(file, type.parseArgLine("--redirect-stderr foo.txt").output.getStderrPath()),
666+
() -> assertEquals(file, type.parseArgLine("--redirect-stderr bar.txt --redirect-stderr foo.txt").output.getStderrPath())
667+
);
668+
// @formatter:on
669+
}
670+
671+
@Test
672+
void parseInvalidStderrRedirectionFile() {
673+
assertOptionWithMissingRequiredArgumentThrowsException("--redirect-stderr");
674+
}
675+
635676
@Test
636677
void parseInvalidConfigurationParametersResource() {
637678
assertOptionWithMissingRequiredArgumentThrowsException("--config-resource");
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright 2015-2025 the original author or authors.
3+
*
4+
* All rights reserved. This program and the accompanying materials are
5+
* made available under the terms of the Eclipse Public License v2.0 which
6+
* accompanies this distribution and is available at
7+
*
8+
* https://www.eclipse.org/legal/epl-v20.html
9+
*/
10+
11+
package org.junit.platform.console.options;
12+
13+
import org.junit.jupiter.api.Test;
14+
15+
public class StdStreamTestCase {
16+
17+
private static final String STDOUT_DATA = "Writing to STDOUT...";
18+
private static final String STDERR_DATA = "Writing to STDERR...";
19+
20+
public static int getStdoutOutputFileSize() {
21+
return STDOUT_DATA.length();
22+
}
23+
24+
public static int getStderrOutputFileSize() {
25+
return STDERR_DATA.length();
26+
}
27+
28+
@Test
29+
void printTest() {
30+
System.out.print(STDOUT_DATA);
31+
System.err.print(STDERR_DATA);
32+
}
33+
}

platform-tooling-support-tests/src/archUnit/java/platform/tooling/support/tests/ArchUnitTests.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ void avoidAccessingStandardStreams(JavaClasses classes) {
104104
// ConsoleLauncher, StreamInterceptor, Picocli et al...
105105
var subset = classes //
106106
.that(are(not(name("org.junit.platform.console.ConsoleLauncher")))) //
107+
.that(are(not(name("org.junit.platform.console.tasks.ConsoleTestExecutor")))) //
107108
.that(are(not(name("org.junit.platform.launcher.core.StreamInterceptor")))) //
108109
.that(are(not(name("org.junit.platform.runner.JUnitPlatformRunnerListener")))) //
109110
.that(are(not(name("org.junit.platform.testkit.engine.Events")))) //

0 commit comments

Comments
 (0)