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/TestRunnerComponentTest.java b/maven/core-unittests/src/test/java/com/codename1/testing/TestRunnerComponentTest.java index 020f01d136..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 @@ -1,14 +1,18 @@ 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; 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; @@ -71,25 +75,41 @@ 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); + assertEquals(1, recordingLog.loggedThrowables.size(), "failure should be logged immediately"); + assertSame(failure, recordingLog.loggedThrowables.get(0)); + flushSerialCalls(); + + Container resultsPane = getResultsPane(component); + assertEquals(2, resultsPane.getComponentCount()); + Button status = (Button) resultsPane.getComponentAt(1); + 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.loggedThrowables.size() > loggedBeforeAction) { + failureListener = candidate; + break; + } + } } + assertNotNull(failureListener, "failure action listener should be installed"); + assertEquals(loggedBeforeAction + 1, recordingLog.loggedThrowables.size(), "tapping failure should log again"); + assertSame(failure, recordingLog.loggedThrowables.get(loggedBeforeAction)); + } finally { + restoreLog(originalLog); } - assertTrue(found); } private Container getResultsPane(TestRunnerComponent component) throws Exception { @@ -98,6 +118,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 +162,13 @@ public String toString() { return name; } } + + private static class RecordingLog extends Log { + private final List loggedThrowables = new ArrayList<>(); + + @Override + protected void logThrowable(Throwable t) { + loggedThrowables.add(t); + } + } } 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..e2a8d9309c --- /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.reflect.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 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