Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce --redirect-stdout and --redirect-stderr CLI options to redirect STDOUT and STDERR #3637

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -31,7 +31,9 @@ repository on GitHub.
-- for use in third-party extensions and test engines.
* Error messages for type mismatches in `NamespacedHierarchicalStore` now include the
actual type and value in addition to the required type.

* New optional config parameter `junit.platform.output.capture.merge` that can be used to merge stdout and stderr and
capture it as stdout.
* New optional CLI options `--redirect-stdout` and `--redirect-stderr` to redirect stdout and stderr outputs to a file.

[[release-notes-5.11.0-M1-junit-jupiter]]
=== JUnit Jupiter
Expand Down
Expand Up @@ -1019,14 +1019,17 @@ expressions can be useful.
Since version 1.3, the JUnit Platform provides opt-in support for capturing output
printed to `System.out` and `System.err`. To enable it, set the
`junit.platform.output.capture.stdout` and/or `junit.platform.output.capture.stderr`
<<running-tests-config-params, configuration parameter>> to `true`. And since version 5.11
you can capture a merged stdout/stderr output by setting the `junit.platform.output.capture.merge`
<<running-tests-config-params, configuration parameter>> to `true`. In addition, you may
configure the maximum number of buffered bytes to be used per executed test or container
using `junit.platform.output.capture.maxBuffer`.

If enabled, the JUnit Platform captures the corresponding output and publishes it as a
report entry using the `stdout` or `stderr` keys to all registered
`{TestExecutionListener}` instances immediately before reporting the test or container as
finished.
finished, in case of a `junit.platform.output.capture.merge` <<running-tests-config-params, configuration parameter>>,
the merged output will be published as a report entry using the `stdout` key.

Please note that the captured output will only contain output emitted by the thread that
was used to execute a container or test. Any output by other threads will be omitted
Expand Down
Expand Up @@ -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;
Expand Down Expand Up @@ -73,4 +75,24 @@ public void setTheme(Theme theme) {
this.theme = theme;
}

@API(status = INTERNAL, since = "5.11")
public Path getStdoutPath() {
return this.stdoutPath;
}

@API(status = INTERNAL, since = "5.11")
public void setStdoutPath(Path stdoutPath) {
this.stdoutPath = stdoutPath;
}

@API(status = INTERNAL, since = "5.11")
public Path getStderrPath() {
return this.stderrPath;
}

@API(status = INTERNAL, since = "5.11")
public void setStderrPath(Path stderrPath) {
this.stderrPath = stderrPath;
}

}
Expand Up @@ -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));
}
}

Expand Down
Expand Up @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -98,6 +101,24 @@ private TestExecutionSummary executeTests(PrintWriter out, Optional<Path> 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);

Expand Down Expand Up @@ -185,6 +206,34 @@ private void printSummary(TestExecutionSummary summary, PrintWriter out) {
summary.printTo(out);
}

@API(status = INTERNAL, since = "5.11")
private boolean isSameFile(Path path1, Path path2) {
if (path1 == null || path2 == null)
return false;
return (path1.normalize().toAbsolutePath().equals(path2.normalize().toAbsolutePath()));
}

@API(status = INTERNAL, since = "5.11")
private void captureStdout() {
Map<String, String> configParameters = new HashMap<>(discoveryOptions.getConfigurationParameters());
configParameters.put(LauncherConstants.CAPTURE_STDOUT_PROPERTY_NAME, "true");
discoveryOptions.setConfigurationParameters(configParameters);
}

@API(status = INTERNAL, since = "5.11")
private void captureStderr() {
Map<String, String> configParameters = new HashMap<>(discoveryOptions.getConfigurationParameters());
configParameters.put(LauncherConstants.CAPTURE_STDERR_PROPERTY_NAME, "true");
discoveryOptions.setConfigurationParameters(configParameters);
}

@API(status = INTERNAL, since = "5.11")
private void captureMergedStandardStreams() {
Map<String, String> 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);
Expand Down
@@ -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);
}
}
Expand Up @@ -60,6 +60,21 @@ public class LauncherConstants {
*/
public static final String CAPTURE_STDERR_PROPERTY_NAME = "junit.platform.output.capture.stderr";

/**
* Property name used to enable merging and capturing output to {@link System#err} and {@link System#out}:
* {@value}
*
* <p>If enabled, the JUnit Platform merges stdout and stderr and publishes
* it as a {@link ReportEntry} using the
* {@value #STDOUT_REPORT_ENTRY_KEY} key immediately before reporting the
* test identifier as finished.
*
* @see #STDOUT_REPORT_ENTRY_KEY
* @see ReportEntry
* @see TestExecutionListener#reportingEntryPublished(TestIdentifier, ReportEntry)
*/
public static final String CAPTURE_MERGED_STANDARD_STREAMS_PROPERTY_NAME = "junit.platform.output.capture.merge";

/**
* Property name used to configure the maximum number of bytes for buffering
* to use per thread and output type if output capturing is enabled:
Expand Down
Expand Up @@ -12,6 +12,7 @@

import static org.junit.platform.launcher.LauncherConstants.CAPTURE_MAX_BUFFER_DEFAULT;
import static org.junit.platform.launcher.LauncherConstants.CAPTURE_MAX_BUFFER_PROPERTY_NAME;
import static org.junit.platform.launcher.LauncherConstants.CAPTURE_MERGED_STANDARD_STREAMS_PROPERTY_NAME;
import static org.junit.platform.launcher.LauncherConstants.CAPTURE_STDERR_PROPERTY_NAME;
import static org.junit.platform.launcher.LauncherConstants.CAPTURE_STDOUT_PROPERTY_NAME;
import static org.junit.platform.launcher.LauncherConstants.STDERR_REPORT_ENTRY_KEY;
Expand Down Expand Up @@ -43,17 +44,28 @@ static Optional<StreamInterceptingTestExecutionListener> create(ConfigurationPar

boolean captureStdout = configurationParameters.getBoolean(CAPTURE_STDOUT_PROPERTY_NAME).orElse(false);
boolean captureStderr = configurationParameters.getBoolean(CAPTURE_STDERR_PROPERTY_NAME).orElse(false);
if (!captureStdout && !captureStderr) {
boolean captureMergeStandardStreams = configurationParameters.getBoolean(
CAPTURE_MERGED_STANDARD_STREAMS_PROPERTY_NAME).orElse(false);

if (!captureStdout && !captureStderr && !captureMergeStandardStreams) {
return Optional.empty();
}

int maxSize = configurationParameters.get(CAPTURE_MAX_BUFFER_PROPERTY_NAME, Integer::valueOf) //
.orElse(CAPTURE_MAX_BUFFER_DEFAULT);

Optional<StreamInterceptor> stdoutInterceptor = captureStdout ? StreamInterceptor.registerStdout(maxSize)
: Optional.empty();
Optional<StreamInterceptor> stderrInterceptor = captureStderr ? StreamInterceptor.registerStderr(maxSize)
: Optional.empty();
Optional<StreamInterceptor> stdoutInterceptor = Optional.empty();
Optional<StreamInterceptor> stderrInterceptor = Optional.empty();

if (captureMergeStandardStreams) {
stdoutInterceptor = StreamInterceptor.registerMergedStandardStreams(maxSize);
captureStderr = false;
captureStdout = true;
}
else {
stdoutInterceptor = captureStdout ? StreamInterceptor.registerStdout(maxSize) : Optional.empty();
stderrInterceptor = captureStderr ? StreamInterceptor.registerStderr(maxSize) : Optional.empty();
}

if ((!stdoutInterceptor.isPresent() && captureStdout) || (!stderrInterceptor.isPresent() && captureStderr)) {
stdoutInterceptor.ifPresent(StreamInterceptor::unregister);
Expand Down
Expand Up @@ -37,6 +37,12 @@ static Optional<StreamInterceptor> registerStderr(int maxNumberOfBytesPerThread)
return register(System.err, System::setErr, maxNumberOfBytesPerThread);
}

static Optional<StreamInterceptor> registerMergedStandardStreams(int maxNumberOfBytesPerThread) {
Optional<StreamInterceptor> interceptor = registerStdout(maxNumberOfBytesPerThread);
interceptor.ifPresent((System::setErr));
return interceptor;
}

static Optional<StreamInterceptor> register(PrintStream originalStream, Consumer<PrintStream> streamSetter,
int maxNumberOfBytesPerThread) {
if (originalStream instanceof StreamInterceptor) {
Expand Down