From f7fedd68c4c625040ca7aa2bd44728eff05ce62f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 24 Oct 2025 18:31:23 +0300 Subject: [PATCH 1/7] Fail tests when console output occurs --- .../codename1/testing/AllowConsoleOutput.java | 21 +++ .../testing/UnexpectedLogExtension.java | 154 ++++++++++++++++++ .../org.junit.jupiter.api.extension.Extension | 1 + 3 files changed, 176 insertions(+) create mode 100644 maven/core-unittests/src/test/java/com/codename1/testing/AllowConsoleOutput.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/testing/UnexpectedLogExtension.java create mode 100644 maven/core-unittests/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension diff --git a/maven/core-unittests/src/test/java/com/codename1/testing/AllowConsoleOutput.java b/maven/core-unittests/src/test/java/com/codename1/testing/AllowConsoleOutput.java new file mode 100644 index 0000000000..0ccf485c5a --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/testing/AllowConsoleOutput.java @@ -0,0 +1,21 @@ +package com.codename1.testing; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Indicates that console output produced during a test should be allowed without causing the + * {@link UnexpectedLogExtension} to fail the test. This can be applied to individual test methods or + * entire test classes when console output is part of the expected behaviour under test. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface AllowConsoleOutput { + /** + * When set to {@code true}, console output is permitted. The attribute exists to allow future + * flexibility should tests wish to dynamically enable or disable console output. + */ + boolean value() default true; +} diff --git a/maven/core-unittests/src/test/java/com/codename1/testing/UnexpectedLogExtension.java b/maven/core-unittests/src/test/java/com/codename1/testing/UnexpectedLogExtension.java new file mode 100644 index 0000000000..c28376c93f --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/testing/UnexpectedLogExtension.java @@ -0,0 +1,154 @@ +package com.codename1.testing; + +import java.io.ByteArrayOutputStream; +import java.io.OutputStream; +import java.io.PrintStream; +import java.lang.annotation.AnnotatedElement; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; + +/** + * Captures console output generated during each test execution and fails the test if any unexpected + * messages are produced. This helps surface silent failures that only manifest via stack traces or + * log statements printed to {@code System.out} or {@code System.err}. + */ +public class UnexpectedLogExtension implements BeforeEachCallback, AfterEachCallback { + private static final ExtensionContext.Namespace NAMESPACE = + ExtensionContext.Namespace.create(UnexpectedLogExtension.class); + + @Override + public void beforeEach(ExtensionContext context) { + CapturedStreams captured = new CapturedStreams(System.out, System.err); + getStore(context).put(context.getUniqueId(), captured); + + System.setOut(captured.createInterceptingStream(captured.originalOut, captured.capturedOut)); + System.setErr(captured.createInterceptingStream(captured.originalErr, captured.capturedErr)); + } + + @Override + public void afterEach(ExtensionContext context) { + CapturedStreams captured = + getStore(context).remove(context.getUniqueId(), CapturedStreams.class); + if (captured == null) { + return; + } + + System.setOut(captured.originalOut); + System.setErr(captured.originalErr); + + if (isOutputAllowed(context)) { + return; + } + + List problems = new ArrayList<>(); + String stdout = new String(captured.capturedOut.toByteArray(), StandardCharsets.UTF_8); + String stderr = new String(captured.capturedErr.toByteArray(), StandardCharsets.UTF_8); + + if (!stdout.trim().isEmpty()) { + problems.add("System.out:\n" + stdout.trim()); + } + if (!stderr.trim().isEmpty()) { + problems.add("System.err:\n" + stderr.trim()); + } + + if (!problems.isEmpty()) { + String message = + "Unexpected console output detected during test '" + + context.getDisplayName() + + "'."; + throw new AssertionError(message + System.lineSeparator() + System.lineSeparator() + + String.join(System.lineSeparator() + System.lineSeparator(), problems)); + } + } + + private ExtensionContext.Store getStore(ExtensionContext context) { + return context.getStore(NAMESPACE); + } + + private boolean isOutputAllowed(ExtensionContext context) { + if (context.getElement().isPresent() + && isAnnotatedWithAllow(context.getElement().get())) { + return true; + } + Class testClass = context.getTestClass().orElse(null); + return testClass != null && isAnnotatedWithAllow(testClass); + } + + private boolean isAnnotatedWithAllow(AnnotatedElement element) { + AllowConsoleOutput annotation = element.getAnnotation(AllowConsoleOutput.class); + return annotation != null && annotation.value(); + } + + private static class CapturedStreams { + final PrintStream originalOut; + final PrintStream originalErr; + final ByteArrayOutputStream capturedOut = new ByteArrayOutputStream(); + final ByteArrayOutputStream capturedErr = new ByteArrayOutputStream(); + + CapturedStreams(PrintStream originalOut, PrintStream originalErr) { + this.originalOut = originalOut; + this.originalErr = originalErr; + } + + PrintStream createInterceptingStream(PrintStream original, ByteArrayOutputStream capture) { + return new PrintStream(new TeeOutputStream(original, capture), true); + } + } + + private static class TeeOutputStream extends OutputStream { + private final OutputStream first; + private final OutputStream second; + + TeeOutputStream(OutputStream first, OutputStream second) { + this.first = first; + this.second = second; + } + + @Override + public void write(int b) { + try { + first.write(b); + } catch (Exception ignore) { + // Swallow exceptions from the original stream to ensure we still capture the output. + } + try { + second.write(b); + } catch (Exception ignore) { + // ByteArrayOutputStream should not fail, but swallow just in case. + } + } + + @Override + public void write(byte[] b, int off, int len) { + try { + first.write(b, off, len); + } catch (Exception ignore) { + // Swallow to ensure output continues to be captured even if the original stream fails. + } + try { + second.write(b, off, len); + } catch (Exception ignore) { + // Ignore failures while capturing. + } + } + + @Override + public void flush() { + try { + first.flush(); + } catch (Exception ignore) { + // Ignore flush failures on the original stream. + } + try { + second.flush(); + } catch (Exception ignore) { + // Ignore flush failures on the captured stream. + } + } + } +} diff --git a/maven/core-unittests/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension b/maven/core-unittests/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension new file mode 100644 index 0000000000..2280221123 --- /dev/null +++ b/maven/core-unittests/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension @@ -0,0 +1 @@ +com.codename1.testing.UnexpectedLogExtension From 6bad8f7d216b78915a1bdcc232f6c9f360191fff Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 24 Oct 2025 19:38:25 +0300 Subject: [PATCH 2/7] Fix AnnotatedElement import --- .../test/java/com/codename1/testing/UnexpectedLogExtension.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/maven/core-unittests/src/test/java/com/codename1/testing/UnexpectedLogExtension.java b/maven/core-unittests/src/test/java/com/codename1/testing/UnexpectedLogExtension.java index c28376c93f..e2a8d9309c 100644 --- a/maven/core-unittests/src/test/java/com/codename1/testing/UnexpectedLogExtension.java +++ b/maven/core-unittests/src/test/java/com/codename1/testing/UnexpectedLogExtension.java @@ -3,7 +3,7 @@ import java.io.ByteArrayOutputStream; import java.io.OutputStream; import java.io.PrintStream; -import java.lang.annotation.AnnotatedElement; +import java.lang.reflect.AnnotatedElement; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; From 21ba2164dcfd57eb29079cd6d47d0473b75560bd Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 24 Oct 2025 20:31:39 +0300 Subject: [PATCH 3/7] Enable JUnit 5 extension autodetection for core tests --- .../core-unittests/src/test/resources/junit-platform.properties | 1 + 1 file changed, 1 insertion(+) create mode 100644 maven/core-unittests/src/test/resources/junit-platform.properties diff --git a/maven/core-unittests/src/test/resources/junit-platform.properties b/maven/core-unittests/src/test/resources/junit-platform.properties new file mode 100644 index 0000000000..6efc0d5e85 --- /dev/null +++ b/maven/core-unittests/src/test/resources/junit-platform.properties @@ -0,0 +1 @@ +junit.jupiter.extensions.autodetection.enabled=true From fe9d239cb7d30381d98a4765cc30d71ff4eef800 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 24 Oct 2025 21:56:08 +0300 Subject: [PATCH 4/7] Allow console output for failure listener test --- .../test/java/com/codename1/testing/TestRunnerComponentTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/maven/core-unittests/src/test/java/com/codename1/testing/TestRunnerComponentTest.java b/maven/core-unittests/src/test/java/com/codename1/testing/TestRunnerComponentTest.java index 020f01d136..7912866618 100644 --- a/maven/core-unittests/src/test/java/com/codename1/testing/TestRunnerComponentTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/testing/TestRunnerComponentTest.java @@ -66,6 +66,7 @@ void showFormCreatesAndReusesHostForm() { } @Test + @AllowConsoleOutput void runTestsAddsFailureActionListenerOnException() throws Exception { RuntimeException failure = new RuntimeException("explode"); TestRunnerComponent component = new TestRunnerComponent(); From 34848079483e000a11f578f8f1754e544e0015c9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 25 Oct 2025 06:11:14 +0300 Subject: [PATCH 5/7] Stop logging test failures twice --- CodenameOne/src/com/codename1/testing/TestRunnerComponent.java | 1 - .../test/java/com/codename1/testing/TestRunnerComponentTest.java | 1 - 2 files changed, 2 deletions(-) diff --git a/CodenameOne/src/com/codename1/testing/TestRunnerComponent.java b/CodenameOne/src/com/codename1/testing/TestRunnerComponent.java index 56df89c4bf..57545dd439 100644 --- a/CodenameOne/src/com/codename1/testing/TestRunnerComponent.java +++ b/CodenameOne/src/com/codename1/testing/TestRunnerComponent.java @@ -119,7 +119,6 @@ public void run() { } } catch (final Throwable t) { - Log.e(t); CN.callSerially(new Runnable() { public void run() { statusLabel.setText(test + ": Failed"); diff --git a/maven/core-unittests/src/test/java/com/codename1/testing/TestRunnerComponentTest.java b/maven/core-unittests/src/test/java/com/codename1/testing/TestRunnerComponentTest.java index 7912866618..020f01d136 100644 --- a/maven/core-unittests/src/test/java/com/codename1/testing/TestRunnerComponentTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/testing/TestRunnerComponentTest.java @@ -66,7 +66,6 @@ void showFormCreatesAndReusesHostForm() { } @Test - @AllowConsoleOutput void runTestsAddsFailureActionListenerOnException() throws Exception { RuntimeException failure = new RuntimeException("explode"); TestRunnerComponent component = new TestRunnerComponent(); From 5f8e51fc9ce30687ebd7346fbe77fd27f4a6256b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 25 Oct 2025 06:11:20 +0300 Subject: [PATCH 6/7] Mock Log.e during TestRunnerComponent failure test --- .../testing/TestRunnerComponentTest.java | 65 +++++++++++++++---- 1 file changed, 51 insertions(+), 14 deletions(-) diff --git a/maven/core-unittests/src/test/java/com/codename1/testing/TestRunnerComponentTest.java b/maven/core-unittests/src/test/java/com/codename1/testing/TestRunnerComponentTest.java index 020f01d136..cc6ffee542 100644 --- a/maven/core-unittests/src/test/java/com/codename1/testing/TestRunnerComponentTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/testing/TestRunnerComponentTest.java @@ -1,9 +1,11 @@ package com.codename1.testing; +import com.codename1.io.Log; import com.codename1.test.UITestBase; import com.codename1.ui.Button; import com.codename1.ui.Container; import com.codename1.ui.Form; +import com.codename1.ui.events.ActionEvent; import com.codename1.ui.events.ActionListener; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -71,25 +73,37 @@ void runTestsAddsFailureActionListenerOnException() throws Exception { TestRunnerComponent component = new TestRunnerComponent(); component.add(new SimpleTest("Explosive", true, true, failure)); + RecordingLog recordingLog = new RecordingLog(); + Log originalLog = replaceLog(recordingLog); + Form form = component.showForm(); assertNotNull(form); - assertDoesNotThrow(component::runTests); - flushSerialCalls(); - - Container resultsPane = getResultsPane(component); - assertEquals(2, resultsPane.getComponentCount()); - Button status = (Button) resultsPane.getComponentAt(1); - assertEquals("Explosive: Failed", status.getText()); - - boolean found = false; - for (Object listener : status.getListeners()) { - if (listener instanceof ActionListener) { - found = true; - break; + try { + assertDoesNotThrow(component::runTests); + flushSerialCalls(); + + Container resultsPane = getResultsPane(component); + assertEquals(2, resultsPane.getComponentCount()); + Button status = (Button) resultsPane.getComponentAt(1); + assertEquals("Explosive: Failed", status.getText()); + + ActionListener failureListener = null; + for (Object listener : status.getListeners()) { + if (listener instanceof ActionListener) { + ActionListener candidate = (ActionListener) listener; + candidate.actionPerformed(new ActionEvent(status)); + if (recordingLog.loggedThrowable != null) { + failureListener = candidate; + break; + } + } } + assertNotNull(failureListener, "failure action listener should be installed"); + assertSame(failure, recordingLog.loggedThrowable, "failure should be forwarded to Log.e"); + } finally { + restoreLog(originalLog); } - assertTrue(found); } private Container getResultsPane(TestRunnerComponent component) throws Exception { @@ -98,6 +112,20 @@ private Container getResultsPane(TestRunnerComponent component) throws Exception return (Container) field.get(component); } + private Log replaceLog(Log replacement) throws Exception { + Field field = Log.class.getDeclaredField("instance"); + field.setAccessible(true); + Log original = (Log) field.get(null); + field.set(null, replacement); + return original; + } + + private void restoreLog(Log original) throws Exception { + Field field = Log.class.getDeclaredField("instance"); + field.setAccessible(true); + field.set(null, original); + } + private static class SimpleTest extends AbstractTest { private final String name; private final boolean shouldExecuteOnEDT; @@ -128,4 +156,13 @@ public String toString() { return name; } } + + private static class RecordingLog extends Log { + private Throwable loggedThrowable; + + @Override + protected void logThrowable(Throwable t) { + loggedThrowable = t; + } + } } From 84100c4c7256c4a06c3abd9b2a0813c88a55e57c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 25 Oct 2025 06:35:50 +0300 Subject: [PATCH 7/7] Restore immediate logging for test runner failures --- .../com/codename1/testing/TestRunnerComponent.java | 1 + .../codename1/testing/TestRunnerComponentTest.java | 14 ++++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/testing/TestRunnerComponent.java b/CodenameOne/src/com/codename1/testing/TestRunnerComponent.java index 57545dd439..56df89c4bf 100644 --- a/CodenameOne/src/com/codename1/testing/TestRunnerComponent.java +++ b/CodenameOne/src/com/codename1/testing/TestRunnerComponent.java @@ -119,6 +119,7 @@ public void run() { } } catch (final Throwable t) { + Log.e(t); CN.callSerially(new Runnable() { public void run() { statusLabel.setText(test + ": Failed"); diff --git a/maven/core-unittests/src/test/java/com/codename1/testing/TestRunnerComponentTest.java b/maven/core-unittests/src/test/java/com/codename1/testing/TestRunnerComponentTest.java index cc6ffee542..256983ffe8 100644 --- a/maven/core-unittests/src/test/java/com/codename1/testing/TestRunnerComponentTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/testing/TestRunnerComponentTest.java @@ -11,6 +11,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -81,6 +83,8 @@ void runTestsAddsFailureActionListenerOnException() throws Exception { try { assertDoesNotThrow(component::runTests); + assertEquals(1, recordingLog.loggedThrowables.size(), "failure should be logged immediately"); + assertSame(failure, recordingLog.loggedThrowables.get(0)); flushSerialCalls(); Container resultsPane = getResultsPane(component); @@ -89,18 +93,20 @@ void runTestsAddsFailureActionListenerOnException() throws Exception { assertEquals("Explosive: Failed", status.getText()); ActionListener failureListener = null; + int loggedBeforeAction = recordingLog.loggedThrowables.size(); for (Object listener : status.getListeners()) { if (listener instanceof ActionListener) { ActionListener candidate = (ActionListener) listener; candidate.actionPerformed(new ActionEvent(status)); - if (recordingLog.loggedThrowable != null) { + if (recordingLog.loggedThrowables.size() > loggedBeforeAction) { failureListener = candidate; break; } } } assertNotNull(failureListener, "failure action listener should be installed"); - assertSame(failure, recordingLog.loggedThrowable, "failure should be forwarded to Log.e"); + assertEquals(loggedBeforeAction + 1, recordingLog.loggedThrowables.size(), "tapping failure should log again"); + assertSame(failure, recordingLog.loggedThrowables.get(loggedBeforeAction)); } finally { restoreLog(originalLog); } @@ -158,11 +164,11 @@ public String toString() { } private static class RecordingLog extends Log { - private Throwable loggedThrowable; + private final List loggedThrowables = new ArrayList<>(); @Override protected void logThrowable(Throwable t) { - loggedThrowable = t; + loggedThrowables.add(t); } } }