From 393f60c0c5c6ca0b1420a59461d756793a0b232d Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Fri, 13 Dec 2024 19:39:09 +0100 Subject: [PATCH 1/2] Attribute non-test thread output to most recent test thread When using frameworks or running external processes, test output is often written on other threads than the test. When tests are executed sequentially that output can be attributed unambiguously to the current test. If tests are run in parallel, picking the right test to attribute output to becomes much harder, though. --- .../launcher/core/StreamInterceptor.java | 36 +++++++++++++++---- .../launcher/core/StreamInterceptorTests.java | 34 +++++++++++++----- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/StreamInterceptor.java b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/StreamInterceptor.java index b34fd72c1a15..1fbfe7ae81d2 100644 --- a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/StreamInterceptor.java +++ b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/StreamInterceptor.java @@ -15,6 +15,7 @@ import java.util.ArrayDeque; import java.util.Deque; import java.util.Optional; +import java.util.concurrent.ConcurrentLinkedDeque; import java.util.function.Consumer; /** @@ -22,6 +23,8 @@ */ class StreamInterceptor extends PrintStream { + private final Deque mostRecentOutputs = new ConcurrentLinkedDeque<>(); + private final PrintStream originalStream; private final Consumer unregisterAction; private final int maxNumberOfBytesPerThread; @@ -56,11 +59,18 @@ private StreamInterceptor(PrintStream originalStream, Consumer unre } void capture() { - output.get().mark(); + RewindableByteArrayOutputStream out = output.get(); + out.mark(); + pushToTop(out); } String consume() { - return output.get().rewind(); + RewindableByteArrayOutputStream out = output.get(); + String result = out.rewind(); + if (!out.isMarked()) { + mostRecentOutputs.remove(out); + } + return result; } void unregister() { @@ -69,8 +79,9 @@ void unregister() { @Override public void write(int b) { - RewindableByteArrayOutputStream out = output.get(); - if (out.isMarked() && out.size() < maxNumberOfBytesPerThread) { + RewindableByteArrayOutputStream out = getOutput(); + if (out != null && out.size() < maxNumberOfBytesPerThread) { + pushToTop(out); out.write(b); } super.write(b); @@ -83,16 +94,29 @@ public void write(byte[] b) { @Override public void write(byte[] buf, int off, int len) { - RewindableByteArrayOutputStream out = output.get(); - if (out.isMarked()) { + RewindableByteArrayOutputStream out = getOutput(); + if (out != null) { int actualLength = Math.max(0, Math.min(len, maxNumberOfBytesPerThread - out.size())); if (actualLength > 0) { + pushToTop(out); out.write(buf, off, actualLength); } } super.write(buf, off, len); } + private void pushToTop(RewindableByteArrayOutputStream out) { + if (!out.equals(mostRecentOutputs.peek())) { + mostRecentOutputs.remove(out); + mostRecentOutputs.push(out); + } + } + + private RewindableByteArrayOutputStream getOutput() { + RewindableByteArrayOutputStream out = output.get(); + return out.isMarked() ? out : mostRecentOutputs.peek(); + } + static class RewindableByteArrayOutputStream extends ByteArrayOutputStream { private final Deque markedPositions = new ArrayDeque<>(); diff --git a/platform-tests/src/test/java/org/junit/platform/launcher/core/StreamInterceptorTests.java b/platform-tests/src/test/java/org/junit/platform/launcher/core/StreamInterceptorTests.java index d524f5b7d283..04b00edb441f 100644 --- a/platform-tests/src/test/java/org/junit/platform/launcher/core/StreamInterceptorTests.java +++ b/platform-tests/src/test/java/org/junit/platform/launcher/core/StreamInterceptorTests.java @@ -20,6 +20,7 @@ import java.io.PrintStream; import java.util.stream.IntStream; +import org.junit.jupiter.api.AutoClose; import org.junit.jupiter.api.Test; /** @@ -27,17 +28,19 @@ */ class StreamInterceptorTests { - private ByteArrayOutputStream originalOut = new ByteArrayOutputStream(); - private PrintStream targetStream = new PrintStream(originalOut); + final ByteArrayOutputStream originalOut = new ByteArrayOutputStream(); + PrintStream targetStream = new PrintStream(originalOut); + + @AutoClose + StreamInterceptor streamInterceptor; @Test void interceptsWriteOperationsToStreamPerThread() { - var streamInterceptor = StreamInterceptor.register(targetStream, newStream -> this.targetStream = newStream, + streamInterceptor = StreamInterceptor.register(targetStream, newStream -> this.targetStream = newStream, 3).orElseThrow(RuntimeException::new); // @formatter:off IntStream.range(0, 1000) .parallel() - .peek(i -> targetStream.println(i)) .mapToObj(String::valueOf) .peek(i -> streamInterceptor.capture()) .peek(i -> targetStream.println(i)) @@ -49,7 +52,7 @@ void interceptsWriteOperationsToStreamPerThread() { void unregisterRestoresOriginalStream() { var originalStream = targetStream; - var streamInterceptor = StreamInterceptor.register(targetStream, newStream -> this.targetStream = newStream, + streamInterceptor = StreamInterceptor.register(targetStream, newStream -> this.targetStream = newStream, 3).orElseThrow(RuntimeException::new); assertSame(streamInterceptor, targetStream); @@ -61,8 +64,8 @@ void unregisterRestoresOriginalStream() { void writeForwardsOperationsToOriginalStream() throws IOException { var originalStream = targetStream; - StreamInterceptor.register(targetStream, newStream -> this.targetStream = newStream, 2).orElseThrow( - RuntimeException::new); + streamInterceptor = StreamInterceptor.register(targetStream, newStream -> this.targetStream = newStream, + 2).orElseThrow(RuntimeException::new); assertNotSame(originalStream, targetStream); targetStream.write('a'); @@ -73,7 +76,7 @@ void writeForwardsOperationsToOriginalStream() throws IOException { @Test void handlesNestedCaptures() { - var streamInterceptor = StreamInterceptor.register(targetStream, newStream -> this.targetStream = newStream, + streamInterceptor = StreamInterceptor.register(targetStream, newStream -> this.targetStream = newStream, 100).orElseThrow(RuntimeException::new); String outermost, inner, innermost; @@ -100,4 +103,19 @@ void handlesNestedCaptures() { () -> assertEquals("innermost", innermost) // ); } + + @Test + void capturesOutputFromNonTestThreads() throws Exception { + streamInterceptor = StreamInterceptor.register(targetStream, newStream -> this.targetStream = newStream, + 100).orElseThrow(RuntimeException::new); + + streamInterceptor.capture(); + var thread = new Thread(() -> { + targetStream.println("from non-test thread"); + }); + thread.start(); + thread.join(); + + assertEquals("from non-test thread", streamInterceptor.consume().trim()); + } } From b6a60e255180cf84614124ca78459fc6efd8aeb1 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Mon, 16 Dec 2024 09:19:27 +0100 Subject: [PATCH 2/2] Add to release notes --- .../docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc index 8281d15a7315..90112bdc7455 100644 --- a/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc +++ b/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc @@ -52,6 +52,8 @@ JUnit repository on GitHub. - If <<../user-guide/index.adoc#running-tests-capturing-output, output capturing>> is enabled, the captured output written to `System.out` and `System.err` is now included in the XML report. +* Output written to `System.out` and `System.err` from non-test threads is now attributed + to the most recent test or container that was started or has written output. * Introduced contracts for Kotlin-specific assertion methods.