From daca31267637fd8a892687adaa97b866f22d03da Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:04:18 -0400 Subject: [PATCH 01/12] feat: add testProgress flag and MvndTestProgress bridge The mvnd.testProgress flag (default on) gates the feature; MvndTestProgress is the shared bridge whose Class is exported to the Maven core realm so the daemon and the surefire plugin realm resolve one listener registry. Branch-identical with mvnd-1.x. Co-Authored-By: Claude Opus 4.8 --- .../mvndaemon/mvnd/common/Environment.java | 7 +++ .../mvnd/testprogress/MvndTestProgress.java | 54 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 common/src/main/java/org/mvndaemon/mvnd/testprogress/MvndTestProgress.java diff --git a/common/src/main/java/org/mvndaemon/mvnd/common/Environment.java b/common/src/main/java/org/mvndaemon/mvnd/common/Environment.java index d53c7597d..01ebaf3f3 100644 --- a/common/src/main/java/org/mvndaemon/mvnd/common/Environment.java +++ b/common/src/main/java/org/mvndaemon/mvnd/common/Environment.java @@ -161,6 +161,13 @@ public enum Environment { */ MVND_NO_MODEL_CACHE("mvnd.noModelCache", null, Boolean.FALSE, OptionType.BOOLEAN, Flags.OPTIONAL), + /** + * If true (default), mvnd shows live per-test progress on each project's worker line while + * Surefire/Failsafe run tests. Set to false to disable the feature entirely (nothing is injected + * into the surefire/failsafe configuration and no listener is registered). + */ + MVND_TEST_PROGRESS("mvnd.testProgress", null, Boolean.TRUE, OptionType.BOOLEAN, Flags.OPTIONAL), + /** * If true, the daemon will be launched in debug mode with the following JVM argument: * -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=8000; otherwise the debug argument is diff --git a/common/src/main/java/org/mvndaemon/mvnd/testprogress/MvndTestProgress.java b/common/src/main/java/org/mvndaemon/mvnd/testprogress/MvndTestProgress.java new file mode 100644 index 000000000..6d5552239 --- /dev/null +++ b/common/src/main/java/org/mvndaemon/mvnd/testprogress/MvndTestProgress.java @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.mvndaemon.mvnd.testprogress; + +import java.util.concurrent.atomic.AtomicReference; + +/** + * Bridge between Surefire's plugin realm (where {@code MvndForkNodeFactory} runs) and mvnd's daemon realm + * (where the {@code ClientDispatcher} lives). This type MUST be loaded from a package exported by the Maven core + * realm so both realms resolve the same {@link Class} and therefore share the static {@link #LISTENER} registry. + */ +public interface MvndTestProgress { + + /** + * Push a per-test progress snapshot. Implementations must be cheap and non-throwing; the caller already + * guards against exceptions but should not rely on it. + */ + void update( + String projectId, + String testClass, + String testMethod, + int completed, + int failures, + int errors, + int skipped); + + AtomicReference LISTENER = new AtomicReference<>(); + + /** Registered by the daemon at build start; cleared at build end. */ + static void setListener(MvndTestProgress listener) { + LISTENER.set(listener); + } + + /** Returns the active listener, or {@code null} when the feature is off or this is not a daemon invocation. */ + static MvndTestProgress getListener() { + return LISTENER.get(); + } +} From 1756fdb130e4288e389fd20329a0d221e04e492b Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:04:20 -0400 Subject: [PATCH 02/12] feat: add mvnd-surefire-progress module New module carrying the pure per-fork accumulator, the surefire ForkNodeFactory (MvndForkNodeFactory) that decorates the event handler to observe per-test events, and a jar locator. Kept in package org.mvndaemon.mvnd.forknode, OUT of the exported testprogress bridge prefix, so the surefire plugin realm loads it from its own class path (ClassWorlds exports are prefix-based). Branch-identical with mvnd-1.x. Co-Authored-By: Claude Opus 4.8 --- pom.xml | 6 + surefire-progress/pom.xml | 74 +++++++ .../mvnd/forknode/MvndForkNodeFactory.java | 185 ++++++++++++++++++ .../forknode/MvndSurefireProgressLocator.java | 28 +++ .../forknode/TestProgressAccumulator.java | 97 +++++++++ .../forknode/MvndForkNodeFactoryTest.java | 70 +++++++ .../forknode/TestProgressAccumulatorTest.java | 74 +++++++ 7 files changed, 534 insertions(+) create mode 100644 surefire-progress/pom.xml create mode 100644 surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/MvndForkNodeFactory.java create mode 100644 surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/MvndSurefireProgressLocator.java create mode 100644 surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulator.java create mode 100644 surefire-progress/src/test/java/org/mvndaemon/mvnd/forknode/MvndForkNodeFactoryTest.java create mode 100644 surefire-progress/src/test/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulatorTest.java diff --git a/pom.xml b/pom.xml index c8e8063ae..5c5cf97c6 100644 --- a/pom.xml +++ b/pom.xml @@ -49,6 +49,7 @@ agent helper common + surefire-progress client logging daemon @@ -269,6 +270,11 @@ mvnd-common ${project.version} + + org.apache.maven.daemon + mvnd-surefire-progress + ${project.version} + org.apache.maven.daemon mvnd-dist diff --git a/surefire-progress/pom.xml b/surefire-progress/pom.xml new file mode 100644 index 000000000..d30b18110 --- /dev/null +++ b/surefire-progress/pom.xml @@ -0,0 +1,74 @@ + + + + + 4.0.0 + + org.apache.maven.daemon + mvnd + 2.0.0-rc-4-SNAPSHOT + + + mvnd-surefire-progress + jar + Maven Daemon - Surefire Test Progress + + + 3.5.2 + + + + + org.apache.maven.daemon + mvnd-common + ${project.version} + provided + + + org.apache.maven.surefire + surefire-extensions-api + ${surefire.spi.version} + provided + + + + org.apache.maven.surefire + maven-surefire-common + ${surefire.spi.version} + provided + + + org.apache.maven.surefire + surefire-extensions-spi + ${surefire.spi.version} + provided + + + org.apache.maven.surefire + surefire-api + ${surefire.spi.version} + provided + + + org.junit.jupiter + junit-jupiter + test + + + diff --git a/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/MvndForkNodeFactory.java b/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/MvndForkNodeFactory.java new file mode 100644 index 000000000..09dcb976e --- /dev/null +++ b/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/MvndForkNodeFactory.java @@ -0,0 +1,185 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.mvndaemon.mvnd.forknode; + +import java.io.IOException; +import java.nio.channels.ReadableByteChannel; +import java.nio.channels.WritableByteChannel; + +import org.apache.maven.plugin.surefire.extensions.SurefireForkNodeFactory; +import org.apache.maven.surefire.api.event.Event; +import org.apache.maven.surefire.api.event.TestErrorEvent; +import org.apache.maven.surefire.api.event.TestFailedEvent; +import org.apache.maven.surefire.api.event.TestSkippedEvent; +import org.apache.maven.surefire.api.event.TestStartingEvent; +import org.apache.maven.surefire.api.event.TestSucceededEvent; +import org.apache.maven.surefire.api.event.TestsetCompletedEvent; +import org.apache.maven.surefire.api.event.TestsetStartingEvent; +import org.apache.maven.surefire.api.fork.ForkNodeArguments; +import org.apache.maven.surefire.api.report.ReportEntry; +import org.apache.maven.surefire.extensions.CommandReader; +import org.apache.maven.surefire.extensions.EventHandler; +import org.apache.maven.surefire.extensions.ForkChannel; +import org.apache.maven.surefire.extensions.util.CountdownCloseable; +import org.mvndaemon.mvnd.testprogress.MvndTestProgress; + +/** + * A {@link org.apache.maven.surefire.extensions.ForkNodeFactory} that delegates channel creation to Surefire's + * default ({@link SurefireForkNodeFactory}) and decorates the {@link EventHandler} so mvnd can observe per-test + * events. Injected into the surefire/failsafe {@code } config by the daemon; carries the mvnd + * {@code projectId} for attribution. + */ +public class MvndForkNodeFactory extends SurefireForkNodeFactory { + + /** Set by Surefire from the injected {@code ...} configuration. */ + private String projectId; + + public void setProjectId(String projectId) { + this.projectId = projectId; + } + + public String getProjectId() { + return projectId; + } + + @Override + public ForkChannel createForkChannel(ForkNodeArguments arguments) throws IOException { + ForkChannel delegate = super.createForkChannel(arguments); + return new WrappingForkChannel(arguments, delegate, projectId); + } + + /** Wraps a {@link ForkChannel}, decorating the event handler passed to {@link #bindEventHandler}. */ + static final class WrappingForkChannel extends ForkChannel { + private final ForkChannel delegate; + private final String projectId; + + WrappingForkChannel(ForkNodeArguments arguments, ForkChannel delegate, String projectId) { + super(arguments); + this.delegate = delegate; + this.projectId = projectId; + } + + @Override + public void tryConnectToClient() throws IOException, InterruptedException { + delegate.tryConnectToClient(); + } + + @Override + public String getForkNodeConnectionString() { + return delegate.getForkNodeConnectionString(); + } + + @Override + public int getCountdownCloseablePermits() { + return delegate.getCountdownCloseablePermits(); + } + + @Override + public void bindCommandReader(CommandReader commands, WritableByteChannel stdIn) + throws IOException, InterruptedException { + delegate.bindCommandReader(commands, stdIn); + } + + @Override + public void bindEventHandler( + EventHandler eventHandler, CountdownCloseable countdown, ReadableByteChannel stdOut) + throws IOException, InterruptedException { + delegate.bindEventHandler( + new ProgressEventHandler(projectId, eventHandler, new TestProgressAccumulator()), + countdown, + stdOut); + } + + @Override + public void disable() { + delegate.disable(); + } + + @Override + public void close() throws IOException { + delegate.close(); + } + } + + /** Observes each event, updates the accumulator, pushes through the bridge, then always delegates. */ + static final class ProgressEventHandler implements EventHandler { + private final String projectId; + private final EventHandler delegate; + private final TestProgressAccumulator acc; + + ProgressEventHandler(String projectId, EventHandler delegate, TestProgressAccumulator acc) { + this.projectId = projectId; + this.delegate = delegate; + this.acc = acc; + } + + @Override + public void handleEvent(Event event) { + try { + observe(event); + } catch (Throwable ignored) { + // Never break the test run because of the progress feature. + } + delegate.handleEvent(event); + } + + private void observe(Event event) { + final TestProgressAccumulator.Type type; + final ReportEntry re; + if (event instanceof TestsetStartingEvent) { + type = TestProgressAccumulator.Type.TESTSET_STARTING; + re = ((TestsetStartingEvent) event).getReportEntry(); + } else if (event instanceof TestStartingEvent) { + type = TestProgressAccumulator.Type.TEST_STARTING; + re = ((TestStartingEvent) event).getReportEntry(); + } else if (event instanceof TestSucceededEvent) { + type = TestProgressAccumulator.Type.TEST_SUCCEEDED; + re = ((TestSucceededEvent) event).getReportEntry(); + } else if (event instanceof TestFailedEvent) { + type = TestProgressAccumulator.Type.TEST_FAILED; + re = ((TestFailedEvent) event).getReportEntry(); + } else if (event instanceof TestErrorEvent) { + type = TestProgressAccumulator.Type.TEST_ERROR; + re = ((TestErrorEvent) event).getReportEntry(); + } else if (event instanceof TestSkippedEvent) { + type = TestProgressAccumulator.Type.TEST_SKIPPED; + re = ((TestSkippedEvent) event).getReportEntry(); + } else if (event instanceof TestsetCompletedEvent) { + type = TestProgressAccumulator.Type.TESTSET_COMPLETED; + re = ((TestsetCompletedEvent) event).getReportEntry(); + } else { + return; // not a test lifecycle event + } + + acc.record(type, re.getSourceName(), re.getName()); + + MvndTestProgress listener = MvndTestProgress.getListener(); + if (listener != null) { + listener.update( + projectId, + acc.getTestClass(), + acc.getTestMethod(), + acc.getCompleted(), + acc.getFailures(), + acc.getErrors(), + acc.getSkipped()); + } + } + } +} diff --git a/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/MvndSurefireProgressLocator.java b/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/MvndSurefireProgressLocator.java new file mode 100644 index 000000000..925ac86e3 --- /dev/null +++ b/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/MvndSurefireProgressLocator.java @@ -0,0 +1,28 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.mvndaemon.mvnd.forknode; + +/** + * Zero-dependency marker used by the daemon to locate this module's jar on disk + * (via {@code getProtectionDomain().getCodeSource().getLocation()}) so it can be added to the Surefire plugin realm. + * Deliberately imports nothing from Surefire so it links in the daemon realm. + */ +public final class MvndSurefireProgressLocator { + private MvndSurefireProgressLocator() {} +} diff --git a/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulator.java b/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulator.java new file mode 100644 index 000000000..2079c732b --- /dev/null +++ b/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulator.java @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.mvndaemon.mvnd.forknode; + +/** + * Accumulates per-fork test counts and the currently executing class/method. Not thread-safe: Surefire delivers + * fork-reader events on a single thread per fork channel. + */ +public class TestProgressAccumulator { + + public enum Type { + TESTSET_STARTING, + TEST_STARTING, + TEST_SUCCEEDED, + TEST_FAILED, + TEST_ERROR, + TEST_SKIPPED, + TESTSET_COMPLETED + } + + private int completed; + private int failures; + private int errors; + private int skipped; + private String testClass; + private String testMethod; + + public void record(Type type, String testClass, String testMethod) { + switch (type) { + case TESTSET_STARTING: + this.testClass = testClass; + this.testMethod = null; + break; + case TEST_STARTING: + this.testClass = testClass; + this.testMethod = testMethod; + break; + case TEST_SUCCEEDED: + completed++; + break; + case TEST_FAILED: + completed++; + failures++; + break; + case TEST_ERROR: + completed++; + errors++; + break; + case TEST_SKIPPED: + completed++; + skipped++; + break; + case TESTSET_COMPLETED: + break; + } + } + + public int getCompleted() { + return completed; + } + + public int getFailures() { + return failures; + } + + public int getErrors() { + return errors; + } + + public int getSkipped() { + return skipped; + } + + public String getTestClass() { + return testClass; + } + + public String getTestMethod() { + return testMethod; + } +} diff --git a/surefire-progress/src/test/java/org/mvndaemon/mvnd/forknode/MvndForkNodeFactoryTest.java b/surefire-progress/src/test/java/org/mvndaemon/mvnd/forknode/MvndForkNodeFactoryTest.java new file mode 100644 index 000000000..c9d50d1c4 --- /dev/null +++ b/surefire-progress/src/test/java/org/mvndaemon/mvnd/forknode/MvndForkNodeFactoryTest.java @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.mvndaemon.mvnd.forknode; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.maven.surefire.api.event.ControlByeEvent; +import org.apache.maven.surefire.api.event.Event; +import org.apache.maven.surefire.extensions.EventHandler; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mvndaemon.mvnd.testprogress.MvndTestProgress; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class MvndForkNodeFactoryTest { + + @AfterEach + void clearListener() { + MvndTestProgress.setListener(null); + } + + @Test + void alwaysDelegatesEvenWhenListenerThrows() { + List delegated = new ArrayList<>(); + EventHandler real = delegated::add; + + // A listener that always blows up must not prevent delegation to the real handler. + MvndTestProgress.setListener((p, c, m, comp, f, e, s) -> { + throw new RuntimeException("boom"); + }); + + EventHandler wrapper = + new MvndForkNodeFactory.ProgressEventHandler("proj", real, new TestProgressAccumulator()); + + wrapper.handleEvent(new ControlByeEvent()); + + assertEquals(1, delegated.size(), "the real handler must always be called"); + } + + @Test + void delegatesWhenNoListenerRegistered() { + List delegated = new ArrayList<>(); + EventHandler real = delegated::add; + + EventHandler wrapper = + new MvndForkNodeFactory.ProgressEventHandler("proj", real, new TestProgressAccumulator()); + + wrapper.handleEvent(new ControlByeEvent()); + + assertEquals(1, delegated.size(), "non-test events still pass through"); + } +} diff --git a/surefire-progress/src/test/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulatorTest.java b/surefire-progress/src/test/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulatorTest.java new file mode 100644 index 000000000..838417bc9 --- /dev/null +++ b/surefire-progress/src/test/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulatorTest.java @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.mvndaemon.mvnd.forknode; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mvndaemon.mvnd.forknode.TestProgressAccumulator.Type.TESTSET_STARTING; +import static org.mvndaemon.mvnd.forknode.TestProgressAccumulator.Type.TEST_ERROR; +import static org.mvndaemon.mvnd.forknode.TestProgressAccumulator.Type.TEST_FAILED; +import static org.mvndaemon.mvnd.forknode.TestProgressAccumulator.Type.TEST_SKIPPED; +import static org.mvndaemon.mvnd.forknode.TestProgressAccumulator.Type.TEST_STARTING; +import static org.mvndaemon.mvnd.forknode.TestProgressAccumulator.Type.TEST_SUCCEEDED; + +class TestProgressAccumulatorTest { + + @Test + void countsPassingTests() { + TestProgressAccumulator acc = new TestProgressAccumulator(); + acc.record(TESTSET_STARTING, "MyServiceTest", null); + acc.record(TEST_STARTING, "MyServiceTest", "shouldWork"); + acc.record(TEST_SUCCEEDED, "MyServiceTest", "shouldWork"); + acc.record(TEST_STARTING, "MyServiceTest", "alsoWorks"); + acc.record(TEST_SUCCEEDED, "MyServiceTest", "alsoWorks"); + + assertEquals(2, acc.getCompleted()); + assertEquals(0, acc.getFailures()); + assertEquals(0, acc.getErrors()); + assertEquals(0, acc.getSkipped()); + assertEquals("MyServiceTest", acc.getTestClass()); + assertEquals("alsoWorks", acc.getTestMethod()); + } + + @Test + void countsFailuresErrorsAndSkips() { + TestProgressAccumulator acc = new TestProgressAccumulator(); + acc.record(TEST_STARTING, "T", "a"); + acc.record(TEST_FAILED, "T", "a"); + acc.record(TEST_STARTING, "T", "b"); + acc.record(TEST_ERROR, "T", "b"); + acc.record(TEST_STARTING, "T", "c"); + acc.record(TEST_SKIPPED, "T", "c"); + + assertEquals(3, acc.getCompleted()); + assertEquals(1, acc.getFailures()); + assertEquals(1, acc.getErrors()); + assertEquals(1, acc.getSkipped()); + } + + @Test + void testsetStartingSetsClassWithNullMethod() { + TestProgressAccumulator acc = new TestProgressAccumulator(); + acc.record(TESTSET_STARTING, "OtherTest", null); + assertEquals("OtherTest", acc.getTestClass()); + assertNull(acc.getTestMethod()); + } +} From bb503f05fc00d0c38c53f1e62a05475f318c94bd Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:04:40 -0400 Subject: [PATCH 03/12] feat(daemon): dispatch test progress and register bridge listener ClientDispatcher.testProgress enqueues a PROJECT_TEST_PROGRESS message; Server registers a MvndTestProgress listener that forwards to it around each build (guarded by mvnd.testProgress) and clears it in finally. Branch-identical with mvnd-1.x. Co-Authored-By: Claude Opus 4.8 --- daemon/pom.xml | 4 ++++ .../org/mvndaemon/mvnd/daemon/ClientDispatcher.java | 11 +++++++++++ .../main/java/org/mvndaemon/mvnd/daemon/Server.java | 12 ++++++++++++ 3 files changed, 27 insertions(+) diff --git a/daemon/pom.xml b/daemon/pom.xml index ee26e73a6..141d03f08 100644 --- a/daemon/pom.xml +++ b/daemon/pom.xml @@ -41,6 +41,10 @@ org.apache.maven.daemon mvnd-common + + org.apache.maven.daemon + mvnd-surefire-progress + org.apache.maven.daemon mvnd-native diff --git a/daemon/src/main/java/org/mvndaemon/mvnd/daemon/ClientDispatcher.java b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/ClientDispatcher.java index e262d1ec9..111054526 100644 --- a/daemon/src/main/java/org/mvndaemon/mvnd/daemon/ClientDispatcher.java +++ b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/ClientDispatcher.java @@ -130,6 +130,17 @@ public void mojoStarted(ExecutionEvent event) { execution.getExecutionId())); } + public void testProgress( + String projectId, + String testClass, + String testMethod, + int completed, + int failures, + int errors, + int skipped) { + queue.add(Message.projectTestProgress(projectId, testClass, testMethod, completed, failures, errors, skipped)); + } + public void finish(int exitCode) throws Exception { queue.add(new Message.BuildFinished(exitCode)); queue.add(Message.BareMessage.STOP_SINGLETON); diff --git a/daemon/src/main/java/org/mvndaemon/mvnd/daemon/Server.java b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/Server.java index 3e386692c..7cafa89aa 100644 --- a/daemon/src/main/java/org/mvndaemon/mvnd/daemon/Server.java +++ b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/Server.java @@ -65,6 +65,7 @@ import org.mvndaemon.mvnd.common.SocketFamily; import org.mvndaemon.mvnd.daemon.DaemonExpiration.DaemonExpirationResult; import org.mvndaemon.mvnd.daemon.DaemonExpiration.DaemonExpirationStrategy; +import org.mvndaemon.mvnd.testprogress.MvndTestProgress; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -508,6 +509,16 @@ private void handle(DaemonConnection connection, BuildRequest buildRequest) { final BlockingQueue sendQueue = new PriorityBlockingQueue<>(64, Message.getMessageComparator()); final BlockingQueue recvQueue = new LinkedBlockingDeque<>(); final BuildEventListener buildEventListener = new ClientDispatcher(sendQueue); + final boolean testProgressEnabled = Environment.MVND_TEST_PROGRESS + .asOptional() + .map(Boolean::parseBoolean) + .orElse(Boolean.TRUE); + if (testProgressEnabled) { + final ClientDispatcher clientDispatcher = (ClientDispatcher) buildEventListener; + MvndTestProgress.setListener((projectId, testClass, testMethod, completed, failures, errors, skipped) -> + clientDispatcher.testProgress( + projectId, testClass, testMethod, completed, failures, errors, skipped)); + } final DaemonInputStream daemonInputStream = new DaemonInputStream( (projectId, bytesToRead) -> sendQueue.add(Message.requestInput(projectId, bytesToRead)), (projectId) -> sendQueue.add(Message.requestInputAvailable(projectId))); @@ -649,6 +660,7 @@ public T request(Message request, Class responseType, Pre } catch (Throwable t) { LOGGER.error("Error while building project", t); } finally { + MvndTestProgress.setListener(null); System.setIn(in); if (!noDaemon) { LOGGER.info("Daemon back to idle"); From 02f7608ec41ae521311775deda26742e7d3d8d09 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:04:41 -0400 Subject: [PATCH 04/12] feat(daemon): inject forkNode via MojoExecutionConfigurator + IT (2.x) Maven 4 wiring for the test-progress feed: - DaemonPlexusContainerCapsuleFactory: export the testprogress bridge package to the core realm so the surefire plugin realm shares one MvndTestProgress Class - MvndMojoExecutionConfigurator: overrides the default Maven 4 configurator to inject into surefire test / failsafe integration-test executions (model mutation in afterProjectsRead is ignored under Maven 4's immutable model). Guarded on Surefire >= 3.0.0-M5. The Sisu wiring is load-bearing: @Named("default") + explicit implements MojoExecutionConfigurator + no-arg ctor + @Priority(10) are all required for it to win the Map "default" key (see the class javadoc) - InvalidatingPluginRealmCache: addURL the mvnd-surefire-progress jar onto the surefire/failsafe plugin realm so it can load MvndForkNodeFactory - dist bundles the jar; bash completion adds -Dmvnd.testProgress - integration-tests: TestProgressTest (emission + opt-out) validates end-to-end against a real daemon Co-Authored-By: Claude Opus 4.8 --- .../DaemonPlexusContainerCapsuleFactory.java | 1 + .../InvalidatingPluginRealmCache.java | 28 +++- .../daemon/MvndMojoExecutionConfigurator.java | 121 ++++++++++++++++++ .../MvndMojoExecutionConfiguratorTest.java | 40 ++++++ .../main/distro/bin/mvnd-bash-completion.bash | 2 +- dist/src/main/provisio/maven-distro.xml | 3 + .../mvndaemon/mvnd/it/TestProgressTest.java | 75 +++++++++++ .../src/test/projects/test-progress/pom.xml | 55 ++++++++ .../mvndaemon/mvnd/test/MyServiceTest.java | 48 +++++++ 9 files changed, 371 insertions(+), 2 deletions(-) create mode 100644 daemon/src/main/java/org/mvndaemon/mvnd/daemon/MvndMojoExecutionConfigurator.java create mode 100644 daemon/src/test/java/org/mvndaemon/mvnd/daemon/MvndMojoExecutionConfiguratorTest.java create mode 100644 integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressTest.java create mode 100644 integration-tests/src/test/projects/test-progress/pom.xml create mode 100644 integration-tests/src/test/projects/test-progress/src/test/java/org/mvndaemon/mvnd/test/MyServiceTest.java diff --git a/daemon/src/main/java/org/apache/maven/cli/DaemonPlexusContainerCapsuleFactory.java b/daemon/src/main/java/org/apache/maven/cli/DaemonPlexusContainerCapsuleFactory.java index 215bdae8a..3ad19766f 100644 --- a/daemon/src/main/java/org/apache/maven/cli/DaemonPlexusContainerCapsuleFactory.java +++ b/daemon/src/main/java/org/apache/maven/cli/DaemonPlexusContainerCapsuleFactory.java @@ -36,6 +36,7 @@ protected Set collectExportedPackages( CoreExtensionEntry coreEntry, List extensionEntries) { HashSet result = new HashSet<>(super.collectExportedPackages(coreEntry, extensionEntries)); result.add("org.mvndaemon.mvnd.interactivity"); + result.add("org.mvndaemon.mvnd.testprogress"); return result; } diff --git a/daemon/src/main/java/org/mvndaemon/mvnd/cache/invalidating/InvalidatingPluginRealmCache.java b/daemon/src/main/java/org/mvndaemon/mvnd/cache/invalidating/InvalidatingPluginRealmCache.java index 805866e51..f88ac2767 100644 --- a/daemon/src/main/java/org/mvndaemon/mvnd/cache/invalidating/InvalidatingPluginRealmCache.java +++ b/daemon/src/main/java/org/mvndaemon/mvnd/cache/invalidating/InvalidatingPluginRealmCache.java @@ -22,6 +22,7 @@ import javax.inject.Named; import javax.inject.Singleton; +import java.net.URL; import java.nio.file.Path; import java.util.List; import java.util.stream.Stream; @@ -36,6 +37,7 @@ import org.eclipse.sisu.Priority; import org.mvndaemon.mvnd.cache.Cache; import org.mvndaemon.mvnd.cache.CacheFactory; +import org.mvndaemon.mvnd.forknode.MvndSurefireProgressLocator; @Singleton @Named @@ -86,7 +88,9 @@ public CacheRecord get(Key key, PluginRealmSupplier supplier) try { Record r = cache.computeIfAbsent(key, k -> { try { - return new Record(supplier.load()); + CacheRecord loaded = supplier.load(); + addTestProgressJarIfSurefire(loaded.getRealm()); + return new Record(loaded); } catch (PluginResolutionException | PluginContainerException e) { throw new RuntimeException(e); } @@ -103,6 +107,28 @@ public CacheRecord get(Key key, PluginRealmSupplier supplier) } } + /** + * Puts the {@code mvnd-surefire-progress} jar on the surefire/failsafe plugin realm so Surefire can load + * {@code MvndForkNodeFactory} when it parses the injected {@code } configuration. No-op for any other + * plugin realm, and never fails plugin-realm creation because of the progress feature. + */ + private static void addTestProgressJarIfSurefire(ClassRealm realm) { + String id = realm != null ? realm.getId() : null; + if (id == null || (!id.contains("maven-surefire-plugin") && !id.contains("maven-failsafe-plugin"))) { + return; + } + try { + java.security.CodeSource cs = + MvndSurefireProgressLocator.class.getProtectionDomain().getCodeSource(); + URL jar = cs != null ? cs.getLocation() : null; + if (jar != null) { + realm.addURL(jar); + } + } catch (RuntimeException e) { + // ignore: the test-progress feature must never break plugin realm creation + } + } + @Override public CacheRecord put(Key key, ClassRealm pluginRealm, List pluginArtifacts) { CacheRecord record = super.put(key, pluginRealm, pluginArtifacts); diff --git a/daemon/src/main/java/org/mvndaemon/mvnd/daemon/MvndMojoExecutionConfigurator.java b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/MvndMojoExecutionConfigurator.java new file mode 100644 index 000000000..64a460c2e --- /dev/null +++ b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/MvndMojoExecutionConfigurator.java @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.mvndaemon.mvnd.daemon; + +import javax.inject.Named; +import javax.inject.Singleton; + +import org.apache.maven.lifecycle.MojoExecutionConfigurator; +import org.apache.maven.lifecycle.internal.DefaultMojoExecutionConfigurator; +import org.apache.maven.plugin.MojoExecution; +import org.apache.maven.project.MavenProject; +import org.codehaus.plexus.util.xml.Xpp3Dom; +import org.eclipse.sisu.Priority; +import org.mvndaemon.mvnd.common.Environment; + +/** + * Overrides the default Maven 4 mojo-execution configurator to auto-inject an mvnd {@code } into surefire + * {@code test} and failsafe {@code integration-test} executions, unless the user already configured one or the feature + * is disabled. This is the Maven-4 mechanism: model mutation in {@code afterProjectsRead} is ignored under Maven 4, so + * the injection must happen where Surefire's effective configuration is assembled. + * + *

The referenced factory lives in {@code org.mvndaemon.mvnd.forknode}, a package deliberately kept out of the + * core-realm-exported {@code org.mvndaemon.mvnd.testprogress} prefix so the surefire plugin realm loads it from its own + * class path (see {@code InvalidatingPluginRealmCache}) rather than importing it from the core realm. + * + *

The Sisu wiring here is load-bearing and non-obvious. Maven 4's concurrent {@code BuildPlanExecutor} selects the + * configurator via {@code Map.get("default")}, so this component must: + *

    + *
  • be {@code @Named("default")} (an empty {@code @Named} keys the map entry by fully-qualified class name, not + * {@code "default"}, so it would never be selected);
  • + *
  • declare {@code implements MojoExecutionConfigurator} explicitly (Sisu does not publish the interface it only + * inherits through the superclass, so without this it is absent from the map);
  • + *
  • use a no-argument constructor (Sisu silently drops a candidate from a collection binding when a constructor + * dependency such as {@code MessageBuilderFactory} cannot be resolved in that scope; the superclass no-arg + * constructor supplies a default {@code MessageBuilderFactory} itself);
  • + *
  • carry {@code @Priority(10)} to win the {@code "default"} key over maven-core's own binding.
  • + *
+ */ +@Named("default") +@Singleton +@Priority(10) +public class MvndMojoExecutionConfigurator extends DefaultMojoExecutionConfigurator + implements MojoExecutionConfigurator { + + private static final String FORK_NODE_IMPL = "org.mvndaemon.mvnd.forknode.MvndForkNodeFactory"; + + public MvndMojoExecutionConfigurator() { + super(); + } + + @Override + public void configure(MavenProject project, MojoExecution mojoExecution, boolean allowPluginLevelConfig) { + super.configure(project, mojoExecution, allowPluginLevelConfig); + if (!isEnabled() || !isTestGoal(mojoExecution) || !supportsForkNode(mojoExecution.getVersion())) { + // Never inject into a Surefire/Failsafe that cannot load the fork-node SPI; it would fail the build. + return; + } + Xpp3Dom config = mojoExecution.getConfiguration(); + if (config == null) { + config = new Xpp3Dom("configuration"); + mojoExecution.setConfiguration(config); + } + if (config.getChild("forkNode") != null) { + return; // user already configured a fork node + } + Xpp3Dom forkNode = new Xpp3Dom("forkNode"); + forkNode.setAttribute("implementation", FORK_NODE_IMPL); + Xpp3Dom projectId = new Xpp3Dom("projectId"); + projectId.setValue(project.getArtifactId()); + forkNode.addChild(projectId); + config.addChild(forkNode); + } + + private static boolean isEnabled() { + return Environment.MVND_TEST_PROGRESS + .asOptional() + .map(Boolean::parseBoolean) + .orElse(Boolean.TRUE); + } + + private static boolean isTestGoal(MojoExecution e) { + String artifactId = e.getArtifactId(); + String goal = e.getGoal(); + return ("maven-surefire-plugin".equals(artifactId) && "test".equals(goal)) + || ("maven-failsafe-plugin".equals(artifactId) && "integration-test".equals(goal)); + } + + /** The Surefire fork-node SPI exists since 3.0.0-M5; older versions must be skipped so the build never fails. */ + static boolean supportsForkNode(String version) { + if (version == null) { + return false; + } + java.util.regex.Matcher m = java.util.regex.Pattern.compile("^(\\d+)\\.(\\d+)\\.(\\d+)(?:-M(\\d+))?") + .matcher(version); + if (!m.find()) { + return false; + } + int major = Integer.parseInt(m.group(1)); + if (major != 3) { + return major > 3; + } + String milestone = m.group(4); + return milestone == null || Integer.parseInt(milestone) >= 5; + } +} diff --git a/daemon/src/test/java/org/mvndaemon/mvnd/daemon/MvndMojoExecutionConfiguratorTest.java b/daemon/src/test/java/org/mvndaemon/mvnd/daemon/MvndMojoExecutionConfiguratorTest.java new file mode 100644 index 000000000..c7a778042 --- /dev/null +++ b/daemon/src/test/java/org/mvndaemon/mvnd/daemon/MvndMojoExecutionConfiguratorTest.java @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.mvndaemon.mvnd.daemon; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MvndMojoExecutionConfiguratorTest { + + @Test + void injectsOnlyForSurefireVersionsThatSupportForkNode() { + // forkNode SPI exists since 3.0.0-M5 -> anything older must be skipped so the build never fails + assertFalse(MvndMojoExecutionConfigurator.supportsForkNode(null)); + assertFalse(MvndMojoExecutionConfigurator.supportsForkNode("2.22.2")); + assertFalse(MvndMojoExecutionConfigurator.supportsForkNode("3.0.0-M4")); + + assertTrue(MvndMojoExecutionConfigurator.supportsForkNode("3.0.0-M5")); + assertTrue(MvndMojoExecutionConfigurator.supportsForkNode("3.0.0-M8")); + assertTrue(MvndMojoExecutionConfigurator.supportsForkNode("3.5.6")); + assertTrue(MvndMojoExecutionConfigurator.supportsForkNode("4.0.0")); + } +} diff --git a/dist/src/main/distro/bin/mvnd-bash-completion.bash b/dist/src/main/distro/bin/mvnd-bash-completion.bash index cce9a82f6..0d581d623 100755 --- a/dist/src/main/distro/bin/mvnd-bash-completion.bash +++ b/dist/src/main/distro/bin/mvnd-bash-completion.bash @@ -218,7 +218,7 @@ _mvnd() local mvnd_opts="-1" local mvnd_long_opts="--color|--completion|--diag|--purge|--serial|--status|--stop" - local mvnd_properties="-Djava.home|-Djdk.java.options|-Dmaven.multiModuleProjectDirectory|-Dmaven.repo.local|-Dmaven.settings|-Dmaven.style.color|-Dmvnd.buildTime|-Dmvnd.builder|-Dmvnd.cancelConnectTimeout|-Dmvnd.connectTimeout|-Dmvnd.coreExtensionsExclude|-Dmvnd.daemonStorage|-Dmvnd.debug|-Dmvnd.debug.address|-Dmvnd.duplicateDaemonGracePeriod|-Dmvnd.enableAssertions|-Dmvnd.expirationCheckDelay|-Dmvnd.home|-Dmvnd.idleTimeout|-Dmvnd.jvmArgs|-Dmvnd.keepAlive|-Dmvnd.logPurgePeriod|-Dmvnd.maxHeapSize|-Dmvnd.maxLostKeepAlive|-Dmvnd.minHeapSize|-Dmvnd.minThreads|-Dmvnd.noBuffering|-Dmvnd.noDaemon|-Dmvnd.noModelCache|-Dmvnd.pluginRealmEvictPattern|-Dmvnd.propertiesPath|-Dmvnd.registry|-Dmvnd.rollingWindowSize|-Dmvnd.serial|-Dmvnd.socketConnectTimeout|-Dmvnd.socketFamily|-Dmvnd.threadStackSize|-Dmvnd.threads|-Duser.dir|-Duser.home" + local mvnd_properties="-Djava.home|-Djdk.java.options|-Dmaven.multiModuleProjectDirectory|-Dmaven.repo.local|-Dmaven.settings|-Dmaven.style.color|-Dmvnd.buildTime|-Dmvnd.builder|-Dmvnd.cancelConnectTimeout|-Dmvnd.connectTimeout|-Dmvnd.coreExtensionsExclude|-Dmvnd.daemonStorage|-Dmvnd.debug|-Dmvnd.debug.address|-Dmvnd.duplicateDaemonGracePeriod|-Dmvnd.enableAssertions|-Dmvnd.expirationCheckDelay|-Dmvnd.home|-Dmvnd.idleTimeout|-Dmvnd.jvmArgs|-Dmvnd.keepAlive|-Dmvnd.logPurgePeriod|-Dmvnd.maxHeapSize|-Dmvnd.maxLostKeepAlive|-Dmvnd.minHeapSize|-Dmvnd.minThreads|-Dmvnd.noBuffering|-Dmvnd.noDaemon|-Dmvnd.noModelCache|-Dmvnd.pluginRealmEvictPattern|-Dmvnd.propertiesPath|-Dmvnd.registry|-Dmvnd.rollingWindowSize|-Dmvnd.serial|-Dmvnd.socketConnectTimeout|-Dmvnd.socketFamily|-Dmvnd.testProgress|-Dmvnd.threadStackSize|-Dmvnd.threads|-Duser.dir|-Duser.home" local opts="-am|-amd|-B|-C|-c|-cpu|-D|-e|-emp|-ep|-f|-fae|-ff|-fn|-gs|-h|-l|-N|-npr|-npu|-nsu|-o|-P|-pl|-q|-rf|-s|-T|-t|-U|-up|-V|-v|-X|${mvnd_opts}" local long_opts="--also-make|--also-make-dependents|--batch-mode|--strict-checksums|--lax-checksums|--check-plugin-updates|--define|--errors|--encrypt-master-password|--encrypt-password|--file|--fail-at-end|--fail-fast|--fail-never|--global-settings|--help|--log-file|--non-recursive|--no-plugin-registry|--no-plugin-updates|--no-snapshot-updates|--offline|--activate-profiles|--projects|--quiet|--resume-from|--settings|--threads|--toolchains|--update-snapshots|--update-plugins|--show-version|--version|--debug|${mvnd_long_opts}" diff --git a/dist/src/main/provisio/maven-distro.xml b/dist/src/main/provisio/maven-distro.xml index 79a14bf89..4ff5d2aa6 100644 --- a/dist/src/main/provisio/maven-distro.xml +++ b/dist/src/main/provisio/maven-distro.xml @@ -46,6 +46,9 @@ + + + diff --git a/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressTest.java b/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressTest.java new file mode 100644 index 000000000..0804e46ce --- /dev/null +++ b/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressTest.java @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.mvndaemon.mvnd.it; + +import javax.inject.Inject; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.mvndaemon.mvnd.assertj.TestClientOutput; +import org.mvndaemon.mvnd.client.Client; +import org.mvndaemon.mvnd.common.Message; +import org.mvndaemon.mvnd.junit.MvndTest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@MvndTest(projectDir = "src/test/projects/test-progress") +class TestProgressTest { + + @Inject + Client client; + + @Test + void emitsIncreasingTestProgress() throws InterruptedException { + final TestClientOutput output = new TestClientOutput(); + client.execute(output, "clean", "test", "-B").assertSuccess(); + + List events = testProgressEvents(output); + + assertTrue(!events.isEmpty(), "expected PROJECT_TEST_PROGRESS messages, got none"); + int maxCompleted = events.stream() + .mapToInt(Message.ProjectTestProgressEvent::getCompleted) + .max() + .orElse(0); + assertTrue( + maxCompleted >= 3, + "expected completed count to reach the number of executed tests, got " + maxCompleted); + assertTrue( + events.stream().anyMatch(e -> "org.mvndaemon.mvnd.test.MyServiceTest".equals(e.getTestClass())), + "expected the current test class name to be reported"); + } + + @Test + void disabledEmitsNoTestProgress() throws InterruptedException { + final TestClientOutput output = new TestClientOutput(); + client.execute(output, "clean", "test", "-B", "-Dmvnd.testProgress=false") + .assertSuccess(); + + assertEquals(0, testProgressEvents(output).size(), "no test-progress messages expected when feature disabled"); + } + + private static List testProgressEvents(TestClientOutput output) { + return output.getMessages().stream() + .filter(Message.ProjectTestProgressEvent.class::isInstance) + .map(Message.ProjectTestProgressEvent.class::cast) + .toList(); + } +} diff --git a/integration-tests/src/test/projects/test-progress/pom.xml b/integration-tests/src/test/projects/test-progress/pom.xml new file mode 100644 index 000000000..46e3bc2bf --- /dev/null +++ b/integration-tests/src/test/projects/test-progress/pom.xml @@ -0,0 +1,55 @@ + + + + 4.0.0 + org.mvndaemon.mvnd.test.test-progress + test-progress + 0.0.1-SNAPSHOT + jar + + + UTF-8 + 17 + 17 + + 3.5.6 + 5.14.4 + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + + + + diff --git a/integration-tests/src/test/projects/test-progress/src/test/java/org/mvndaemon/mvnd/test/MyServiceTest.java b/integration-tests/src/test/projects/test-progress/src/test/java/org/mvndaemon/mvnd/test/MyServiceTest.java new file mode 100644 index 000000000..d3e0a78e6 --- /dev/null +++ b/integration-tests/src/test/projects/test-progress/src/test/java/org/mvndaemon/mvnd/test/MyServiceTest.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.mvndaemon.mvnd.test; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class MyServiceTest { + + @Test + void shouldWork() { + assertEquals(2, 1 + 1); + } + + @Test + void alsoWorks() { + assertEquals(4, 2 + 2); + } + + @Test + void andAgain() { + assertEquals(9, 3 * 3); + } + + @Test + @Disabled("intentionally skipped to exercise the skipped count") + void skippedForNow() { + assertEquals(1, 2); + } +} From 3132db56a76206badddc2aad85f9ccbc9be25edf Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:23:38 -0400 Subject: [PATCH 05/12] feat: report flaky, failed, and errored tests in the build summary Extend the live test-progress feed beyond basic counts so a failing build tells you which tests broke and why, directly in mvnd's output. - Accumulate retrying/flaky state and capture FAILED/ERRORED test identities plus a compact failure message (Surefire smartTrimmedStackTrace, sanitized), streamed alongside flaky names in ProjectTestProgressEvent. - Render a live "Flaky tests:" summary at build end, and inject a "Failed tests:" / "Errored tests:" block (" Class#method: message") directly above Maven's BUILD FAILURE banner, with a build-finished fallback. - Add mvnd.hideBannedProjectSkips (default true) to drop the per-project "Skipping X / banned from the build" reactor blocks while preserving the final reactor "... SKIPPED" rows; disable with =false. - Cover with accumulator, message round-trip, and TerminalOutput unit tests (ordering + banned-block filter), plus a new TestProgressFailureTest IT. Co-Authored-By: Claude Opus 4.8 --- .../mvndaemon/mvnd/client/DefaultClient.java | 3 +- .../mvnd/client/DaemonParameters.java | 4 + .../mvndaemon/mvnd/common/Environment.java | 8 + .../org/mvndaemon/mvnd/common/Message.java | 165 +++++++- .../mvnd/common/logging/TerminalOutput.java | 354 ++++++++++++++++-- .../mvnd/testprogress/MvndTestProgress.java | 9 +- .../mvndaemon/mvnd/common/MessageTest.java | 27 +- .../common/logging/TerminalOutputTest.java | 206 +++++++++- .../mvnd/daemon/ClientDispatcher.java | 23 +- .../org/mvndaemon/mvnd/daemon/Server.java | 30 +- .../mvnd/it/TestProgressFailureTest.java | 61 +++ .../mvndaemon/mvnd/it/TestProgressTest.java | 17 + .../projects/test-progress-failure/pom.xml | 59 +++ .../mvnd/test/FailingServiceTest.java | 36 ++ .../src/test/projects/test-progress/pom.xml | 3 + .../mvndaemon/mvnd/test/FlakyServiceTest.java | 35 ++ .../mvnd/forknode/MvndForkNodeFactory.java | 39 +- .../forknode/TestProgressAccumulator.java | 214 ++++++++++- .../forknode/MvndForkNodeFactoryTest.java | 6 +- .../forknode/TestProgressAccumulatorTest.java | 76 ++++ 20 files changed, 1305 insertions(+), 70 deletions(-) create mode 100644 integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressFailureTest.java create mode 100644 integration-tests/src/test/projects/test-progress-failure/pom.xml create mode 100644 integration-tests/src/test/projects/test-progress-failure/src/test/java/org/mvndaemon/mvnd/test/FailingServiceTest.java create mode 100644 integration-tests/src/test/projects/test-progress/src/test/java/org/mvndaemon/mvnd/test/FlakyServiceTest.java diff --git a/client/src/main/java-mvnd/org/mvndaemon/mvnd/client/DefaultClient.java b/client/src/main/java-mvnd/org/mvndaemon/mvnd/client/DefaultClient.java index 86dd62951..f0ebd3d08 100644 --- a/client/src/main/java-mvnd/org/mvndaemon/mvnd/client/DefaultClient.java +++ b/client/src/main/java-mvnd/org/mvndaemon/mvnd/client/DefaultClient.java @@ -151,7 +151,8 @@ public static void main(String[] argv) throws Exception { int exitCode = 0; boolean noBuffering = batchMode || parameters.noBuffering(); - try (TerminalOutput output = new TerminalOutput(noBuffering, parameters.rollingWindowSize(), logFile)) { + try (TerminalOutput output = new TerminalOutput( + noBuffering, parameters.hideBannedProjectSkips(), parameters.rollingWindowSize(), logFile)) { try { // Color // We need to defer this part until the terminal is created diff --git a/client/src/main/java/org/mvndaemon/mvnd/client/DaemonParameters.java b/client/src/main/java/org/mvndaemon/mvnd/client/DaemonParameters.java index 9f7b87630..71d3cb258 100644 --- a/client/src/main/java/org/mvndaemon/mvnd/client/DaemonParameters.java +++ b/client/src/main/java/org/mvndaemon/mvnd/client/DaemonParameters.java @@ -392,6 +392,10 @@ public boolean noBuffering() { return property(Environment.MVND_NO_BUFERING).orFail().asBoolean(); } + public boolean hideBannedProjectSkips() { + return property(Environment.MVND_HIDE_BANNED_PROJECT_SKIPS).orFail().asBoolean(); + } + public int rollingWindowSize() { return property(Environment.MVND_ROLLING_WINDOW_SIZE).orFail().asInt(); } diff --git a/common/src/main/java/org/mvndaemon/mvnd/common/Environment.java b/common/src/main/java/org/mvndaemon/mvnd/common/Environment.java index 01ebaf3f3..deddb3c3b 100644 --- a/common/src/main/java/org/mvndaemon/mvnd/common/Environment.java +++ b/common/src/main/java/org/mvndaemon/mvnd/common/Environment.java @@ -168,6 +168,14 @@ public enum Environment { */ MVND_TEST_PROGRESS("mvnd.testProgress", null, Boolean.TRUE, OptionType.BOOLEAN, Flags.OPTIONAL), + /** + * If true (default), the client omits the per-project + * Skipping X / This project has been banned from the build due to previous failures. blocks that + * Maven logs after a reactor failure. Set to false to show them. The final reactor summary (including + * its ... SKIPPED rows) is always kept. + */ + MVND_HIDE_BANNED_PROJECT_SKIPS("mvnd.hideBannedProjectSkips", null, Boolean.TRUE, OptionType.BOOLEAN, Flags.NONE), + /** * If true, the daemon will be launched in debug mode with the following JVM argument: * -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=8000; otherwise the debug argument is diff --git a/common/src/main/java/org/mvndaemon/mvnd/common/Message.java b/common/src/main/java/org/mvndaemon/mvnd/common/Message.java index 6ca27d4aa..1dc953f35 100644 --- a/common/src/main/java/org/mvndaemon/mvnd/common/Message.java +++ b/common/src/main/java/org/mvndaemon/mvnd/common/Message.java @@ -635,46 +635,87 @@ public void write(DataOutputStream output) throws IOException { public static class ProjectTestProgressEvent extends Message { final String projectId; + final int forkChannelId; final String testClass; final String testMethod; final int completed; final int failures; final int errors; final int skipped; + final int retrying; + final int flaky; + final List flakyTests; + final List failedTests; + final List erroredTests; public static ProjectTestProgressEvent read(DataInputStream input) throws IOException { final String projectId = readUTF(input); + final int forkChannelId = input.readInt(); final String testClass = readUTF(input); final String testMethod = readUTF(input); final int completed = input.readInt(); final int failures = input.readInt(); final int errors = input.readInt(); final int skipped = input.readInt(); - return new ProjectTestProgressEvent(projectId, testClass, testMethod, completed, failures, errors, skipped); + final int retrying = input.readInt(); + final int flaky = input.readInt(); + final List flakyTests = readStringList(input); + final List failedTests = readStringList(input); + final List erroredTests = readStringList(input); + return new ProjectTestProgressEvent( + projectId, + forkChannelId, + testClass, + testMethod, + completed, + failures, + errors, + skipped, + retrying, + flaky, + flakyTests, + failedTests, + erroredTests); } public ProjectTestProgressEvent( String projectId, + int forkChannelId, String testClass, String testMethod, int completed, int failures, int errors, - int skipped) { + int skipped, + int retrying, + int flaky, + List flakyTests, + List failedTests, + List erroredTests) { super(PROJECT_TEST_PROGRESS); this.projectId = Objects.requireNonNull(projectId, "projectId cannot be null"); + this.forkChannelId = forkChannelId; this.testClass = testClass; this.testMethod = testMethod; this.completed = completed; this.failures = failures; this.errors = errors; this.skipped = skipped; + this.retrying = retrying; + this.flaky = flaky; + this.flakyTests = flakyTests == null ? new ArrayList<>() : new ArrayList<>(flakyTests); + this.failedTests = failedTests == null ? new ArrayList<>() : new ArrayList<>(failedTests); + this.erroredTests = erroredTests == null ? new ArrayList<>() : new ArrayList<>(erroredTests); } public String getProjectId() { return projectId; } + public int getForkChannelId() { + return forkChannelId; + } + public String getTestClass() { return testClass; } @@ -699,26 +740,109 @@ public int getSkipped() { return skipped; } + public int getRetrying() { + return retrying; + } + + public int getFlaky() { + return flaky; + } + + public List getFlakyTests() { + return flakyTests; + } + + public List getFailedTests() { + return failedTests; + } + + public List getErroredTests() { + return erroredTests; + } + @Override public void write(DataOutputStream output) throws IOException { super.write(output); writeUTF(output, projectId); + output.writeInt(forkChannelId); writeUTF(output, testClass); writeUTF(output, testMethod); output.writeInt(completed); output.writeInt(failures); output.writeInt(errors); output.writeInt(skipped); + output.writeInt(retrying); + output.writeInt(flaky); + writeStringList(output, flakyTests); + writeStringList(output, failedTests); + writeStringList(output, erroredTests); } @Override public String toString() { - return "ProjectTestProgress{projectId='" + projectId + "', testClass='" + testClass + "', testMethod='" - + testMethod + "', completed=" + completed + ", failures=" + failures + ", errors=" + errors - + ", skipped=" + skipped + "}"; + return "ProjectTestProgress{projectId='" + projectId + "', forkChannelId=" + forkChannelId + + ", testClass='" + testClass + "', testMethod='" + testMethod + "', completed=" + completed + + ", failures=" + failures + ", errors=" + errors + ", skipped=" + skipped + + ", retrying=" + retrying + ", flaky=" + flaky + ", flakyTests=" + flakyTests + + ", failedTests=" + failedTests + ", erroredTests=" + erroredTests + "}"; } } + public static ProjectTestProgressEvent projectTestProgress( + String projectId, + int forkChannelId, + String testClass, + String testMethod, + int completed, + int failures, + int errors, + int skipped) { + return projectTestProgress( + projectId, + forkChannelId, + testClass, + testMethod, + completed, + failures, + errors, + skipped, + 0, + 0, + null, + null, + null); + } + + public static ProjectTestProgressEvent projectTestProgress( + String projectId, + int forkChannelId, + String testClass, + String testMethod, + int completed, + int failures, + int errors, + int skipped, + int retrying, + int flaky, + List flakyTests, + List failedTests, + List erroredTests) { + return new ProjectTestProgressEvent( + projectId, + forkChannelId, + testClass, + testMethod, + completed, + failures, + errors, + skipped, + retrying, + flaky, + flakyTests, + failedTests, + erroredTests); + } + public static ProjectTestProgressEvent projectTestProgress( String projectId, String testClass, @@ -727,7 +851,36 @@ public static ProjectTestProgressEvent projectTestProgress( int failures, int errors, int skipped) { - return new ProjectTestProgressEvent(projectId, testClass, testMethod, completed, failures, errors, skipped); + return projectTestProgress(projectId, -1, testClass, testMethod, completed, failures, errors, skipped); + } + + public static ProjectTestProgressEvent projectTestProgress( + String projectId, + String testClass, + String testMethod, + int completed, + int failures, + int errors, + int skipped, + int retrying, + int flaky, + List flakyTests, + List failedTests, + List erroredTests) { + return projectTestProgress( + projectId, + -1, + testClass, + testMethod, + completed, + failures, + errors, + skipped, + retrying, + flaky, + flakyTests, + failedTests, + erroredTests); } public static class BuildStarted extends Message { diff --git a/common/src/main/java/org/mvndaemon/mvnd/common/logging/TerminalOutput.java b/common/src/main/java/org/mvndaemon/mvnd/common/logging/TerminalOutput.java index 646759dce..6a9fbdf99 100644 --- a/common/src/main/java/org/mvndaemon/mvnd/common/logging/TerminalOutput.java +++ b/common/src/main/java/org/mvndaemon/mvnd/common/logging/TerminalOutput.java @@ -29,9 +29,12 @@ import java.util.Collections; import java.util.Deque; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Consumer; +import java.util.regex.Pattern; import java.util.stream.Collector; import java.util.stream.Collectors; @@ -135,6 +138,16 @@ public class TerminalOutput implements ClientOutput { private String buildStatus; private boolean displayDone = false; private boolean noBuffering; + private final Map failureProgress = new LinkedHashMap<>(); + private final Map> flakyTests = new LinkedHashMap<>(); + private final Map> failedTests = new LinkedHashMap<>(); + private final Map> erroredTests = new LinkedHashMap<>(); + /** Guards against emitting the aggregated failed/errored summary more than once. */ + private boolean failureSummaryEmitted; + /** When {@code true}, "Skipping X / banned from the build" reactor blocks are dropped from the console. */ + private final boolean hideBannedProjectSkips; + + private final BannedSkipFilter bannedSkipFilter = new BannedSkipFilter(); /** * {@link Project} is owned by the display loop thread and is accessed only from there. Therefore it does not need @@ -143,7 +156,7 @@ public class TerminalOutput implements ClientOutput { static class Project { final String id; MojoStartedEvent runningExecution; - Message.ProjectTestProgressEvent testProgress; + final Map testProgress = new LinkedHashMap<>(); final List log = new ArrayList<>(); public Project(String id) { @@ -152,12 +165,18 @@ public Project(String id) { } public TerminalOutput(boolean noBuffering, int rollingWindowSize, Path logFile) throws IOException { + this(noBuffering, true, rollingWindowSize, logFile); + } + + public TerminalOutput(boolean noBuffering, boolean hideBannedProjectSkips, int rollingWindowSize, Path logFile) + throws IOException { this.start = System.currentTimeMillis(); TerminalBuilder builder = TerminalBuilder.builder(); builder.systemOutput(TerminalBuilder.SystemOutput.SysErr); this.terminal = builder.build(); this.dumb = terminal.getType().startsWith("dumb"); this.noBuffering = noBuffering; + this.hideBannedProjectSkips = hideBannedProjectSkips; this.linesPerProject = rollingWindowSize; terminal.enterRawMode(); Thread mainThread = Thread.currentThread(); @@ -272,7 +291,7 @@ private boolean doAccept(Message entry) { final MojoStartedEvent execution = (MojoStartedEvent) entry; final Project prj = projects.computeIfAbsent(execution.getArtifactId(), Project::new); prj.runningExecution = execution; - prj.testProgress = null; + prj.testProgress.clear(); break; } case Message.PROJECT_STOPPED: { @@ -291,8 +310,21 @@ private boolean doAccept(Message entry) { break; } case Message.BUILD_FINISHED: { + if (hideBannedProjectSkips) { + bannedSkipFilter.flush(log::accept); + } projects.values().stream().flatMap(p -> p.log.stream()).forEach(log); - clearDisplay(); + if (!failureSummaryEmitted) { + emitFailedTestsSummary(log::accept, failedTests, erroredTests); + failureSummaryEmitted = true; + } + String flakySummary = formatFlakySummary(flakyTests); + if (flakySummary != null) { + log.accept(flakySummary); + } + if (failures.isEmpty()) { + clearDisplay(); + } try { log.close(); } catch (IOException e) { @@ -339,14 +371,14 @@ private boolean doAccept(Message entry) { } case Message.BUILD_LOG_MESSAGE: { StringMessage sm = (StringMessage) entry; - log.accept(sm.getMessage()); + acceptReactorLine(sm.getMessage()); break; } case Message.PROJECT_LOG_MESSAGE: { final ProjectEvent bm = (ProjectEvent) entry; final Project prj = projects.get(bm.getProjectId()); if (prj == null) { - log.accept(bm.getMessage()); + acceptReactorLine(bm.getMessage()); } else if (noBuffering || dumb) { String msg; if (maxThreads > 1) { @@ -408,6 +440,13 @@ private boolean doAccept(Message entry) { case Message.EXECUTION_FAILURE: { final ExecutionFailureEvent efe = (ExecutionFailureEvent) entry; failures.add(efe); + final Project prj = projects.get(efe.getProjectId()); + if (prj != null) { + Message.ProjectTestProgressEvent tp = aggregateTestProgress(prj.testProgress.values()); + if (tp != null) { + failureProgress.put(efe.getProjectId(), tp); + } + } break; } case Message.REQUEST_INPUT: { @@ -432,7 +471,22 @@ private boolean doAccept(Message entry) { final Message.ProjectTestProgressEvent e = (Message.ProjectTestProgressEvent) entry; final Project prj = projects.get(e.getProjectId()); if (prj != null) { - prj.testProgress = e; + prj.testProgress.put(e.getForkChannelId(), e); + } + if (!e.getFlakyTests().isEmpty()) { + flakyTests + .computeIfAbsent(e.getProjectId(), k -> new LinkedHashSet<>()) + .addAll(e.getFlakyTests()); + } + if (!e.getFailedTests().isEmpty()) { + failedTests + .computeIfAbsent(e.getProjectId(), k -> new LinkedHashSet<>()) + .addAll(e.getFailedTests()); + } + if (!e.getErroredTests().isEmpty()) { + erroredTests + .computeIfAbsent(e.getProjectId(), k -> new LinkedHashSet<>()) + .addAll(e.getErroredTests()); } break; } @@ -545,39 +599,43 @@ private void update() { dispLines--; } - if (projectsCount <= dispLines) { - int remLogLines = dispLines - projectsCount; - for (Project prj : projects.values()) { - addProjectLine(lines, prj); - // get the last lines of the project log, taking multi-line logs into account - int nb = Math.min(remLogLines, linesPerProject); - List logs = lastN(prj.log, nb).stream() - .flatMap(s -> AttributedString.fromAnsi(s).columnSplitLength(Integer.MAX_VALUE).stream()) - .map(s -> concat(" ", s)) - .collect(lastN(nb)); - lines.addAll(logs); - remLogLines -= logs.size(); - } - final AttributedString idleLine = new AttributedStringBuilder() - .style(BOLD_GREEN_FOREGROUND) - .append("> ") - .style(AttributedStyle.DEFAULT.faint()) - .append("IDLE") - .style(AttributedStyle.DEFAULT) - .toAttributedString(); - int idleSlots = maxThreads - projectsCount; - while (idleSlots-- > 0 && remLogLines-- > 0 && lines.size() <= maxThreads + 1) { - lines.add(idleLine); - } - } else { - int skipProjects = projectsCount - dispLines; - for (Project prj : projects.values()) { - if (skipProjects == 0) { + if (shouldShowProjectDetails(projectsCount, dispLines, failures.size())) { + if (projectsCount <= dispLines) { + int remLogLines = dispLines - projectsCount; + for (Project prj : projects.values()) { addProjectLine(lines, prj); - } else { - skipProjects--; + // get the last lines of the project log, taking multi-line logs into account + int nb = Math.min(remLogLines, linesPerProject); + List logs = lastN(prj.log, nb).stream() + .flatMap(s -> AttributedString.fromAnsi(s).columnSplitLength(Integer.MAX_VALUE).stream()) + .map(s -> concat(" ", s)) + .collect(lastN(nb)); + lines.addAll(logs); + remLogLines -= logs.size(); + } + final AttributedString idleLine = new AttributedStringBuilder() + .style(BOLD_GREEN_FOREGROUND) + .append("> ") + .style(AttributedStyle.DEFAULT.faint()) + .append("IDLE") + .style(AttributedStyle.DEFAULT) + .toAttributedString(); + int idleSlots = maxThreads - projectsCount; + while (idleSlots-- > 0 && remLogLines-- > 0 && lines.size() <= maxThreads + 1) { + lines.add(idleLine); + } + } else { + int skipProjects = projectsCount - dispLines; + for (Project prj : projects.values()) { + if (skipProjects == 0) { + addProjectLine(lines, prj); + } else { + skipProjects--; + } } } + } else { + // On large failing reactors, keep the summary visible and stop churning project lines. } List trimmed = lines.stream().map(s -> s.columnSubSequence(0, cols)).collect(Collectors.toList()); @@ -606,6 +664,10 @@ private AttributedString formatFailures() { } asb.append(": ").append(exception); } + Message.ProjectTestProgressEvent tp = failureProgress.get(efe.getProjectId()); + if (tp != null) { + appendTestProgress(asb, tp); + } } else { asb.append(String.valueOf(failures.size())).append(" projects failed: "); asb.append( @@ -814,7 +876,7 @@ private void addProjectLine(final List lines, Project prj) { .append('(') .append(execution.getExecutionId()) .append(')'); - final Message.ProjectTestProgressEvent tp = prj.testProgress; + final Message.ProjectTestProgressEvent tp = aggregateTestProgress(prj.testProgress.values()); if (tp != null) { appendTestProgress(asb, tp); } @@ -822,9 +884,221 @@ private void addProjectLine(final List lines, Project prj) { lines.add(asb.toAttributedString()); } + static String formatFlakySummary(Map> flakyTests) { + if (flakyTests.isEmpty()) { + return null; + } + StringBuilder sb = new StringBuilder(); + sb.append("Flaky tests: "); + boolean firstProject = true; + for (Map.Entry> entry : flakyTests.entrySet()) { + if (!firstProject) { + sb.append("; "); + } + firstProject = false; + sb.append(entry.getKey()).append(" ["); + boolean firstTest = true; + for (String test : entry.getValue()) { + if (!firstTest) { + sb.append(", "); + } + firstTest = false; + sb.append(test); + } + sb.append(']'); + } + return sb.toString(); + } + + /** Matches SGR (color) escape sequences emitted by the daemon-side log renderer. */ + private static final Pattern ANSI = Pattern.compile("\\[[0-9;]*m"); + /** Matches a leading {@code [LEVEL] } prefix such as {@code [INFO] } or {@code [ERROR] }. */ + private static final Pattern LEVEL_PREFIX = Pattern.compile("^\\[[A-Z]+\\]\\s?"); + + private static final String BANNED_MARKER = "This project has been banned from the build due to previous failures."; + + /** Strips ANSI color and the {@code [LEVEL] } prefix so reactor lines can be matched by their bare text. */ + static String stripDecoration(String line) { + if (line == null) { + return ""; + } + String s = ANSI.matcher(line).replaceAll(""); + s = LEVEL_PREFIX.matcher(s).replaceFirst(""); + return s.trim(); + } + + private static boolean isSeparator(String stripped) { + if (stripped.isEmpty()) { + return false; + } + for (int i = 0; i < stripped.length(); i++) { + if (stripped.charAt(i) != '-') { + return false; + } + } + return true; + } + + /** + * Handles a reactor-level (project-less) Maven log line: injects the aggregated failed/errored summary directly + * above the {@code BUILD FAILURE} banner, and drops "banned from the build" skip blocks when enabled. + */ + private void acceptReactorLine(String line) { + failureSummaryEmitted = acceptReactorLine( + line, + hideBannedProjectSkips, + failedTests, + erroredTests, + failureSummaryEmitted, + bannedSkipFilter, + log::accept); + } + + /** + * Processes one reactor line: injects the aggregated failed/errored summary immediately above the + * {@code BUILD FAILURE} banner (once), and drops "banned from the build" blocks when {@code hideBannedProjectSkips} + * is set. Returns the updated {@code failureSummaryEmitted} flag. Static and side-effect free apart from + * {@code out}/{@code filter} so the ordering can be unit-tested without a terminal. + */ + static boolean acceptReactorLine( + String line, + boolean hideBannedProjectSkips, + Map> failedTests, + Map> erroredTests, + boolean failureSummaryEmitted, + BannedSkipFilter filter, + Consumer out) { + final String stripped = stripDecoration(line); + if (!failureSummaryEmitted + && stripped.equals("BUILD FAILURE") + && (!failedTests.isEmpty() || !erroredTests.isEmpty())) { + if (hideBannedProjectSkips) { + filter.flush(out); + } + emitFailedTestsSummary(out, failedTests, erroredTests); + failureSummaryEmitted = true; + } + if (hideBannedProjectSkips) { + filter.accept(line, stripped, out); + } else { + out.accept(line); + } + return failureSummaryEmitted; + } + + /** Emits the aggregated failed/errored test blocks (nothing when both are empty). */ + static void emitFailedTestsSummary( + Consumer out, Map> failedTests, Map> erroredTests) { + emitCategory(out, "Failed tests:", failedTests); + emitCategory(out, "Errored tests:", erroredTests); + } + + static void emitCategory(Consumer out, String header, Map> byProject) { + if (byProject.isEmpty()) { + return; + } + out.accept(header); + for (Map.Entry> entry : byProject.entrySet()) { + for (String test : entry.getValue()) { + out.accept(" " + entry.getKey() + " " + test); + } + } + } + + /** + * Drops the five-line reactor block Maven logs for a banned project (blank, separator, {@code Skipping X}, + * {@code This project has been banned...}, separator) while leaving every other line, including the final + * reactor-summary {@code ... SKIPPED} rows, untouched. Blank/separator/{@code Skipping} lines are buffered so the + * preamble can be discarded retroactively once the banned marker confirms the block; buffered lines are flushed + * ahead of any real content line (order preserved) and by {@link #flush(Consumer)} at build end. + */ + static final class BannedSkipFilter { + private final List pending = new ArrayList<>(); + private boolean swallowNextSeparator; + + void accept(String line, String stripped, Consumer out) { + if (stripped.equals(BANNED_MARKER)) { + pending.clear(); // drop the buffered blank + separator + "Skipping X" preamble and this marker + swallowNextSeparator = true; + return; + } + if (swallowNextSeparator) { + swallowNextSeparator = false; + if (isSeparator(stripped)) { + return; // drop the block's closing separator + } + } + if (stripped.isEmpty() || isSeparator(stripped) || stripped.startsWith("Skipping ")) { + pending.add(line); // structural or candidate line: hold until the next real line resolves it + return; + } + flush(out); + out.accept(line); + } + + void flush(Consumer out) { + for (String held : pending) { + out.accept(held); + } + pending.clear(); + } + } + + static Message.ProjectTestProgressEvent aggregateTestProgress( + Collection snapshots) { + if (snapshots.isEmpty()) { + return null; + } + int completed = 0; + int failures = 0; + int errors = 0; + int skipped = 0; + int retrying = 0; + int flaky = 0; + Set flakyTests = new LinkedHashSet<>(); + Set failedTests = new LinkedHashSet<>(); + Set erroredTests = new LinkedHashSet<>(); + Message.ProjectTestProgressEvent latest = null; + long latestSeq = Long.MIN_VALUE; + for (Message.ProjectTestProgressEvent tp : snapshots) { + completed += tp.getCompleted(); + failures += tp.getFailures(); + errors += tp.getErrors(); + skipped += tp.getSkipped(); + retrying += tp.getRetrying(); + flaky += tp.getFlaky(); + flakyTests.addAll(tp.getFlakyTests()); + failedTests.addAll(tp.getFailedTests()); + erroredTests.addAll(tp.getErroredTests()); + if (tp.seq() > latestSeq) { + latestSeq = tp.seq(); + latest = tp; + } + } + return Message.projectTestProgress( + latest.getProjectId(), + latest.getForkChannelId(), + latest.getTestClass(), + latest.getTestMethod(), + completed, + failures, + errors, + skipped, + retrying, + flaky, + new ArrayList<>(flakyTests), + new ArrayList<>(failedTests), + new ArrayList<>(erroredTests)); + } + + static boolean shouldShowProjectDetails(int projectsCount, int dispLines, int failuresCount) { + return failuresCount == 0 || projectsCount <= dispLines; + } + static void appendTestProgress(AttributedStringBuilder asb, Message.ProjectTestProgressEvent tp) { final AttributedStyle faint = AttributedStyle.DEFAULT.faint(); final AttributedStyle red = AttributedStyle.DEFAULT.foreground(AttributedStyle.RED); + final AttributedStyle yellow = AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW); asb.append(' ').style(faint).append("[Tests: ").append(String.valueOf(tp.getCompleted())); if (tp.getFailures() > 0) { asb.style(faint).append(", Failures: ").style(red).append(String.valueOf(tp.getFailures())); @@ -835,6 +1109,12 @@ static void appendTestProgress(AttributedStringBuilder asb, Message.ProjectTestP if (tp.getSkipped() > 0) { asb.style(faint).append(", Skipped: ").append(String.valueOf(tp.getSkipped())); } + if (tp.getRetrying() > 0) { + asb.style(faint).append(", Retrying: ").append(String.valueOf(tp.getRetrying())); + } + if (tp.getFlaky() > 0) { + asb.style(faint).append(", Flaky: ").style(yellow).append(String.valueOf(tp.getFlaky())); + } asb.style(faint).append("]"); final String testClass = tp.getTestClass(); if (testClass != null) { diff --git a/common/src/main/java/org/mvndaemon/mvnd/testprogress/MvndTestProgress.java b/common/src/main/java/org/mvndaemon/mvnd/testprogress/MvndTestProgress.java index 6d5552239..fbbf161f0 100644 --- a/common/src/main/java/org/mvndaemon/mvnd/testprogress/MvndTestProgress.java +++ b/common/src/main/java/org/mvndaemon/mvnd/testprogress/MvndTestProgress.java @@ -18,6 +18,7 @@ */ package org.mvndaemon.mvnd.testprogress; +import java.util.List; import java.util.concurrent.atomic.AtomicReference; /** @@ -33,12 +34,18 @@ public interface MvndTestProgress { */ void update( String projectId, + int forkChannelId, String testClass, String testMethod, int completed, int failures, int errors, - int skipped); + int skipped, + int retrying, + int flaky, + List flakyTests, + List failedTests, + List erroredTests); AtomicReference LISTENER = new AtomicReference<>(); diff --git a/common/src/test/java/org/mvndaemon/mvnd/common/MessageTest.java b/common/src/test/java/org/mvndaemon/mvnd/common/MessageTest.java index e876875c6..5197692da 100644 --- a/common/src/test/java/org/mvndaemon/mvnd/common/MessageTest.java +++ b/common/src/test/java/org/mvndaemon/mvnd/common/MessageTest.java @@ -76,7 +76,20 @@ void buildExceptionSerialization() throws Exception { @Test void projectTestProgressSerialization() throws IOException { - Message msg = Message.projectTestProgress("my-app", "com.acme.FooTest", "shouldWork", 3, 1, 0, 1); + Message msg = Message.projectTestProgress( + "my-app", + 7, + "com.acme.FooTest", + "shouldWork", + 3, + 1, + 0, + 1, + 2, + 1, + java.util.List.of("FooTest#shouldWork"), + java.util.List.of("FooTest#broken: expected <5> but was <4>"), + java.util.List.of("FooTest#blows: / by zero")); ByteArrayOutputStream baos = new ByteArrayOutputStream(); try (DataOutputStream daos = new DataOutputStream(baos)) { @@ -90,17 +103,23 @@ void projectTestProgressSerialization() throws IOException { assertTrue(msg2 instanceof Message.ProjectTestProgressEvent); Message.ProjectTestProgressEvent e = (Message.ProjectTestProgressEvent) msg2; assertEquals("my-app", e.getProjectId()); + assertEquals(7, e.getForkChannelId()); assertEquals("com.acme.FooTest", e.getTestClass()); assertEquals("shouldWork", e.getTestMethod()); assertEquals(3, e.getCompleted()); assertEquals(1, e.getFailures()); assertEquals(0, e.getErrors()); assertEquals(1, e.getSkipped()); + assertEquals(2, e.getRetrying()); + assertEquals(1, e.getFlaky()); + assertEquals(java.util.List.of("FooTest#shouldWork"), e.getFlakyTests()); + assertEquals(java.util.List.of("FooTest#broken: expected <5> but was <4>"), e.getFailedTests()); + assertEquals(java.util.List.of("FooTest#blows: / by zero"), e.getErroredTests()); } @Test void projectTestProgressNullClassAndMethod() throws IOException { - Message msg = Message.projectTestProgress("my-app", null, null, 0, 0, 0, 0); + Message msg = Message.projectTestProgress("my-app", 11, null, null, 0, 0, 0, 0); ByteArrayOutputStream baos = new ByteArrayOutputStream(); try (DataOutputStream daos = new DataOutputStream(baos)) { msg.write(daos); @@ -110,7 +129,11 @@ void projectTestProgressNullClassAndMethod() throws IOException { msg2 = Message.read(dis); } Message.ProjectTestProgressEvent e = (Message.ProjectTestProgressEvent) msg2; + assertEquals(11, e.getForkChannelId()); assertNull(e.getTestClass()); assertNull(e.getTestMethod()); + assertEquals(0, e.getRetrying()); + assertEquals(0, e.getFlaky()); + assertTrue(e.getFlakyTests().isEmpty()); } } diff --git a/common/src/test/java/org/mvndaemon/mvnd/common/logging/TerminalOutputTest.java b/common/src/test/java/org/mvndaemon/mvnd/common/logging/TerminalOutputTest.java index e517b16e9..faab26e30 100644 --- a/common/src/test/java/org/mvndaemon/mvnd/common/logging/TerminalOutputTest.java +++ b/common/src/test/java/org/mvndaemon/mvnd/common/logging/TerminalOutputTest.java @@ -18,6 +18,10 @@ */ package org.mvndaemon.mvnd.common.logging; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; + import org.jline.utils.AttributedString; import org.jline.utils.AttributedStringBuilder; import org.jline.utils.AttributedStyle; @@ -25,6 +29,7 @@ import org.mvndaemon.mvnd.common.Message; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; class TerminalOutputTest { @@ -48,7 +53,7 @@ void renderBarFull() { void suffixAllPassing() { AttributedStringBuilder asb = new AttributedStringBuilder(); TerminalOutput.appendTestProgress( - asb, Message.projectTestProgress("app", "com.acme.FooTest", "shouldWork", 12, 0, 0, 0)); + asb, Message.projectTestProgress("app", 1, "com.acme.FooTest", "shouldWork", 12, 0, 0, 0)); assertEquals(" [Tests: 12] FooTest#shouldWork", asb.toAttributedString().toString()); } @@ -56,7 +61,7 @@ void suffixAllPassing() { void suffixFailuresRenderRed() { AttributedStringBuilder asb = new AttributedStringBuilder(); TerminalOutput.appendTestProgress( - asb, Message.projectTestProgress("app", "com.acme.FooTest", "shouldWork", 12, 1, 0, 0)); + asb, Message.projectTestProgress("app", 1, "com.acme.FooTest", "shouldWork", 12, 1, 0, 0)); AttributedString s = asb.toAttributedString(); assertEquals(" [Tests: 12, Failures: 1] FooTest#shouldWork", s.toString()); int failureDigit = s.toString().indexOf("Failures: ") + "Failures: ".length(); @@ -69,17 +74,210 @@ void suffixFailuresRenderRed() { void suffixErrorsAndSkips() { AttributedStringBuilder asb = new AttributedStringBuilder(); TerminalOutput.appendTestProgress( - asb, Message.projectTestProgress("app", "com.acme.FooTest", "shouldWork", 5, 0, 2, 1)); + asb, Message.projectTestProgress("app", 1, "com.acme.FooTest", "shouldWork", 5, 0, 2, 1)); assertEquals( " [Tests: 5, Errors: 2, Skipped: 1] FooTest#shouldWork", asb.toAttributedString().toString()); } + @Test + void suffixRetryingAndFlakyTests() { + AttributedStringBuilder asb = new AttributedStringBuilder(); + TerminalOutput.appendTestProgress( + asb, + Message.projectTestProgress( + "app", + 1, + "com.acme.FooTest", + "shouldWork", + 4, + 0, + 0, + 0, + 1, + 2, + java.util.List.of("FooTest#shouldWork", "FooTest#other"), + java.util.List.of(), + java.util.List.of())); + AttributedString s = asb.toAttributedString(); + assertEquals(" [Tests: 4, Retrying: 1, Flaky: 2] FooTest#shouldWork", s.toString()); + int flakyDigit = s.toString().indexOf("Flaky: ") + "Flaky: ".length(); + assertEquals(AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW), s.styleAt(flakyDigit)); + } + @Test void suffixClassOnly() { AttributedStringBuilder asb = new AttributedStringBuilder(); TerminalOutput.appendTestProgress( - asb, Message.projectTestProgress("app", "com.acme.FooTest", null, 3, 0, 0, 0)); + asb, Message.projectTestProgress("app", 1, "com.acme.FooTest", null, 3, 0, 0, 0)); assertEquals(" [Tests: 3] FooTest", asb.toAttributedString().toString()); } + + @Test + void aggregateTestProgressSumsForkSnapshots() { + Message.ProjectTestProgressEvent failed = + Message.projectTestProgress("app", 1, "com.acme.FooTest", "failedTest", 3, 1, 0, 0); + Message.ProjectTestProgressEvent skipped = + Message.projectTestProgress("app", 2, "com.acme.FooTest", "skippedTest", 2, 0, 0, 1); + + Message.ProjectTestProgressEvent aggregated = + TerminalOutput.aggregateTestProgress(java.util.List.of(failed, skipped)); + + assertEquals(5, aggregated.getCompleted()); + assertEquals(1, aggregated.getFailures()); + assertEquals(0, aggregated.getErrors()); + assertEquals(1, aggregated.getSkipped()); + assertEquals("com.acme.FooTest", aggregated.getTestClass()); + assertEquals("skippedTest", aggregated.getTestMethod()); + } + + @Test + void formatFlakySummaryListsRecoveredTests() { + Map> flakyTests = new LinkedHashMap<>(); + flakyTests.put("app", new LinkedHashSet<>(java.util.List.of("FooTest#shouldWork", "FooTest#other"))); + flakyTests.put("lib", new LinkedHashSet<>(java.util.List.of("BarTest#retries"))); + + assertEquals( + "Flaky tests: app [FooTest#shouldWork, FooTest#other]; lib [BarTest#retries]", + TerminalOutput.formatFlakySummary(flakyTests)); + } + + @Test + void hidesProjectDetailsForLargeFailingReactors() { + assertEquals(false, TerminalOutput.shouldShowProjectDetails(20, 10, 1)); + assertEquals(true, TerminalOutput.shouldShowProjectDetails(20, 10, 0)); + assertEquals(true, TerminalOutput.shouldShowProjectDetails(5, 10, 1)); + } + + @Test + void stripDecorationRemovesLevelPrefixAndAnsi() { + assertEquals("BUILD FAILURE", TerminalOutput.stripDecoration("[INFO] BUILD FAILURE")); + assertEquals("BUILD FAILURE", TerminalOutput.stripDecoration("[INFO] BUILD FAILURE")); + assertEquals( + "This project has been banned from the build due to previous failures.", + TerminalOutput.stripDecoration( + "[INFO] This project has been banned from the build due to previous failures.")); + } + + @Test + void emitCategoryPrefixesEachTestWithProjectId() { + java.util.List out = new java.util.ArrayList<>(); + Map> failed = new LinkedHashMap<>(); + failed.put("camel-jms", new LinkedHashSet<>(java.util.List.of("FooTest#bar: expected <5> but was <4>"))); + failed.put("camel-nats", new LinkedHashSet<>(java.util.List.of("NatsIT#connects: refused"))); + + TerminalOutput.emitCategory(out::add, "Failed tests:", failed); + + assertEquals( + java.util.List.of( + "Failed tests:", + " camel-jms FooTest#bar: expected <5> but was <4>", + " camel-nats NatsIT#connects: refused"), + out); + } + + @Test + void emitCategoryEmitsNothingWhenEmpty() { + java.util.List out = new java.util.ArrayList<>(); + TerminalOutput.emitCategory(out::add, "Failed tests:", new LinkedHashMap<>()); + assertEquals(java.util.List.of(), out); + } + + @Test + void bannedSkipFilterDropsBannedBlockButKeepsOtherLines() { + TerminalOutput.BannedSkipFilter filter = new TerminalOutput.BannedSkipFilter(); + java.util.List out = new java.util.ArrayList<>(); + String sep = "[INFO] ------------------------------------------------------------------------"; + String[] lines = { + "[INFO] Reactor Summary:", + "[INFO] ", + sep, + "[INFO] Skipping Camel :: YAML DSL", + "[INFO] This project has been banned from the build due to previous failures.", + sep, + "[INFO] camel-core ......... SKIPPED", + }; + for (String l : lines) { + filter.accept(l, TerminalOutput.stripDecoration(l), out::add); + } + filter.flush(out::add); + + assertEquals(java.util.List.of("[INFO] Reactor Summary:", "[INFO] camel-core ......... SKIPPED"), out); + } + + @Test + void reactorSummaryIsInjectedRightBeforeBuildFailureBannerAndBannedBlockIsDropped() { + Map> failed = new LinkedHashMap<>(); + failed.put("camel-jms", new LinkedHashSet<>(java.util.List.of("FooTest#bar: expected <5> but was <4>"))); + Map> errored = new LinkedHashMap<>(); + errored.put("camel-nats", new LinkedHashSet<>(java.util.List.of("NatsIT#connects: refused"))); + + String sep = "[INFO] ------------------------------------------------------------------------"; + String[] lines = { + sep, + "[INFO] Skipping Camel :: YAML DSL", + "[INFO] This project has been banned from the build due to previous failures.", + sep, + "[INFO] Reactor Summary:", + "[INFO] camel-core ......... SKIPPED", + sep, + "[INFO] BUILD FAILURE", + sep, + "[INFO] Total time: 1.2 s", + }; + + java.util.List out = new java.util.ArrayList<>(); + TerminalOutput.BannedSkipFilter filter = new TerminalOutput.BannedSkipFilter(); + boolean emitted = false; + for (String l : lines) { + emitted = TerminalOutput.acceptReactorLine(l, true, failed, errored, emitted, filter, out::add); + } + filter.flush(out::add); + + // Summary appears immediately above the BUILD FAILURE banner text. + int failedHeader = out.indexOf("Failed tests:"); + int banner = out.indexOf("[INFO] BUILD FAILURE"); + assertEquals(banner - 4, failedHeader, "summary block must sit directly above the BUILD FAILURE banner"); + assertEquals( + java.util.List.of( + "Failed tests:", + " camel-jms FooTest#bar: expected <5> but was <4>", + "Errored tests:", + " camel-nats NatsIT#connects: refused", + "[INFO] BUILD FAILURE"), + out.subList(failedHeader, banner + 1)); + // The banned "Skipping / banned from the build" block is gone, but the reactor SKIPPED row stays. + assertTrue(out.stream().noneMatch(s -> s.contains("banned from the build")), "banned block must be dropped"); + assertTrue(out.stream().noneMatch(s -> s.contains("Skipping Camel")), "skipping line must be dropped"); + assertTrue( + out.contains("[INFO] camel-core ......... SKIPPED"), "reactor summary SKIPPED row must be preserved"); + } + + @Test + void reactorLineIsPassedThroughUnchangedWhenSuppressionDisabled() { + java.util.List out = new java.util.ArrayList<>(); + TerminalOutput.BannedSkipFilter filter = new TerminalOutput.BannedSkipFilter(); + TerminalOutput.acceptReactorLine( + "[INFO] This project has been banned from the build due to previous failures.", + false, + new LinkedHashMap<>(), + new LinkedHashMap<>(), + false, + filter, + out::add); + assertEquals( + java.util.List.of("[INFO] This project has been banned from the build due to previous failures."), out); + } + + @Test + void bannedSkipFilterKeepsUnbannedSkippingLine() { + TerminalOutput.BannedSkipFilter filter = new TerminalOutput.BannedSkipFilter(); + java.util.List out = new java.util.ArrayList<>(); + filter.accept( + "[INFO] Skipping bad plugin", TerminalOutput.stripDecoration("[INFO] Skipping bad plugin"), out::add); + filter.accept("[INFO] Building foo", TerminalOutput.stripDecoration("[INFO] Building foo"), out::add); + filter.flush(out::add); + + assertEquals(java.util.List.of("[INFO] Skipping bad plugin", "[INFO] Building foo"), out); + } } diff --git a/daemon/src/main/java/org/mvndaemon/mvnd/daemon/ClientDispatcher.java b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/ClientDispatcher.java index 111054526..8eb86b081 100644 --- a/daemon/src/main/java/org/mvndaemon/mvnd/daemon/ClientDispatcher.java +++ b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/ClientDispatcher.java @@ -132,13 +132,32 @@ public void mojoStarted(ExecutionEvent event) { public void testProgress( String projectId, + int forkChannelId, String testClass, String testMethod, int completed, int failures, int errors, - int skipped) { - queue.add(Message.projectTestProgress(projectId, testClass, testMethod, completed, failures, errors, skipped)); + int skipped, + int retrying, + int flaky, + List flakyTests, + List failedTests, + List erroredTests) { + queue.add(Message.projectTestProgress( + projectId, + forkChannelId, + testClass, + testMethod, + completed, + failures, + errors, + skipped, + retrying, + flaky, + flakyTests, + failedTests, + erroredTests)); } public void finish(int exitCode) throws Exception { diff --git a/daemon/src/main/java/org/mvndaemon/mvnd/daemon/Server.java b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/Server.java index 7cafa89aa..400776502 100644 --- a/daemon/src/main/java/org/mvndaemon/mvnd/daemon/Server.java +++ b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/Server.java @@ -515,9 +515,33 @@ private void handle(DaemonConnection connection, BuildRequest buildRequest) { .orElse(Boolean.TRUE); if (testProgressEnabled) { final ClientDispatcher clientDispatcher = (ClientDispatcher) buildEventListener; - MvndTestProgress.setListener((projectId, testClass, testMethod, completed, failures, errors, skipped) -> - clientDispatcher.testProgress( - projectId, testClass, testMethod, completed, failures, errors, skipped)); + MvndTestProgress.setListener( + (projectId, + forkChannelId, + testClass, + testMethod, + completed, + failures, + errors, + skipped, + retrying, + flaky, + flakyTests, + failedTests, + erroredTests) -> clientDispatcher.testProgress( + projectId, + forkChannelId, + testClass, + testMethod, + completed, + failures, + errors, + skipped, + retrying, + flaky, + flakyTests, + failedTests, + erroredTests)); } final DaemonInputStream daemonInputStream = new DaemonInputStream( (projectId, bytesToRead) -> sendQueue.add(Message.requestInput(projectId, bytesToRead)), diff --git a/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressFailureTest.java b/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressFailureTest.java new file mode 100644 index 000000000..bfc239840 --- /dev/null +++ b/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressFailureTest.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.mvndaemon.mvnd.it; + +import javax.inject.Inject; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.mvndaemon.mvnd.assertj.TestClientOutput; +import org.mvndaemon.mvnd.client.Client; +import org.mvndaemon.mvnd.common.Message; +import org.mvndaemon.mvnd.junit.MvndTest; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +@MvndTest(projectDir = "src/test/projects/test-progress-failure") +class TestProgressFailureTest { + + @Inject + Client client; + + @Test + void reportsFailedAndErroredTestsWithMessages() throws InterruptedException { + final TestClientOutput output = new TestClientOutput(); + client.execute(output, "clean", "test", "-B").assertFailure(); + + List events = output.getMessages().stream() + .filter(Message.ProjectTestProgressEvent.class::isInstance) + .map(Message.ProjectTestProgressEvent.class::cast) + .toList(); + + assertTrue(!events.isEmpty(), "expected PROJECT_TEST_PROGRESS messages, got none"); + assertTrue( + events.stream() + .flatMap(e -> e.getFailedTests().stream()) + .anyMatch(t -> t.startsWith("FailingServiceTest#failsAssertion") && t.contains(": ")), + "expected the failed test to be reported with its message"); + assertTrue( + events.stream() + .flatMap(e -> e.getErroredTests().stream()) + .anyMatch(t -> t.startsWith("FailingServiceTest#throwsError") && t.contains(": ")), + "expected the errored test to be reported with its message"); + } +} diff --git a/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressTest.java b/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressTest.java index 0804e46ce..4c013f985 100644 --- a/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressTest.java +++ b/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressTest.java @@ -57,6 +57,23 @@ void emitsIncreasingTestProgress() throws InterruptedException { "expected the current test class name to be reported"); } + @Test + void emitsFlakyTestProgress() throws InterruptedException { + final TestClientOutput output = new TestClientOutput(); + client.execute(output, "clean", "test", "-B").assertSuccess(); + + List events = testProgressEvents(output); + + assertTrue( + events.stream().anyMatch(e -> e.getRetrying() > 0), + "expected a retrying snapshot while the flaky test was being rerun"); + assertTrue( + events.stream().anyMatch(e -> e.getFlaky() > 0), "expected a flaky snapshot after the rerun succeeded"); + assertTrue( + events.stream().anyMatch(e -> e.getFlakyTests().contains("FlakyServiceTest#succeedsOnRetry")), + "expected the recovered test to be reported in the flaky test list"); + } + @Test void disabledEmitsNoTestProgress() throws InterruptedException { final TestClientOutput output = new TestClientOutput(); diff --git a/integration-tests/src/test/projects/test-progress-failure/pom.xml b/integration-tests/src/test/projects/test-progress-failure/pom.xml new file mode 100644 index 000000000..d075a6996 --- /dev/null +++ b/integration-tests/src/test/projects/test-progress-failure/pom.xml @@ -0,0 +1,59 @@ + + + + 4.0.0 + org.mvndaemon.mvnd.test.test-progress-failure + test-progress-failure + 0.0.1-SNAPSHOT + jar + + + UTF-8 + 17 + 17 + + 3.5.6 + 5.14.4 + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + + false + + + + + + diff --git a/integration-tests/src/test/projects/test-progress-failure/src/test/java/org/mvndaemon/mvnd/test/FailingServiceTest.java b/integration-tests/src/test/projects/test-progress-failure/src/test/java/org/mvndaemon/mvnd/test/FailingServiceTest.java new file mode 100644 index 000000000..9fde3e152 --- /dev/null +++ b/integration-tests/src/test/projects/test-progress-failure/src/test/java/org/mvndaemon/mvnd/test/FailingServiceTest.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.mvndaemon.mvnd.test; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class FailingServiceTest { + + @Test + void failsAssertion() { + assertEquals(5, 2 + 2, "arithmetic is broken"); + } + + @Test + void throwsError() { + throw new IllegalStateException("service unavailable"); + } +} diff --git a/integration-tests/src/test/projects/test-progress/pom.xml b/integration-tests/src/test/projects/test-progress/pom.xml index 46e3bc2bf..9b2156093 100644 --- a/integration-tests/src/test/projects/test-progress/pom.xml +++ b/integration-tests/src/test/projects/test-progress/pom.xml @@ -48,6 +48,9 @@ org.apache.maven.plugins maven-surefire-plugin ${maven-surefire-plugin.version} + + 1 + diff --git a/integration-tests/src/test/projects/test-progress/src/test/java/org/mvndaemon/mvnd/test/FlakyServiceTest.java b/integration-tests/src/test/projects/test-progress/src/test/java/org/mvndaemon/mvnd/test/FlakyServiceTest.java new file mode 100644 index 000000000..183be43de --- /dev/null +++ b/integration-tests/src/test/projects/test-progress/src/test/java/org/mvndaemon/mvnd/test/FlakyServiceTest.java @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.mvndaemon.mvnd.test; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FlakyServiceTest { + + private static final AtomicInteger attempts = new AtomicInteger(); + + @Test + void succeedsOnRetry() { + assertTrue(attempts.incrementAndGet() >= 2, "first attempt fails, rerun should pass"); + } +} diff --git a/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/MvndForkNodeFactory.java b/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/MvndForkNodeFactory.java index 09dcb976e..bb12be7b8 100644 --- a/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/MvndForkNodeFactory.java +++ b/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/MvndForkNodeFactory.java @@ -33,6 +33,8 @@ import org.apache.maven.surefire.api.event.TestsetStartingEvent; import org.apache.maven.surefire.api.fork.ForkNodeArguments; import org.apache.maven.surefire.api.report.ReportEntry; +import org.apache.maven.surefire.api.report.SafeThrowable; +import org.apache.maven.surefire.api.report.StackTraceWriter; import org.apache.maven.surefire.extensions.CommandReader; import org.apache.maven.surefire.extensions.EventHandler; import org.apache.maven.surefire.extensions.ForkChannel; @@ -68,11 +70,13 @@ public ForkChannel createForkChannel(ForkNodeArguments arguments) throws IOExcep static final class WrappingForkChannel extends ForkChannel { private final ForkChannel delegate; private final String projectId; + private final int forkChannelId; WrappingForkChannel(ForkNodeArguments arguments, ForkChannel delegate, String projectId) { super(arguments); this.delegate = delegate; this.projectId = projectId; + this.forkChannelId = arguments.getForkChannelId(); } @Override @@ -101,7 +105,7 @@ public void bindEventHandler( EventHandler eventHandler, CountdownCloseable countdown, ReadableByteChannel stdOut) throws IOException, InterruptedException { delegate.bindEventHandler( - new ProgressEventHandler(projectId, eventHandler, new TestProgressAccumulator()), + new ProgressEventHandler(projectId, forkChannelId, eventHandler, new TestProgressAccumulator()), countdown, stdOut); } @@ -120,11 +124,14 @@ public void close() throws IOException { /** Observes each event, updates the accumulator, pushes through the bridge, then always delegates. */ static final class ProgressEventHandler implements EventHandler { private final String projectId; + private final int forkChannelId; private final EventHandler delegate; private final TestProgressAccumulator acc; - ProgressEventHandler(String projectId, EventHandler delegate, TestProgressAccumulator acc) { + ProgressEventHandler( + String projectId, int forkChannelId, EventHandler delegate, TestProgressAccumulator acc) { this.projectId = projectId; + this.forkChannelId = forkChannelId; this.delegate = delegate; this.acc = acc; } @@ -167,19 +174,43 @@ private void observe(Event event) { return; // not a test lifecycle event } - acc.record(type, re.getSourceName(), re.getName()); + final String failureMessage = (type == TestProgressAccumulator.Type.TEST_FAILED + || type == TestProgressAccumulator.Type.TEST_ERROR) + ? extractFailureMessage(re) + : null; + acc.record(type, re.getSourceName(), re.getName(), re.getRunMode(), re.getTestRunId(), failureMessage); MvndTestProgress listener = MvndTestProgress.getListener(); if (listener != null) { listener.update( projectId, + forkChannelId, acc.getTestClass(), acc.getTestMethod(), acc.getCompleted(), acc.getFailures(), acc.getErrors(), - acc.getSkipped()); + acc.getSkipped(), + acc.getRetrying(), + acc.getFlaky(), + acc.getFlakyTests(), + acc.getFailedTests(), + acc.getErroredTests()); } } + + /** Best-effort compact failure message; never throws (caller already guards, but keep it defensive). */ + private static String extractFailureMessage(ReportEntry re) { + StackTraceWriter stw = re.getStackTraceWriter(); + if (stw == null) { + return null; + } + String smart = stw.smartTrimmedStackTrace(); + if (smart != null && !smart.isEmpty()) { + return smart; + } + SafeThrowable throwable = stw.getThrowable(); + return throwable != null ? throwable.getMessage() : null; + } } } diff --git a/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulator.java b/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulator.java index 2079c732b..9f10f73d7 100644 --- a/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulator.java +++ b/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulator.java @@ -18,6 +18,13 @@ */ package org.mvndaemon.mvnd.forknode; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.maven.surefire.api.report.RunMode; + /** * Accumulates per-fork test counts and the currently executing class/method. Not thread-safe: Surefire delivers * fork-reader events on a single thread per fork channel. @@ -38,10 +45,22 @@ public enum Type { private int failures; private int errors; private int skipped; + private int retrying; + private int flaky; private String testClass; private String testMethod; + private final Map tests = new LinkedHashMap<>(); public void record(Type type, String testClass, String testMethod) { + record(type, testClass, testMethod, RunMode.NORMAL_RUN, null, null); + } + + public void record(Type type, String testClass, String testMethod, RunMode runMode, Long testRunId) { + record(type, testClass, testMethod, runMode, testRunId, null); + } + + public void record( + Type type, String testClass, String testMethod, RunMode runMode, Long testRunId, String failureMessage) { switch (type) { case TESTSET_STARTING: this.testClass = testClass; @@ -50,25 +69,25 @@ public void record(Type type, String testClass, String testMethod) { case TEST_STARTING: this.testClass = testClass; this.testMethod = testMethod; + state(testClass, testMethod, testRunId).starting(runMode); break; case TEST_SUCCEEDED: - completed++; + state(testClass, testMethod, testRunId).succeeded(); break; case TEST_FAILED: - completed++; - failures++; + state(testClass, testMethod, testRunId).failed(runMode, sanitize(failureMessage)); break; case TEST_ERROR: - completed++; - errors++; + state(testClass, testMethod, testRunId).errored(runMode, sanitize(failureMessage)); break; case TEST_SKIPPED: - completed++; - skipped++; + state(testClass, testMethod, testRunId).skipped(); break; case TESTSET_COMPLETED: + finalizeRetrying(); break; } + recompute(); } public int getCompleted() { @@ -87,6 +106,14 @@ public int getSkipped() { return skipped; } + public int getRetrying() { + return retrying; + } + + public int getFlaky() { + return flaky; + } + public String getTestClass() { return testClass; } @@ -94,4 +121,177 @@ public String getTestClass() { public String getTestMethod() { return testMethod; } + + public List getFlakyTests() { + List result = new ArrayList<>(); + for (TestState state : tests.values()) { + if (state.isFlaky()) { + result.add(state.displayName()); + } + } + return result; + } + + public List getFailedTests() { + List result = new ArrayList<>(); + for (TestState state : tests.values()) { + if (state.isFailed()) { + result.add(state.failureLine()); + } + } + return result; + } + + public List getErroredTests() { + List result = new ArrayList<>(); + for (TestState state : tests.values()) { + if (state.isErrored()) { + result.add(state.failureLine()); + } + } + return result; + } + + private static String sanitize(String message) { + if (message == null) { + return null; + } + String flattened = message.replaceAll("\\s+", " ").trim(); + return flattened.isEmpty() ? null : flattened; + } + + private TestState state(String testClass, String testMethod, Long testRunId) { + String key = testRunId != null ? String.valueOf(testRunId) : testClass + "#" + testMethod; + TestState state = tests.get(key); + if (state == null) { + state = new TestState(testClass, testMethod); + tests.put(key, state); + } else { + state.updateName(testClass, testMethod); + } + return state; + } + + private void finalizeRetrying() { + for (TestState state : tests.values()) { + state.finalizeRetrying(); + } + } + + private void recompute() { + completed = 0; + failures = 0; + errors = 0; + skipped = 0; + retrying = 0; + flaky = 0; + + for (TestState state : tests.values()) { + if (state.skipped) { + completed++; + skipped++; + } else if (state.success) { + completed++; + if (state.failure || state.error) { + flaky++; + } + } else if (state.retrying) { + retrying++; + } else if (state.error) { + completed++; + errors++; + } else if (state.failure) { + completed++; + failures++; + } + } + } + + private static final class TestState { + private String testClass; + private String testMethod; + private boolean failure; + private boolean error; + private boolean success; + private boolean skipped; + private boolean retrying; + private String message; + + private TestState(String testClass, String testMethod) { + this.testClass = testClass; + this.testMethod = testMethod; + } + + private void updateName(String testClass, String testMethod) { + if (testClass != null) { + this.testClass = testClass; + } + if (testMethod != null) { + this.testMethod = testMethod; + } + } + + private void starting(RunMode runMode) { + if (runMode == RunMode.RERUN_TEST_AFTER_FAILURE) { + retrying = true; + } + } + + private void succeeded() { + success = true; + retrying = false; + } + + private void failed(RunMode runMode, String failureMessage) { + failure = true; + if (message == null) { + message = failureMessage; + } + if (runMode == RunMode.RERUN_TEST_AFTER_FAILURE) { + retrying = true; + } + } + + private void errored(RunMode runMode, String failureMessage) { + error = true; + if (message == null) { + message = failureMessage; + } + if (runMode == RunMode.RERUN_TEST_AFTER_FAILURE) { + retrying = true; + } + } + + private void skipped() { + skipped = true; + } + + private void finalizeRetrying() { + retrying = false; + } + + private boolean isFlaky() { + return success && (failure || error); + } + + private boolean isErrored() { + return !skipped && !success && !retrying && error; + } + + private boolean isFailed() { + return !skipped && !success && !retrying && !error && failure; + } + + private String displayName() { + if (testClass == null) { + return testMethod; + } + String simpleClass = testClass.substring(testClass.lastIndexOf('.') + 1); + return testMethod != null ? simpleClass + "#" + testMethod : simpleClass; + } + + private String failureLine() { + return message != null ? displayName() + ": " + message : displayName(); + } + } } diff --git a/surefire-progress/src/test/java/org/mvndaemon/mvnd/forknode/MvndForkNodeFactoryTest.java b/surefire-progress/src/test/java/org/mvndaemon/mvnd/forknode/MvndForkNodeFactoryTest.java index c9d50d1c4..9a930f144 100644 --- a/surefire-progress/src/test/java/org/mvndaemon/mvnd/forknode/MvndForkNodeFactoryTest.java +++ b/surefire-progress/src/test/java/org/mvndaemon/mvnd/forknode/MvndForkNodeFactoryTest.java @@ -43,12 +43,12 @@ void alwaysDelegatesEvenWhenListenerThrows() { EventHandler real = delegated::add; // A listener that always blows up must not prevent delegation to the real handler. - MvndTestProgress.setListener((p, c, m, comp, f, e, s) -> { + MvndTestProgress.setListener((p, fork, c, m, comp, f, e, s, r, fl, flakyTests, failedTests, erroredTests) -> { throw new RuntimeException("boom"); }); EventHandler wrapper = - new MvndForkNodeFactory.ProgressEventHandler("proj", real, new TestProgressAccumulator()); + new MvndForkNodeFactory.ProgressEventHandler("proj", 7, real, new TestProgressAccumulator()); wrapper.handleEvent(new ControlByeEvent()); @@ -61,7 +61,7 @@ void delegatesWhenNoListenerRegistered() { EventHandler real = delegated::add; EventHandler wrapper = - new MvndForkNodeFactory.ProgressEventHandler("proj", real, new TestProgressAccumulator()); + new MvndForkNodeFactory.ProgressEventHandler("proj", 7, real, new TestProgressAccumulator()); wrapper.handleEvent(new ControlByeEvent()); diff --git a/surefire-progress/src/test/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulatorTest.java b/surefire-progress/src/test/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulatorTest.java index 838417bc9..0c7f5e22a 100644 --- a/surefire-progress/src/test/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulatorTest.java +++ b/surefire-progress/src/test/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulatorTest.java @@ -18,10 +18,13 @@ */ package org.mvndaemon.mvnd.forknode; +import org.apache.maven.surefire.api.report.RunMode; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mvndaemon.mvnd.forknode.TestProgressAccumulator.Type.TESTSET_COMPLETED; import static org.mvndaemon.mvnd.forknode.TestProgressAccumulator.Type.TESTSET_STARTING; import static org.mvndaemon.mvnd.forknode.TestProgressAccumulator.Type.TEST_ERROR; import static org.mvndaemon.mvnd.forknode.TestProgressAccumulator.Type.TEST_FAILED; @@ -64,6 +67,42 @@ void countsFailuresErrorsAndSkips() { assertEquals(1, acc.getSkipped()); } + @Test + void failedAndErroredTestsExposeNameAndMessage() { + TestProgressAccumulator acc = new TestProgressAccumulator(); + acc.record(TEST_STARTING, "org.example.CalcTest", "adds", RunMode.NORMAL_RUN, null); + acc.record(TEST_FAILED, "org.example.CalcTest", "adds", RunMode.NORMAL_RUN, null, "expected: <5> but was: <4>"); + acc.record(TEST_STARTING, "org.example.CalcTest", "divides", RunMode.NORMAL_RUN, null); + acc.record(TEST_ERROR, "org.example.CalcTest", "divides", RunMode.NORMAL_RUN, null, "/ by zero"); + + assertEquals(1, acc.getFailures()); + assertEquals(1, acc.getErrors()); + assertEquals(java.util.List.of("CalcTest#adds: expected: <5> but was: <4>"), acc.getFailedTests()); + assertEquals(java.util.List.of("CalcTest#divides: / by zero"), acc.getErroredTests()); + } + + @Test + void firstFailureMessageWinsAndNewlinesAreFlattened() { + TestProgressAccumulator acc = new TestProgressAccumulator(); + acc.record(TEST_STARTING, "T", "a", RunMode.NORMAL_RUN, null); + acc.record(TEST_FAILED, "T", "a", RunMode.NORMAL_RUN, null, "line one\n line two"); + + assertEquals(java.util.List.of("T#a: line one line two"), acc.getFailedTests()); + } + + @Test + void flakyTestIsNeitherFailedNorErrored() { + TestProgressAccumulator acc = new TestProgressAccumulator(); + acc.record(TEST_STARTING, "T", "a", RunMode.NORMAL_RUN, 1L); + acc.record(TEST_FAILED, "T", "a", RunMode.NORMAL_RUN, 1L, "boom"); + acc.record(TEST_STARTING, "T", "a", RunMode.RERUN_TEST_AFTER_FAILURE, 1L); + acc.record(TEST_SUCCEEDED, "T", "a", RunMode.RERUN_TEST_AFTER_FAILURE, 1L); + + assertTrue(acc.getFailedTests().isEmpty(), "a recovered test must not be listed as failed"); + assertTrue(acc.getErroredTests().isEmpty(), "a recovered test must not be listed as errored"); + assertTrue(acc.getFlakyTests().contains("T#a")); + } + @Test void testsetStartingSetsClassWithNullMethod() { TestProgressAccumulator acc = new TestProgressAccumulator(); @@ -71,4 +110,41 @@ void testsetStartingSetsClassWithNullMethod() { assertEquals("OtherTest", acc.getTestClass()); assertNull(acc.getTestMethod()); } + + @Test + void retriesCanRecoverAsFlakyTests() { + TestProgressAccumulator acc = new TestProgressAccumulator(); + acc.record(TEST_STARTING, "MyServiceTest", "shouldWork", RunMode.NORMAL_RUN, 1L); + acc.record(TEST_FAILED, "MyServiceTest", "shouldWork", RunMode.NORMAL_RUN, 1L); + assertEquals(1, acc.getFailures()); + assertEquals(0, acc.getRetrying()); + + acc.record(TEST_STARTING, "MyServiceTest", "shouldWork", RunMode.RERUN_TEST_AFTER_FAILURE, 1L); + assertEquals(0, acc.getFailures()); + assertEquals(1, acc.getRetrying()); + + acc.record(TEST_SUCCEEDED, "MyServiceTest", "shouldWork", RunMode.RERUN_TEST_AFTER_FAILURE, 1L); + + assertEquals(1, acc.getCompleted()); + assertEquals(0, acc.getFailures()); + assertEquals(0, acc.getRetrying()); + assertEquals(1, acc.getFlaky()); + assertTrue(acc.getFlakyTests().contains("MyServiceTest#shouldWork")); + } + + @Test + void unrecoveredRetryEndsAsFailure() { + TestProgressAccumulator acc = new TestProgressAccumulator(); + acc.record(TEST_STARTING, "MyServiceTest", "shouldWork", RunMode.NORMAL_RUN, 1L); + acc.record(TEST_FAILED, "MyServiceTest", "shouldWork", RunMode.NORMAL_RUN, 1L); + acc.record(TEST_STARTING, "MyServiceTest", "shouldWork", RunMode.RERUN_TEST_AFTER_FAILURE, 1L); + assertEquals(1, acc.getRetrying()); + + acc.record(TESTSET_COMPLETED, "MyServiceTest", null, RunMode.RERUN_TEST_AFTER_FAILURE, 1L); + + assertEquals(1, acc.getCompleted()); + assertEquals(1, acc.getFailures()); + assertEquals(0, acc.getRetrying()); + assertEquals(0, acc.getFlaky()); + } } From 65c4123687908f67c34044f2c9ee4cb4caf25005 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:56:48 -0400 Subject: [PATCH 06/12] feat(daemon): emit test failure/flake summary through daemon-side logging Move end-of-build test summary rendering from the client (string injection keyed on a "BUILD FAILURE" text match) to the daemon, where LoggingExecutionListener logs it through its own SLF4J logger just before the Reactor Summary. This routes the summary through the same MvndSimpleLogger pipeline as every other Maven console line, so ANSI coloring, [LEVEL] prefixes, and -q gating all come for free instead of being reimplemented client-side. ClientDispatcher now collects failed/errored/flaky test identities and per-fork numeric totals into a new TestBuildSummary as testProgress() events arrive; BuildEventListener exposes foldTestProgress()/ getTestSummary() so the Maven-realm listener can fold and render them. TerminalOutput drops the now-daemon-owned summary machinery, keeping only the live per-project progress line and the banned-skip filter. The -q client-side plumbing (MAVEN_QUIET, TerminalOutput's quiet field) is removed since the daemon's own logger level already handles it. Co-Authored-By: Claude Sonnet 5 --- .../mvnd/common/logging/TerminalOutput.java | 114 +------- .../common/logging/TerminalOutputTest.java | 75 +----- .../apache/maven/cli/DaemonMavenInvoker.java | 13 + .../mvnd/daemon/ClientDispatcher.java | 22 ++ .../daemon/TestSummaryExecutionListener.java | 156 +++++++++++ .../mvnd/it/TestProgressFailureTest.java | 27 ++ .../mvndaemon/mvnd/it/TestProgressTest.java | 4 +- logging/pom.xml | 6 + .../mvnd/logging/smart/TestBuildSummary.java | 253 ++++++++++++++++++ .../logging/smart/TestBuildSummaryTest.java | 165 ++++++++++++ .../forknode/TestProgressAccumulator.java | 56 +++- .../forknode/TestProgressAccumulatorTest.java | 28 +- 12 files changed, 736 insertions(+), 183 deletions(-) create mode 100644 daemon/src/main/java/org/mvndaemon/mvnd/daemon/TestSummaryExecutionListener.java create mode 100644 logging/src/main/java/org/mvndaemon/mvnd/logging/smart/TestBuildSummary.java create mode 100644 logging/src/test/java/org/mvndaemon/mvnd/logging/smart/TestBuildSummaryTest.java diff --git a/common/src/main/java/org/mvndaemon/mvnd/common/logging/TerminalOutput.java b/common/src/main/java/org/mvndaemon/mvnd/common/logging/TerminalOutput.java index 6a9fbdf99..69e8954f7 100644 --- a/common/src/main/java/org/mvndaemon/mvnd/common/logging/TerminalOutput.java +++ b/common/src/main/java/org/mvndaemon/mvnd/common/logging/TerminalOutput.java @@ -139,11 +139,6 @@ public class TerminalOutput implements ClientOutput { private boolean displayDone = false; private boolean noBuffering; private final Map failureProgress = new LinkedHashMap<>(); - private final Map> flakyTests = new LinkedHashMap<>(); - private final Map> failedTests = new LinkedHashMap<>(); - private final Map> erroredTests = new LinkedHashMap<>(); - /** Guards against emitting the aggregated failed/errored summary more than once. */ - private boolean failureSummaryEmitted; /** When {@code true}, "Skipping X / banned from the build" reactor blocks are dropped from the console. */ private final boolean hideBannedProjectSkips; @@ -314,14 +309,6 @@ private boolean doAccept(Message entry) { bannedSkipFilter.flush(log::accept); } projects.values().stream().flatMap(p -> p.log.stream()).forEach(log); - if (!failureSummaryEmitted) { - emitFailedTestsSummary(log::accept, failedTests, erroredTests); - failureSummaryEmitted = true; - } - String flakySummary = formatFlakySummary(flakyTests); - if (flakySummary != null) { - log.accept(flakySummary); - } if (failures.isEmpty()) { clearDisplay(); } @@ -473,21 +460,6 @@ private boolean doAccept(Message entry) { if (prj != null) { prj.testProgress.put(e.getForkChannelId(), e); } - if (!e.getFlakyTests().isEmpty()) { - flakyTests - .computeIfAbsent(e.getProjectId(), k -> new LinkedHashSet<>()) - .addAll(e.getFlakyTests()); - } - if (!e.getFailedTests().isEmpty()) { - failedTests - .computeIfAbsent(e.getProjectId(), k -> new LinkedHashSet<>()) - .addAll(e.getFailedTests()); - } - if (!e.getErroredTests().isEmpty()) { - erroredTests - .computeIfAbsent(e.getProjectId(), k -> new LinkedHashSet<>()) - .addAll(e.getErroredTests()); - } break; } default: @@ -884,32 +856,6 @@ private void addProjectLine(final List lines, Project prj) { lines.add(asb.toAttributedString()); } - static String formatFlakySummary(Map> flakyTests) { - if (flakyTests.isEmpty()) { - return null; - } - StringBuilder sb = new StringBuilder(); - sb.append("Flaky tests: "); - boolean firstProject = true; - for (Map.Entry> entry : flakyTests.entrySet()) { - if (!firstProject) { - sb.append("; "); - } - firstProject = false; - sb.append(entry.getKey()).append(" ["); - boolean firstTest = true; - for (String test : entry.getValue()) { - if (!firstTest) { - sb.append(", "); - } - firstTest = false; - sb.append(test); - } - sb.append(']'); - } - return sb.toString(); - } - /** Matches SGR (color) escape sequences emitted by the daemon-side log renderer. */ private static final Pattern ANSI = Pattern.compile("\\[[0-9;]*m"); /** Matches a leading {@code [LEVEL] } prefix such as {@code [INFO] } or {@code [ERROR] }. */ @@ -940,69 +886,23 @@ private static boolean isSeparator(String stripped) { } /** - * Handles a reactor-level (project-less) Maven log line: injects the aggregated failed/errored summary directly - * above the {@code BUILD FAILURE} banner, and drops "banned from the build" skip blocks when enabled. + * Handles a reactor-level (project-less) Maven log line: drops "banned from the build" skip blocks when enabled. */ private void acceptReactorLine(String line) { - failureSummaryEmitted = acceptReactorLine( - line, - hideBannedProjectSkips, - failedTests, - erroredTests, - failureSummaryEmitted, - bannedSkipFilter, - log::accept); + acceptReactorLine(line, hideBannedProjectSkips, bannedSkipFilter, log); } /** - * Processes one reactor line: injects the aggregated failed/errored summary immediately above the - * {@code BUILD FAILURE} banner (once), and drops "banned from the build" blocks when {@code hideBannedProjectSkips} - * is set. Returns the updated {@code failureSummaryEmitted} flag. Static and side-effect free apart from - * {@code out}/{@code filter} so the ordering can be unit-tested without a terminal. + * Processes one reactor line: drops "banned from the build" blocks when {@code hideBannedProjectSkips} is set. + * Static and side-effect free apart from {@code out}/{@code filter} so it can be unit-tested without a terminal. */ - static boolean acceptReactorLine( - String line, - boolean hideBannedProjectSkips, - Map> failedTests, - Map> erroredTests, - boolean failureSummaryEmitted, - BannedSkipFilter filter, - Consumer out) { - final String stripped = stripDecoration(line); - if (!failureSummaryEmitted - && stripped.equals("BUILD FAILURE") - && (!failedTests.isEmpty() || !erroredTests.isEmpty())) { - if (hideBannedProjectSkips) { - filter.flush(out); - } - emitFailedTestsSummary(out, failedTests, erroredTests); - failureSummaryEmitted = true; - } + static void acceptReactorLine( + String line, boolean hideBannedProjectSkips, BannedSkipFilter filter, Consumer out) { if (hideBannedProjectSkips) { - filter.accept(line, stripped, out); + filter.accept(line, stripDecoration(line), out); } else { out.accept(line); } - return failureSummaryEmitted; - } - - /** Emits the aggregated failed/errored test blocks (nothing when both are empty). */ - static void emitFailedTestsSummary( - Consumer out, Map> failedTests, Map> erroredTests) { - emitCategory(out, "Failed tests:", failedTests); - emitCategory(out, "Errored tests:", erroredTests); - } - - static void emitCategory(Consumer out, String header, Map> byProject) { - if (byProject.isEmpty()) { - return; - } - out.accept(header); - for (Map.Entry> entry : byProject.entrySet()) { - for (String test : entry.getValue()) { - out.accept(" " + entry.getKey() + " " + test); - } - } } /** diff --git a/common/src/test/java/org/mvndaemon/mvnd/common/logging/TerminalOutputTest.java b/common/src/test/java/org/mvndaemon/mvnd/common/logging/TerminalOutputTest.java index faab26e30..9119303b1 100644 --- a/common/src/test/java/org/mvndaemon/mvnd/common/logging/TerminalOutputTest.java +++ b/common/src/test/java/org/mvndaemon/mvnd/common/logging/TerminalOutputTest.java @@ -18,10 +18,6 @@ */ package org.mvndaemon.mvnd.common.logging; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.Map; - import org.jline.utils.AttributedString; import org.jline.utils.AttributedStringBuilder; import org.jline.utils.AttributedStyle; @@ -29,7 +25,6 @@ import org.mvndaemon.mvnd.common.Message; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; class TerminalOutputTest { @@ -131,17 +126,6 @@ void aggregateTestProgressSumsForkSnapshots() { assertEquals("skippedTest", aggregated.getTestMethod()); } - @Test - void formatFlakySummaryListsRecoveredTests() { - Map> flakyTests = new LinkedHashMap<>(); - flakyTests.put("app", new LinkedHashSet<>(java.util.List.of("FooTest#shouldWork", "FooTest#other"))); - flakyTests.put("lib", new LinkedHashSet<>(java.util.List.of("BarTest#retries"))); - - assertEquals( - "Flaky tests: app [FooTest#shouldWork, FooTest#other]; lib [BarTest#retries]", - TerminalOutput.formatFlakySummary(flakyTests)); - } - @Test void hidesProjectDetailsForLargeFailingReactors() { assertEquals(false, TerminalOutput.shouldShowProjectDetails(20, 10, 1)); @@ -159,30 +143,6 @@ void stripDecorationRemovesLevelPrefixAndAnsi() { "[INFO] This project has been banned from the build due to previous failures.")); } - @Test - void emitCategoryPrefixesEachTestWithProjectId() { - java.util.List out = new java.util.ArrayList<>(); - Map> failed = new LinkedHashMap<>(); - failed.put("camel-jms", new LinkedHashSet<>(java.util.List.of("FooTest#bar: expected <5> but was <4>"))); - failed.put("camel-nats", new LinkedHashSet<>(java.util.List.of("NatsIT#connects: refused"))); - - TerminalOutput.emitCategory(out::add, "Failed tests:", failed); - - assertEquals( - java.util.List.of( - "Failed tests:", - " camel-jms FooTest#bar: expected <5> but was <4>", - " camel-nats NatsIT#connects: refused"), - out); - } - - @Test - void emitCategoryEmitsNothingWhenEmpty() { - java.util.List out = new java.util.ArrayList<>(); - TerminalOutput.emitCategory(out::add, "Failed tests:", new LinkedHashMap<>()); - assertEquals(java.util.List.of(), out); - } - @Test void bannedSkipFilterDropsBannedBlockButKeepsOtherLines() { TerminalOutput.BannedSkipFilter filter = new TerminalOutput.BannedSkipFilter(); @@ -206,12 +166,7 @@ void bannedSkipFilterDropsBannedBlockButKeepsOtherLines() { } @Test - void reactorSummaryIsInjectedRightBeforeBuildFailureBannerAndBannedBlockIsDropped() { - Map> failed = new LinkedHashMap<>(); - failed.put("camel-jms", new LinkedHashSet<>(java.util.List.of("FooTest#bar: expected <5> but was <4>"))); - Map> errored = new LinkedHashMap<>(); - errored.put("camel-nats", new LinkedHashSet<>(java.util.List.of("NatsIT#connects: refused"))); - + void acceptReactorLineDropsBannedBlockWhenSuppressionEnabled() { String sep = "[INFO] ------------------------------------------------------------------------"; String[] lines = { sep, @@ -220,37 +175,16 @@ void reactorSummaryIsInjectedRightBeforeBuildFailureBannerAndBannedBlockIsDroppe sep, "[INFO] Reactor Summary:", "[INFO] camel-core ......... SKIPPED", - sep, - "[INFO] BUILD FAILURE", - sep, - "[INFO] Total time: 1.2 s", }; java.util.List out = new java.util.ArrayList<>(); TerminalOutput.BannedSkipFilter filter = new TerminalOutput.BannedSkipFilter(); - boolean emitted = false; for (String l : lines) { - emitted = TerminalOutput.acceptReactorLine(l, true, failed, errored, emitted, filter, out::add); + TerminalOutput.acceptReactorLine(l, true, filter, out::add); } filter.flush(out::add); - // Summary appears immediately above the BUILD FAILURE banner text. - int failedHeader = out.indexOf("Failed tests:"); - int banner = out.indexOf("[INFO] BUILD FAILURE"); - assertEquals(banner - 4, failedHeader, "summary block must sit directly above the BUILD FAILURE banner"); - assertEquals( - java.util.List.of( - "Failed tests:", - " camel-jms FooTest#bar: expected <5> but was <4>", - "Errored tests:", - " camel-nats NatsIT#connects: refused", - "[INFO] BUILD FAILURE"), - out.subList(failedHeader, banner + 1)); - // The banned "Skipping / banned from the build" block is gone, but the reactor SKIPPED row stays. - assertTrue(out.stream().noneMatch(s -> s.contains("banned from the build")), "banned block must be dropped"); - assertTrue(out.stream().noneMatch(s -> s.contains("Skipping Camel")), "skipping line must be dropped"); - assertTrue( - out.contains("[INFO] camel-core ......... SKIPPED"), "reactor summary SKIPPED row must be preserved"); + assertEquals(java.util.List.of("[INFO] Reactor Summary:", "[INFO] camel-core ......... SKIPPED"), out); } @Test @@ -260,9 +194,6 @@ void reactorLineIsPassedThroughUnchangedWhenSuppressionDisabled() { TerminalOutput.acceptReactorLine( "[INFO] This project has been banned from the build due to previous failures.", false, - new LinkedHashMap<>(), - new LinkedHashMap<>(), - false, filter, out::add); assertEquals( diff --git a/daemon/src/main/java/org/apache/maven/cli/DaemonMavenInvoker.java b/daemon/src/main/java/org/apache/maven/cli/DaemonMavenInvoker.java index d11709772..5271e9314 100644 --- a/daemon/src/main/java/org/apache/maven/cli/DaemonMavenInvoker.java +++ b/daemon/src/main/java/org/apache/maven/cli/DaemonMavenInvoker.java @@ -31,12 +31,15 @@ import org.apache.maven.cling.invoker.mvn.MavenContext; import org.apache.maven.cling.invoker.mvn.resident.ResidentMavenInvoker; import org.apache.maven.cling.utils.CLIReportingUtils; +import org.apache.maven.execution.ExecutionListener; import org.apache.maven.execution.MavenExecutionRequest; import org.apache.maven.jline.MessageUtils; import org.apache.maven.logging.BuildEventListener; import org.apache.maven.logging.LoggingOutputStream; import org.jline.terminal.TerminalBuilder; import org.mvndaemon.mvnd.common.Environment; +import org.mvndaemon.mvnd.daemon.ClientDispatcher; +import org.mvndaemon.mvnd.daemon.TestSummaryExecutionListener; public class DaemonMavenInvoker extends ResidentMavenInvoker { public DaemonMavenInvoker(ProtoLookup protoLookup, @Nullable Consumer contextConsumer) { @@ -78,6 +81,16 @@ protected org.apache.maven.logging.BuildEventListener doDetermineBuildEventListe return context.invokerRequest.lookup().lookup(BuildEventListener.class); } + @Override + protected ExecutionListener determineExecutionListener(MavenContext context) { + ExecutionListener delegate = super.determineExecutionListener(context); + BuildEventListener buildEventListener = doDetermineBuildEventListener(context); + if (buildEventListener instanceof ClientDispatcher clientDispatcher) { + return new TestSummaryExecutionListener(delegate, clientDispatcher); + } + return delegate; + } + @Override protected void helpOrVersionAndMayExit(MavenContext context) throws Exception { InvokerRequest invokerRequest = context.invokerRequest; diff --git a/daemon/src/main/java/org/mvndaemon/mvnd/daemon/ClientDispatcher.java b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/ClientDispatcher.java index 8eb86b081..8bf87350e 100644 --- a/daemon/src/main/java/org/mvndaemon/mvnd/daemon/ClientDispatcher.java +++ b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/ClientDispatcher.java @@ -38,12 +38,14 @@ import org.mvndaemon.mvnd.common.Message; import org.mvndaemon.mvnd.common.Message.BuildException; import org.mvndaemon.mvnd.common.Message.BuildStarted; +import org.mvndaemon.mvnd.logging.smart.TestBuildSummary; /** * Sends events back to the client. */ public class ClientDispatcher implements BuildEventListener { private final Collection queue; + private final TestBuildSummary testSummary = new TestBuildSummary(); private static final Pattern TRAILING_EOLS_PATTERN = Pattern.compile("[\r\n]+$"); public ClientDispatcher(Collection queue) { @@ -158,6 +160,26 @@ public void testProgress( flakyTests, failedTests, erroredTests)); + testSummary.record( + projectId, + forkChannelId, + completed, + failures, + errors, + skipped, + retrying, + flaky, + flakyTests, + failedTests, + erroredTests); + } + + public void foldTestProgress(String projectId) { + testSummary.foldProject(projectId); + } + + public TestBuildSummary getTestSummary() { + return testSummary; } public void finish(int exitCode) throws Exception { diff --git a/daemon/src/main/java/org/mvndaemon/mvnd/daemon/TestSummaryExecutionListener.java b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/TestSummaryExecutionListener.java new file mode 100644 index 000000000..fb239b929 --- /dev/null +++ b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/TestSummaryExecutionListener.java @@ -0,0 +1,156 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.mvndaemon.mvnd.daemon; + +import java.util.List; + +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.ExecutionListener; +import org.mvndaemon.mvnd.logging.smart.TestBuildSummary; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Decorates the {@link ExecutionListener} chain Maven 4 builds in {@code MavenInvoker.determineExecutionListener} + * (wired up by {@link DaemonMavenInvoker#determineExecutionListener}) to fold per-project test-progress snapshots + * into the reactor-wide {@link TestBuildSummary} and log it through this class's own SLF4J logger immediately + * before {@code delegate.sessionEnded} prints the Reactor Summary. Routing through SLF4J means ANSI coloring and + * {@code -q} log-level gating come from the normal Maven logging pipeline instead of being reimplemented + * client-side, matching how every other Maven console line is rendered. + */ +public class TestSummaryExecutionListener implements ExecutionListener { + + private static final Logger LOGGER = LoggerFactory.getLogger("org.mvndaemon.mvnd.testsummary"); + + private final ExecutionListener delegate; + private final ClientDispatcher clientDispatcher; + + public TestSummaryExecutionListener(ExecutionListener delegate, ClientDispatcher clientDispatcher) { + this.delegate = delegate; + this.clientDispatcher = clientDispatcher; + } + + @Override + public void mojoStarted(ExecutionEvent event) { + delegate.mojoStarted(event); + // Folds the previous test-running mojo's snapshots into the reactor totals; a no-op for non-test mojos. + // Needed so a project's failsafe run doesn't overwrite its still-unfolded surefire snapshots (both start + // fork-channel ids at 0). + clientDispatcher.foldTestProgress(event.getProject().getArtifactId()); + } + + @Override + public void sessionEnded(ExecutionEvent event) { + emitTestSummary(); + delegate.sessionEnded(event); + } + + private void emitTestSummary() { + List lines = + clientDispatcher.getTestSummary().renderLines(); + for (TestBuildSummary.SummaryLine line : lines) { + switch (line.level) { + case ERROR: + LOGGER.error(line.text); + break; + case WARNING: + LOGGER.warn(line.text); + break; + default: + LOGGER.info(line.text); + break; + } + } + } + + @Override + public void projectDiscoveryStarted(ExecutionEvent event) { + delegate.projectDiscoveryStarted(event); + } + + @Override + public void sessionStarted(ExecutionEvent event) { + delegate.sessionStarted(event); + } + + @Override + public void projectSkipped(ExecutionEvent event) { + delegate.projectSkipped(event); + } + + @Override + public void projectStarted(ExecutionEvent event) { + delegate.projectStarted(event); + } + + @Override + public void projectSucceeded(ExecutionEvent event) { + delegate.projectSucceeded(event); + } + + @Override + public void projectFailed(ExecutionEvent event) { + delegate.projectFailed(event); + } + + @Override + public void mojoSkipped(ExecutionEvent event) { + delegate.mojoSkipped(event); + } + + @Override + public void mojoSucceeded(ExecutionEvent event) { + delegate.mojoSucceeded(event); + } + + @Override + public void mojoFailed(ExecutionEvent event) { + delegate.mojoFailed(event); + } + + @Override + public void forkStarted(ExecutionEvent event) { + delegate.forkStarted(event); + } + + @Override + public void forkSucceeded(ExecutionEvent event) { + delegate.forkSucceeded(event); + } + + @Override + public void forkFailed(ExecutionEvent event) { + delegate.forkFailed(event); + } + + @Override + public void forkedProjectStarted(ExecutionEvent event) { + delegate.forkedProjectStarted(event); + } + + @Override + public void forkedProjectSucceeded(ExecutionEvent event) { + delegate.forkedProjectSucceeded(event); + } + + @Override + public void forkedProjectFailed(ExecutionEvent event) { + delegate.forkedProjectFailed(event); + } +} diff --git a/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressFailureTest.java b/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressFailureTest.java index bfc239840..5325dd538 100644 --- a/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressFailureTest.java +++ b/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressFailureTest.java @@ -57,5 +57,32 @@ void reportsFailedAndErroredTestsWithMessages() throws InterruptedException { .flatMap(e -> e.getErroredTests().stream()) .anyMatch(t -> t.startsWith("FailingServiceTest#throwsError") && t.contains(": ")), "expected the errored test to be reported with its message"); + + List logLines = output.getMessages().stream() + .filter(Message.StringMessage.class::isInstance) + .filter(m -> m.getType() == Message.BUILD_LOG_MESSAGE) + .map(Message.StringMessage.class::cast) + .map(Message.StringMessage::getMessage) + .toList(); + + int failuresLine = indexOfLineContaining(logLines, "Failures:"); + int errorsLine = indexOfLineContaining(logLines, "Errors:"); + // test-progress-failure is a single-module fixture, so Maven never prints a "Reactor Summary" section; + // "BUILD FAILURE" is the banner that is always emitted, so it is the anchor used here instead. + int buildFailureLine = indexOfLineContaining(logLines, "BUILD FAILURE"); + assertTrue(failuresLine >= 0, "expected a daemon-emitted log line containing 'Failures:', got: " + logLines); + assertTrue(errorsLine >= 0, "expected a daemon-emitted log line containing 'Errors:', got: " + logLines); + assertTrue(buildFailureLine >= 0, "expected a 'BUILD FAILURE' log line, got: " + logLines); + assertTrue(failuresLine < buildFailureLine, "the test summary must be logged before the BUILD FAILURE banner"); + assertTrue(errorsLine < buildFailureLine, "the test summary must be logged before the BUILD FAILURE banner"); + } + + private static int indexOfLineContaining(List lines, String needle) { + for (int i = 0; i < lines.size(); i++) { + if (lines.get(i).contains(needle)) { + return i; + } + } + return -1; } } diff --git a/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressTest.java b/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressTest.java index 4c013f985..4fae7aa7b 100644 --- a/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressTest.java +++ b/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressTest.java @@ -70,7 +70,9 @@ void emitsFlakyTestProgress() throws InterruptedException { assertTrue( events.stream().anyMatch(e -> e.getFlaky() > 0), "expected a flaky snapshot after the rerun succeeded"); assertTrue( - events.stream().anyMatch(e -> e.getFlakyTests().contains("FlakyServiceTest#succeedsOnRetry")), + events.stream() + .flatMap(e -> e.getFlakyTests().stream()) + .anyMatch(t -> t.startsWith("FlakyServiceTest#succeedsOnRetry")), "expected the recovered test to be reported in the flaky test list"); } diff --git a/logging/pom.xml b/logging/pom.xml index 4d7e646a9..f8340a7fd 100644 --- a/logging/pom.xml +++ b/logging/pom.xml @@ -35,6 +35,12 @@ org.apache.maven maven-logging
+ + + org.junit.jupiter + junit-jupiter + test + diff --git a/logging/src/main/java/org/mvndaemon/mvnd/logging/smart/TestBuildSummary.java b/logging/src/main/java/org/mvndaemon/mvnd/logging/smart/TestBuildSummary.java new file mode 100644 index 000000000..e01312b6a --- /dev/null +++ b/logging/src/main/java/org/mvndaemon/mvnd/logging/smart/TestBuildSummary.java @@ -0,0 +1,253 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.mvndaemon.mvnd.logging.smart; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Collects the reactor-wide failed/errored/flaky test identities and numeric totals needed to render the + * end-of-build test summary. Records arrive on fork-reader threads via {@link #record}, are folded into the + * running totals on build threads via {@link #foldProject}, and are rendered on the main thread via + * {@link #renderLines()}; all three are synchronized so the object can be shared across those threads. + */ +public class TestBuildSummary { + + /** Test-summary line severity, mirroring Maven's INFO/WARNING/ERROR levels. */ + public enum SummaryLevel { + INFO, + WARNING, + ERROR + } + + /** One line of the rendered summary, tagged with the level it should be logged at. */ + public static final class SummaryLine { + public final SummaryLevel level; + public final String text; + + SummaryLine(SummaryLevel level, String text) { + this.level = level; + this.text = text; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof SummaryLine)) { + return false; + } + SummaryLine other = (SummaryLine) o; + return level == other.level && text.equals(other.text); + } + + @Override + public int hashCode() { + return 31 * level.hashCode() + text.hashCode(); + } + + @Override + public String toString() { + return "[" + level + "] " + text; + } + } + + private final Map> failedTests = new LinkedHashMap<>(); + private final Map> erroredTests = new LinkedHashMap<>(); + private final Map> flakyTests = new LinkedHashMap<>(); + /** Latest cumulative per-fork snapshot for each project, indexed by fork channel id. */ + private final Map> currentByProject = new LinkedHashMap<>(); + + private TestTotals totals = TestTotals.EMPTY; + + /** + * Records one project/fork's latest cumulative snapshot and unions in any newly reported test identities. + * Union is collision-free, so fork-channel-id reuse across surefire/failsafe does not affect the identity sets. + */ + public synchronized void record( + String projectId, + int forkChannelId, + int completed, + int failures, + int errors, + int skipped, + int retrying, + int flaky, + List flakyTests, + List failedTests, + List erroredTests) { + if (!flakyTests.isEmpty()) { + this.flakyTests + .computeIfAbsent(projectId, k -> new LinkedHashSet<>()) + .addAll(flakyTests); + } + if (!failedTests.isEmpty()) { + this.failedTests + .computeIfAbsent(projectId, k -> new LinkedHashSet<>()) + .addAll(failedTests); + } + if (!erroredTests.isEmpty()) { + this.erroredTests + .computeIfAbsent(projectId, k -> new LinkedHashSet<>()) + .addAll(erroredTests); + } + currentByProject + .computeIfAbsent(projectId, k -> new LinkedHashMap<>()) + .put(forkChannelId, new int[] {completed, failures, errors, skipped, retrying, flaky}); + } + + /** + * Sums the given project's latest per-fork snapshots into the running reactor-wide totals, then clears them. + * Called at each mojo boundary so cumulative-per-fork counts are summed correctly across surefire+failsafe + * (fork ids restart per plugin execution). A no-op if the project reported no test progress. + */ + public synchronized void foldProject(String projectId) { + Map snapshots = currentByProject.remove(projectId); + if (snapshots == null || snapshots.isEmpty()) { + return; + } + int completed = 0; + int failures = 0; + int errors = 0; + int skipped = 0; + int flaky = 0; + for (int[] snapshot : snapshots.values()) { + completed += snapshot[0]; + failures += snapshot[1]; + errors += snapshot[2]; + skipped += snapshot[3]; + flaky += snapshot[5]; + } + totals = new TestTotals( + totals.completed + completed, + totals.failures + failures, + totals.errors + errors, + totals.skipped + skipped, + totals.flaky + flaky); + } + + /** + * Folds in any projects whose snapshots have not yet been folded, then renders the reactor-wide failed/errored/ + * flaky test summary in the same shape Surefire itself uses (Results: / Failures: / Errors: / Flakes: / + * Tests run: ...). Returns an empty list when there is nothing to report. + */ + public synchronized List renderLines() { + for (String projectId : new ArrayList<>(currentByProject.keySet())) { + foldProject(projectId); + } + List out = new ArrayList<>(); + if (failedTests.isEmpty() && erroredTests.isEmpty() && flakyTests.isEmpty()) { + return out; + } + emit(out, SummaryLevel.INFO, ""); + emit(out, SummaryLevel.INFO, "Results:"); + emit(out, SummaryLevel.INFO, ""); + emitFailureCategory(out, "Failures: ", failedTests); + emitFailureCategory(out, "Errors: ", erroredTests); + emitFlakyCategory(out, "Flakes: ", flakyTests); + emit(out, SummaryLevel.INFO, ""); + emit(out, trailerLevel(totals), trailerLine(totals)); + emit(out, SummaryLevel.INFO, ""); + return out; + } + + private static void emit(List out, SummaryLevel level, String text) { + out.add(new SummaryLine(level, text)); + } + + /** Renders a "Failures: "/"Errors: " section: header plus one {@code " "} line per entry. */ + private static void emitFailureCategory(List out, String header, Map> byProject) { + if (byProject.isEmpty()) { + return; + } + emit(out, SummaryLevel.ERROR, header); + for (Map.Entry> entry : byProject.entrySet()) { + for (String test : entry.getValue()) { + emit(out, SummaryLevel.ERROR, " " + entry.getKey() + " " + test); + } + } + } + + /** + * Renders the "Flakes: " section. Each entry is a {@code TestProgressAccumulator}-formatted multi-line block + * (display name, then one {@code " Run N: PASS"}/{@code " Run N: "} line per attempt); this splits + * that block and re-levels each line: the header/test-name lines are WARNING, a passing run is INFO, a failing + * run is ERROR -- matching Surefire's own per-line coloring exactly. + */ + private static void emitFlakyCategory(List out, String header, Map> byProject) { + if (byProject.isEmpty()) { + return; + } + emit(out, SummaryLevel.WARNING, header); + for (Map.Entry> entry : byProject.entrySet()) { + for (String detail : entry.getValue()) { + String[] lines = detail.split("\n", -1); + emit(out, SummaryLevel.WARNING, " " + entry.getKey() + " " + lines[0]); + for (int i = 1; i < lines.length; i++) { + String runLine = lines[i]; + boolean passed = runLine.trim().endsWith(": PASS"); + emit(out, passed ? SummaryLevel.INFO : SummaryLevel.ERROR, " " + runLine); + } + } + } + } + + private static SummaryLevel trailerLevel(TestTotals totals) { + if (totals.failures > 0 || totals.errors > 0) { + return SummaryLevel.ERROR; + } + return totals.flaky > 0 ? SummaryLevel.WARNING : SummaryLevel.INFO; + } + + private static String trailerLine(TestTotals totals) { + StringBuilder sb = new StringBuilder("Tests run: ") + .append(totals.completed) + .append(", Failures: ") + .append(totals.failures) + .append(", Errors: ") + .append(totals.errors) + .append(", Skipped: ") + .append(totals.skipped); + if (totals.flaky > 0) { + sb.append(", Flakes: ").append(totals.flaky); + } + return sb.toString(); + } + + /** Reactor-wide test totals, folded in as each project's test-running mojo execution finishes. */ + static final class TestTotals { + static final TestTotals EMPTY = new TestTotals(0, 0, 0, 0, 0); + + final int completed; + final int failures; + final int errors; + final int skipped; + final int flaky; + + TestTotals(int completed, int failures, int errors, int skipped, int flaky) { + this.completed = completed; + this.failures = failures; + this.errors = errors; + this.skipped = skipped; + this.flaky = flaky; + } + } +} diff --git a/logging/src/test/java/org/mvndaemon/mvnd/logging/smart/TestBuildSummaryTest.java b/logging/src/test/java/org/mvndaemon/mvnd/logging/smart/TestBuildSummaryTest.java new file mode 100644 index 000000000..7bd9b3810 --- /dev/null +++ b/logging/src/test/java/org/mvndaemon/mvnd/logging/smart/TestBuildSummaryTest.java @@ -0,0 +1,165 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.mvndaemon.mvnd.logging.smart; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.mvndaemon.mvnd.logging.smart.TestBuildSummary.SummaryLevel; +import org.mvndaemon.mvnd.logging.smart.TestBuildSummary.SummaryLine; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TestBuildSummaryTest { + + @Test + void rendersSurefireStyleBlockWithTagsAndTrailer() { + TestBuildSummary summary = new TestBuildSummary(); + summary.record( + "camel-jms", + 1, + 40, + 1, + 0, + 0, + 0, + 0, + List.of(), + List.of("FooTest#bar: expected <5> but was <4>"), + List.of()); + summary.foldProject("camel-jms"); + summary.record("camel-nats", 1, 1, 0, 1, 0, 0, 0, List.of(), List.of(), List.of("NatsIT#connects: refused")); + summary.foldProject("camel-nats"); + summary.record( + "camel-mllp", + 1, + 1, + 0, + 0, + 0, + 0, + 1, + List.of("FlakyTest#retries\n Run 1: boom\n Run 2: PASS"), + List.of(), + List.of()); + summary.foldProject("camel-mllp"); + + List lines = summary.renderLines(); + + assertEquals( + List.of( + line(SummaryLevel.INFO, ""), + line(SummaryLevel.INFO, "Results:"), + line(SummaryLevel.INFO, ""), + line(SummaryLevel.ERROR, "Failures: "), + line(SummaryLevel.ERROR, " camel-jms FooTest#bar: expected <5> but was <4>"), + line(SummaryLevel.ERROR, "Errors: "), + line(SummaryLevel.ERROR, " camel-nats NatsIT#connects: refused"), + line(SummaryLevel.WARNING, "Flakes: "), + line(SummaryLevel.WARNING, " camel-mllp FlakyTest#retries"), + line(SummaryLevel.ERROR, " Run 1: boom"), + line(SummaryLevel.INFO, " Run 2: PASS"), + line(SummaryLevel.INFO, ""), + line(SummaryLevel.ERROR, "Tests run: 42, Failures: 1, Errors: 1, Skipped: 0, Flakes: 1"), + line(SummaryLevel.INFO, "")), + lines); + } + + @Test + void omitsFlakesSuffixWhenNoFlakyTests() { + TestBuildSummary summary = new TestBuildSummary(); + summary.record( + "camel-jms", + 1, + 10, + 1, + 0, + 0, + 0, + 0, + List.of(), + List.of("FooTest#bar: expected <5> but was <4>"), + List.of()); + summary.foldProject("camel-jms"); + + List lines = summary.renderLines(); + + SummaryLine trailer = lines.get(lines.size() - 2); + assertEquals(SummaryLevel.ERROR, trailer.level); + assertEquals("Tests run: 10, Failures: 1, Errors: 0, Skipped: 0", trailer.text); + } + + @Test + void emitsNothingWhenAllCategoriesEmpty() { + TestBuildSummary summary = new TestBuildSummary(); + summary.record("camel-core", 1, 5, 0, 0, 0, 0, 0, List.of(), List.of(), List.of()); + summary.foldProject("camel-core"); + + assertEquals(List.of(), summary.renderLines()); + } + + @Test + void unionsFailedTestIdentitiesAcrossForksWithinTheSameProject() { + TestBuildSummary summary = new TestBuildSummary(); + summary.record("camel-jms", 1, 1, 1, 0, 0, 0, 0, List.of(), List.of("FooTest#bar: boom"), List.of()); + // A second fork on the same project reports a different failed test; identities must union, not overwrite. + summary.record("camel-jms", 2, 1, 1, 0, 0, 0, 0, List.of(), List.of("BarTest#baz: boom"), List.of()); + // Re-recording the same test on the same fork must not create a duplicate line (Set semantics). + summary.record("camel-jms", 1, 2, 1, 0, 0, 0, 0, List.of(), List.of("FooTest#bar: boom"), List.of()); + summary.foldProject("camel-jms"); + + List lines = summary.renderLines(); + List failureLines = lines.stream() + .filter(l -> l.text.startsWith(" camel-jms")) + .map(l -> l.text) + .toList(); + assertEquals(List.of(" camel-jms FooTest#bar: boom", " camel-jms BarTest#baz: boom"), failureLines); + } + + @Test + void foldProjectSumsCumulativeCountsAcrossSurefireThenFailsafeReusingForkChannelIds() { + TestBuildSummary summary = new TestBuildSummary(); + // Surefire execution: fork channel 1 finishes with 5 completed tests. + summary.record("app", 1, 5, 0, 0, 0, 0, 0, List.of(), List.of(), List.of()); + summary.foldProject("app"); + // Failsafe execution reuses fork channel id 1 with its own cumulative count; must add, not replace. + summary.record("app", 1, 3, 1, 0, 0, 0, 0, List.of(), List.of("ItTest#works: boom"), List.of()); + summary.foldProject("app"); + + List lines = summary.renderLines(); + SummaryLine trailer = lines.get(lines.size() - 2); + assertEquals("Tests run: 8, Failures: 1, Errors: 0, Skipped: 0", trailer.text); + } + + @Test + void renderLinesFoldsAnyProjectsNotYetFolded() { + TestBuildSummary summary = new TestBuildSummary(); + summary.record("app", 1, 4, 1, 0, 0, 0, 0, List.of(), List.of("FooTest#bar: boom"), List.of()); + // No explicit foldProject call: renderLines() must fold remaining snapshots itself. + + List lines = summary.renderLines(); + + assertTrue(lines.stream().anyMatch(l -> l.text.equals("Tests run: 4, Failures: 1, Errors: 0, Skipped: 0"))); + } + + private static SummaryLine line(SummaryLevel level, String text) { + return new SummaryLine(level, text); + } +} diff --git a/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulator.java b/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulator.java index 9f10f73d7..33759bd40 100644 --- a/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulator.java +++ b/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulator.java @@ -126,7 +126,7 @@ public List getFlakyTests() { List result = new ArrayList<>(); for (TestState state : tests.values()) { if (state.isFlaky()) { - result.add(state.displayName()); + result.add(state.flakyDetail()); } } return result; @@ -216,6 +216,7 @@ private static final class TestState { private boolean skipped; private boolean retrying; private String message; + private final List runs = new ArrayList<>(); private TestState(String testClass, String testMethod) { this.testClass = testClass; @@ -240,6 +241,7 @@ private void starting(RunMode runMode) { private void succeeded() { success = true; retrying = false; + runs.add(Run.pass()); } private void failed(RunMode runMode, String failureMessage) { @@ -247,6 +249,7 @@ private void failed(RunMode runMode, String failureMessage) { if (message == null) { message = failureMessage; } + runs.add(Run.fail(failureMessage)); if (runMode == RunMode.RERUN_TEST_AFTER_FAILURE) { retrying = true; } @@ -257,6 +260,7 @@ private void errored(RunMode runMode, String failureMessage) { if (message == null) { message = failureMessage; } + runs.add(Run.error(failureMessage)); if (runMode == RunMode.RERUN_TEST_AFTER_FAILURE) { retrying = true; } @@ -293,5 +297,55 @@ private String displayName() { private String failureLine() { return message != null ? displayName() + ": " + message : displayName(); } + + private String flakyDetail() { + StringBuilder sb = new StringBuilder(displayName()); + for (int i = 0; i < runs.size(); i++) { + sb.append('\n') + .append(" Run ") + .append(i + 1) + .append(": ") + .append(runs.get(i).describe()); + } + return sb.toString(); + } + + private static final class Run { + private final Outcome outcome; + private final String message; + + private Run(Outcome outcome, String message) { + this.outcome = outcome; + this.message = message; + } + + private static Run pass() { + return new Run(Outcome.PASS, null); + } + + private static Run fail(String message) { + return new Run(Outcome.FAIL, message); + } + + private static Run error(String message) { + return new Run(Outcome.ERROR, message); + } + + private String describe() { + if (outcome == Outcome.PASS) { + return "PASS"; + } + if (message != null) { + return message; + } + return outcome == Outcome.ERROR ? "ERROR" : "FAIL"; + } + + private enum Outcome { + PASS, + FAIL, + ERROR + } + } } } diff --git a/surefire-progress/src/test/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulatorTest.java b/surefire-progress/src/test/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulatorTest.java index 0c7f5e22a..a3bc503b0 100644 --- a/surefire-progress/src/test/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulatorTest.java +++ b/surefire-progress/src/test/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulatorTest.java @@ -100,7 +100,7 @@ void flakyTestIsNeitherFailedNorErrored() { assertTrue(acc.getFailedTests().isEmpty(), "a recovered test must not be listed as failed"); assertTrue(acc.getErroredTests().isEmpty(), "a recovered test must not be listed as errored"); - assertTrue(acc.getFlakyTests().contains("T#a")); + assertEquals(java.util.List.of("T#a\n Run 1: boom\n Run 2: PASS"), acc.getFlakyTests()); } @Test @@ -129,7 +129,31 @@ void retriesCanRecoverAsFlakyTests() { assertEquals(0, acc.getFailures()); assertEquals(0, acc.getRetrying()); assertEquals(1, acc.getFlaky()); - assertTrue(acc.getFlakyTests().contains("MyServiceTest#shouldWork")); + assertEquals(java.util.List.of("MyServiceTest#shouldWork\n Run 1: FAIL\n Run 2: PASS"), acc.getFlakyTests()); + } + + @Test + void flakyTestDetailListsEachRunWithMessage() { + TestProgressAccumulator acc = new TestProgressAccumulator(); + acc.record(TEST_STARTING, "org.example.FlakyTest", "retries", RunMode.NORMAL_RUN, 7L); + acc.record(TEST_FAILED, "org.example.FlakyTest", "retries", RunMode.NORMAL_RUN, 7L, "expected <5> but was <0>"); + acc.record(TEST_STARTING, "org.example.FlakyTest", "retries", RunMode.RERUN_TEST_AFTER_FAILURE, 7L); + acc.record( + TEST_ERROR, + "org.example.FlakyTest", + "retries", + RunMode.RERUN_TEST_AFTER_FAILURE, + 7L, + "NullPointerException"); + acc.record(TEST_STARTING, "org.example.FlakyTest", "retries", RunMode.RERUN_TEST_AFTER_FAILURE, 7L); + acc.record(TEST_SUCCEEDED, "org.example.FlakyTest", "retries", RunMode.RERUN_TEST_AFTER_FAILURE, 7L); + + assertEquals( + java.util.List.of("FlakyTest#retries\n" + + " Run 1: expected <5> but was <0>\n" + + " Run 2: NullPointerException\n" + + " Run 3: PASS"), + acc.getFlakyTests()); } @Test From bf217ae95848ae1e1bd66d1367b88d4a252480ee Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:04:50 -0400 Subject: [PATCH 07/12] fix: address PR #1670 review feedback on test-progress feature Fix the supportsForkNode milestone check so it only guards the 3.0.0-Mx series instead of every 3.x version, always clear the JLine display at build end (including failures), make ProjectTestProgressEvent's list getters unmodifiable, dedupe the test-progress enabled check between Server and MvndTestProgressLifecycleParticipant, hoist the version regex to a static field, replace magic indices in TestBuildSummary's snapshot array with named constants, and document the BANNED_MARKER string-match as a maintenance risk. Co-Authored-By: Claude Sonnet 5 --- .../org/mvndaemon/mvnd/common/Message.java | 7 +++--- .../mvnd/common/logging/TerminalOutput.java | 6 ++--- .../daemon/MvndMojoExecutionConfigurator.java | 18 ++++++++++--- .../org/mvndaemon/mvnd/daemon/Server.java | 6 +---- .../MvndMojoExecutionConfiguratorTest.java | 8 ++++++ .../mvnd/logging/smart/TestBuildSummary.java | 25 +++++++++++++------ 6 files changed, 47 insertions(+), 23 deletions(-) diff --git a/common/src/main/java/org/mvndaemon/mvnd/common/Message.java b/common/src/main/java/org/mvndaemon/mvnd/common/Message.java index 1dc953f35..0de2de9cd 100644 --- a/common/src/main/java/org/mvndaemon/mvnd/common/Message.java +++ b/common/src/main/java/org/mvndaemon/mvnd/common/Message.java @@ -26,6 +26,7 @@ import java.io.StringWriter; import java.io.UTFDataFormatException; import java.util.ArrayList; +import java.util.Collections; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; @@ -749,15 +750,15 @@ public int getFlaky() { } public List getFlakyTests() { - return flakyTests; + return Collections.unmodifiableList(flakyTests); } public List getFailedTests() { - return failedTests; + return Collections.unmodifiableList(failedTests); } public List getErroredTests() { - return erroredTests; + return Collections.unmodifiableList(erroredTests); } @Override diff --git a/common/src/main/java/org/mvndaemon/mvnd/common/logging/TerminalOutput.java b/common/src/main/java/org/mvndaemon/mvnd/common/logging/TerminalOutput.java index 69e8954f7..bbe3616be 100644 --- a/common/src/main/java/org/mvndaemon/mvnd/common/logging/TerminalOutput.java +++ b/common/src/main/java/org/mvndaemon/mvnd/common/logging/TerminalOutput.java @@ -309,9 +309,7 @@ private boolean doAccept(Message entry) { bannedSkipFilter.flush(log::accept); } projects.values().stream().flatMap(p -> p.log.stream()).forEach(log); - if (failures.isEmpty()) { - clearDisplay(); - } + clearDisplay(); try { log.close(); } catch (IOException e) { @@ -861,6 +859,8 @@ private void addProjectLine(final List lines, Project prj) { /** Matches a leading {@code [LEVEL] } prefix such as {@code [INFO] } or {@code [ERROR] }. */ private static final Pattern LEVEL_PREFIX = Pattern.compile("^\\[[A-Z]+\\]\\s?"); + // Matched verbatim against Maven's reactor log text (no programmatic API for this exists); if Maven ever changes + // this message, hideBannedProjectSkips silently stops filtering instead of failing loud. private static final String BANNED_MARKER = "This project has been banned from the build due to previous failures."; /** Strips ANSI color and the {@code [LEVEL] } prefix so reactor lines can be matched by their bare text. */ diff --git a/daemon/src/main/java/org/mvndaemon/mvnd/daemon/MvndMojoExecutionConfigurator.java b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/MvndMojoExecutionConfigurator.java index 64a460c2e..efbab93dd 100644 --- a/daemon/src/main/java/org/mvndaemon/mvnd/daemon/MvndMojoExecutionConfigurator.java +++ b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/MvndMojoExecutionConfigurator.java @@ -21,6 +21,9 @@ import javax.inject.Named; import javax.inject.Singleton; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + import org.apache.maven.lifecycle.MojoExecutionConfigurator; import org.apache.maven.lifecycle.internal.DefaultMojoExecutionConfigurator; import org.apache.maven.plugin.MojoExecution; @@ -59,6 +62,7 @@ public class MvndMojoExecutionConfigurator extends DefaultMojoExecutionConfigura implements MojoExecutionConfigurator { private static final String FORK_NODE_IMPL = "org.mvndaemon.mvnd.forknode.MvndForkNodeFactory"; + private static final Pattern VERSION_PATTERN = Pattern.compile("^(\\d+)\\.(\\d+)\\.(\\d+)(?:-M(\\d+))?"); public MvndMojoExecutionConfigurator() { super(); @@ -67,7 +71,7 @@ public MvndMojoExecutionConfigurator() { @Override public void configure(MavenProject project, MojoExecution mojoExecution, boolean allowPluginLevelConfig) { super.configure(project, mojoExecution, allowPluginLevelConfig); - if (!isEnabled() || !isTestGoal(mojoExecution) || !supportsForkNode(mojoExecution.getVersion())) { + if (!isTestProgressEnabled() || !isTestGoal(mojoExecution) || !supportsForkNode(mojoExecution.getVersion())) { // Never inject into a Surefire/Failsafe that cannot load the fork-node SPI; it would fail the build. return; } @@ -87,7 +91,8 @@ public void configure(MavenProject project, MojoExecution mojoExecution, boolean config.addChild(forkNode); } - private static boolean isEnabled() { + /** Shared with {@link Server}, which enables the daemon-side test progress listener under the same flag. */ + static boolean isTestProgressEnabled() { return Environment.MVND_TEST_PROGRESS .asOptional() .map(Boolean::parseBoolean) @@ -106,8 +111,7 @@ static boolean supportsForkNode(String version) { if (version == null) { return false; } - java.util.regex.Matcher m = java.util.regex.Pattern.compile("^(\\d+)\\.(\\d+)\\.(\\d+)(?:-M(\\d+))?") - .matcher(version); + Matcher m = VERSION_PATTERN.matcher(version); if (!m.find()) { return false; } @@ -115,6 +119,12 @@ static boolean supportsForkNode(String version) { if (major != 3) { return major > 3; } + int minor = Integer.parseInt(m.group(2)); + int patch = Integer.parseInt(m.group(3)); + if (minor != 0 || patch != 0) { + // The 3.0.0-Mx milestone series is the only pre-GA run; 3.1.0+ always shipped GA. + return true; + } String milestone = m.group(4); return milestone == null || Integer.parseInt(milestone) >= 5; } diff --git a/daemon/src/main/java/org/mvndaemon/mvnd/daemon/Server.java b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/Server.java index 400776502..ae290f600 100644 --- a/daemon/src/main/java/org/mvndaemon/mvnd/daemon/Server.java +++ b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/Server.java @@ -509,11 +509,7 @@ private void handle(DaemonConnection connection, BuildRequest buildRequest) { final BlockingQueue sendQueue = new PriorityBlockingQueue<>(64, Message.getMessageComparator()); final BlockingQueue recvQueue = new LinkedBlockingDeque<>(); final BuildEventListener buildEventListener = new ClientDispatcher(sendQueue); - final boolean testProgressEnabled = Environment.MVND_TEST_PROGRESS - .asOptional() - .map(Boolean::parseBoolean) - .orElse(Boolean.TRUE); - if (testProgressEnabled) { + if (MvndMojoExecutionConfigurator.isTestProgressEnabled()) { final ClientDispatcher clientDispatcher = (ClientDispatcher) buildEventListener; MvndTestProgress.setListener( (projectId, diff --git a/daemon/src/test/java/org/mvndaemon/mvnd/daemon/MvndMojoExecutionConfiguratorTest.java b/daemon/src/test/java/org/mvndaemon/mvnd/daemon/MvndMojoExecutionConfiguratorTest.java index c7a778042..792fc2912 100644 --- a/daemon/src/test/java/org/mvndaemon/mvnd/daemon/MvndMojoExecutionConfiguratorTest.java +++ b/daemon/src/test/java/org/mvndaemon/mvnd/daemon/MvndMojoExecutionConfiguratorTest.java @@ -37,4 +37,12 @@ void injectsOnlyForSurefireVersionsThatSupportForkNode() { assertTrue(MvndMojoExecutionConfigurator.supportsForkNode("3.5.6")); assertTrue(MvndMojoExecutionConfigurator.supportsForkNode("4.0.0")); } + + @Test + void milestoneGuardOnlyAppliesToThe300MilestoneSeries() { + // only 3.0.0-Mx predates the forkNode SPI; 3.1.0+ always shipped GA, so an "-Mx" suffix there + // must not be mistaken for a pre-SPI milestone build. + assertTrue(MvndMojoExecutionConfigurator.supportsForkNode("3.1.0-M2")); + assertTrue(MvndMojoExecutionConfigurator.supportsForkNode("3.2.5-M1")); + } } diff --git a/logging/src/main/java/org/mvndaemon/mvnd/logging/smart/TestBuildSummary.java b/logging/src/main/java/org/mvndaemon/mvnd/logging/smart/TestBuildSummary.java index e01312b6a..9cdfba837 100644 --- a/logging/src/main/java/org/mvndaemon/mvnd/logging/smart/TestBuildSummary.java +++ b/logging/src/main/java/org/mvndaemon/mvnd/logging/smart/TestBuildSummary.java @@ -76,6 +76,15 @@ public String toString() { /** Latest cumulative per-fork snapshot for each project, indexed by fork channel id. */ private final Map> currentByProject = new LinkedHashMap<>(); + /** Indices into the per-fork snapshot {@code int[]} recorded by {@link #record} and folded by {@link #foldProject}. */ + private static final int IDX_COMPLETED = 0; + + private static final int IDX_FAILURES = 1; + private static final int IDX_ERRORS = 2; + private static final int IDX_SKIPPED = 3; + // IDX_RETRYING = 4 is intentionally not folded into totals: a test still retrying has no final outcome yet. + private static final int IDX_FLAKY = 5; + private TestTotals totals = TestTotals.EMPTY; /** @@ -109,9 +118,9 @@ public synchronized void record( .computeIfAbsent(projectId, k -> new LinkedHashSet<>()) .addAll(erroredTests); } - currentByProject - .computeIfAbsent(projectId, k -> new LinkedHashMap<>()) - .put(forkChannelId, new int[] {completed, failures, errors, skipped, retrying, flaky}); + currentByProject.computeIfAbsent(projectId, k -> new LinkedHashMap<>()).put(forkChannelId, new int[] { + completed, failures, errors, skipped, retrying, flaky + }); // indices: see IDX_* fields } /** @@ -130,11 +139,11 @@ public synchronized void foldProject(String projectId) { int skipped = 0; int flaky = 0; for (int[] snapshot : snapshots.values()) { - completed += snapshot[0]; - failures += snapshot[1]; - errors += snapshot[2]; - skipped += snapshot[3]; - flaky += snapshot[5]; + completed += snapshot[IDX_COMPLETED]; + failures += snapshot[IDX_FAILURES]; + errors += snapshot[IDX_ERRORS]; + skipped += snapshot[IDX_SKIPPED]; + flaky += snapshot[IDX_FLAKY]; } totals = new TestTotals( totals.completed + completed, From e7c8d8b00f014564d1cdb460f24304cd3a365a3d Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:13:25 -0400 Subject: [PATCH 08/12] fix: clamp renderBar percent to [0,100] per PR #1666 review feedback renderBar() already produced a sane bar for out-of-range input, but the invariant wasn't self-documenting; clamp explicitly and add a regression test. Co-Authored-By: Claude Sonnet 5 --- .../org/mvndaemon/mvnd/common/logging/TerminalOutput.java | 2 ++ .../mvndaemon/mvnd/common/logging/TerminalOutputTest.java | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/common/src/main/java/org/mvndaemon/mvnd/common/logging/TerminalOutput.java b/common/src/main/java/org/mvndaemon/mvnd/common/logging/TerminalOutput.java index bbe3616be..7405ba32d 100644 --- a/common/src/main/java/org/mvndaemon/mvnd/common/logging/TerminalOutput.java +++ b/common/src/main/java/org/mvndaemon/mvnd/common/logging/TerminalOutput.java @@ -735,6 +735,8 @@ public static String pathToMaven(String location) { static String renderBar(int percent) { final int width = 20; + // percent is expected in [0, 100]; clamp defensively so a rounding/caller quirk can't under/overfill the bar. + percent = Math.max(0, Math.min(100, percent)); int filled = (int) Math.round(percent / 100.0 * width); StringBuilder sb = new StringBuilder(width + 2); sb.append('['); diff --git a/common/src/test/java/org/mvndaemon/mvnd/common/logging/TerminalOutputTest.java b/common/src/test/java/org/mvndaemon/mvnd/common/logging/TerminalOutputTest.java index 9119303b1..2be185bd2 100644 --- a/common/src/test/java/org/mvndaemon/mvnd/common/logging/TerminalOutputTest.java +++ b/common/src/test/java/org/mvndaemon/mvnd/common/logging/TerminalOutputTest.java @@ -44,6 +44,13 @@ void renderBarFull() { assertEquals("[====================]", TerminalOutput.renderBar(100)); } + @Test + void renderBarClampsOutOfRangeInput() { + // doneProjects*100/totalProjects can't actually exceed [0,100], but renderBar clamps defensively anyway + assertEquals("[ ]", TerminalOutput.renderBar(-10)); + assertEquals("[====================]", TerminalOutput.renderBar(150)); + } + @Test void suffixAllPassing() { AttributedStringBuilder asb = new AttributedStringBuilder(); From b141958ede74893d33dc2aed858598332bd248a5 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:09:41 -0400 Subject: [PATCH 09/12] fix: address PR #1670 re-review feedback (2026-08-01) Flush BannedSkipFilter on CANCEL_BUILD/BUILD_EXCEPTION (previously only BUILD_FINISHED did), replace the stale PROJECT_TEST_PROGRESS TODO now that the daemon-side feed is implemented, and give TestState.displayName() a defensive fallback so it can't return null into flakyDetail(). Co-Authored-By: Claude Sonnet 5 --- common/src/main/java/org/mvndaemon/mvnd/common/Message.java | 6 +++--- .../org/mvndaemon/mvnd/common/logging/TerminalOutput.java | 6 ++++++ .../mvndaemon/mvnd/forknode/TestProgressAccumulator.java | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/common/src/main/java/org/mvndaemon/mvnd/common/Message.java b/common/src/main/java/org/mvndaemon/mvnd/common/Message.java index 0de2de9cd..c46332382 100644 --- a/common/src/main/java/org/mvndaemon/mvnd/common/Message.java +++ b/common/src/main/java/org/mvndaemon/mvnd/common/Message.java @@ -69,9 +69,9 @@ public abstract class Message { public static final int REQUEST_INPUT_AVAILABLE = 29; public static final int INPUT_AVAILABLE_DATA = 30; /** - * Live per-test progress for a project's line while surefire/failsafe run. - * TODO: the daemon-side feed that emits this message is not implemented on mvnd-1.x yet; until it - * lands in a follow-up commit, the client render stays dormant (no test-progress suffix is shown). + * Live per-test progress for a project's line while surefire/failsafe run. Emitted by the forked test JVM's + * listener bridge, relayed through the daemon, and rendered by the client as a test-progress suffix on the + * project's status line. */ public static final int PROJECT_TEST_PROGRESS = 31; diff --git a/common/src/main/java/org/mvndaemon/mvnd/common/logging/TerminalOutput.java b/common/src/main/java/org/mvndaemon/mvnd/common/logging/TerminalOutput.java index 7405ba32d..13b2d1f8a 100644 --- a/common/src/main/java/org/mvndaemon/mvnd/common/logging/TerminalOutput.java +++ b/common/src/main/java/org/mvndaemon/mvnd/common/logging/TerminalOutput.java @@ -244,6 +244,9 @@ private boolean doAccept(Message entry) { break; } case Message.CANCEL_BUILD: { + if (hideBannedProjectSkips) { + bannedSkipFilter.flush(log::accept); + } projects.values().stream().flatMap(p -> p.log.stream()).forEach(log); clearDisplay(); try { @@ -264,6 +267,9 @@ private boolean doAccept(Message entry) { } else { msg = e.getClassName() + ": " + e.getMessage(); } + if (hideBannedProjectSkips) { + bannedSkipFilter.flush(log::accept); + } projects.values().stream().flatMap(p -> p.log.stream()).forEach(log); clearDisplay(); try { diff --git a/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulator.java b/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulator.java index 33759bd40..9a9466825 100644 --- a/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulator.java +++ b/surefire-progress/src/main/java/org/mvndaemon/mvnd/forknode/TestProgressAccumulator.java @@ -288,7 +288,7 @@ private boolean isFailed() { private String displayName() { if (testClass == null) { - return testMethod; + return testMethod != null ? testMethod : "(unknown)"; } String simpleClass = testClass.substring(testClass.lastIndexOf('.') + 1); return testMethod != null ? simpleClass + "#" + testMethod : simpleClass; From c2874cf47032cf33cc33c8f624fda39f4f4b7776 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:52:55 -0400 Subject: [PATCH 10/12] Update dist/src/main/distro/bin/mvnd-bash-completion.bash Co-authored-by: Guillaume Nodet --- dist/src/main/distro/bin/mvnd-bash-completion.bash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dist/src/main/distro/bin/mvnd-bash-completion.bash b/dist/src/main/distro/bin/mvnd-bash-completion.bash index 0d581d623..bf0e343cb 100755 --- a/dist/src/main/distro/bin/mvnd-bash-completion.bash +++ b/dist/src/main/distro/bin/mvnd-bash-completion.bash @@ -218,7 +218,7 @@ _mvnd() local mvnd_opts="-1" local mvnd_long_opts="--color|--completion|--diag|--purge|--serial|--status|--stop" - local mvnd_properties="-Djava.home|-Djdk.java.options|-Dmaven.multiModuleProjectDirectory|-Dmaven.repo.local|-Dmaven.settings|-Dmaven.style.color|-Dmvnd.buildTime|-Dmvnd.builder|-Dmvnd.cancelConnectTimeout|-Dmvnd.connectTimeout|-Dmvnd.coreExtensionsExclude|-Dmvnd.daemonStorage|-Dmvnd.debug|-Dmvnd.debug.address|-Dmvnd.duplicateDaemonGracePeriod|-Dmvnd.enableAssertions|-Dmvnd.expirationCheckDelay|-Dmvnd.home|-Dmvnd.idleTimeout|-Dmvnd.jvmArgs|-Dmvnd.keepAlive|-Dmvnd.logPurgePeriod|-Dmvnd.maxHeapSize|-Dmvnd.maxLostKeepAlive|-Dmvnd.minHeapSize|-Dmvnd.minThreads|-Dmvnd.noBuffering|-Dmvnd.noDaemon|-Dmvnd.noModelCache|-Dmvnd.pluginRealmEvictPattern|-Dmvnd.propertiesPath|-Dmvnd.registry|-Dmvnd.rollingWindowSize|-Dmvnd.serial|-Dmvnd.socketConnectTimeout|-Dmvnd.socketFamily|-Dmvnd.testProgress|-Dmvnd.threadStackSize|-Dmvnd.threads|-Duser.dir|-Duser.home" + local mvnd_properties="-Djava.home|-Djdk.java.options|-Dmaven.multiModuleProjectDirectory|-Dmaven.repo.local|-Dmaven.settings|-Dmaven.style.color|-Dmvnd.buildTime|-Dmvnd.builder|-Dmvnd.cancelConnectTimeout|-Dmvnd.connectTimeout|-Dmvnd.coreExtensionsExclude|-Dmvnd.daemonStorage|-Dmvnd.debug|-Dmvnd.debug.address|-Dmvnd.duplicateDaemonGracePeriod|-Dmvnd.enableAssertions|-Dmvnd.expirationCheckDelay|-Dmvnd.hideBannedProjectSkips|-Dmvnd.home|-Dmvnd.idleTimeout|-Dmvnd.jvmArgs|-Dmvnd.keepAlive|-Dmvnd.logPurgePeriod|-Dmvnd.maxHeapSize|-Dmvnd.maxLostKeepAlive|-Dmvnd.minHeapSize|-Dmvnd.minThreads|-Dmvnd.noBuffering|-Dmvnd.noDaemon|-Dmvnd.noModelCache|-Dmvnd.pluginRealmEvictPattern|-Dmvnd.propertiesPath|-Dmvnd.registry|-Dmvnd.rollingWindowSize|-Dmvnd.serial|-Dmvnd.socketConnectTimeout|-Dmvnd.socketFamily|-Dmvnd.testProgress|-Dmvnd.threadStackSize|-Dmvnd.threads|-Duser.dir|-Duser.home" local opts="-am|-amd|-B|-C|-c|-cpu|-D|-e|-emp|-ep|-f|-fae|-ff|-fn|-gs|-h|-l|-N|-npr|-npu|-nsu|-o|-P|-pl|-q|-rf|-s|-T|-t|-U|-up|-V|-v|-X|${mvnd_opts}" local long_opts="--also-make|--also-make-dependents|--batch-mode|--strict-checksums|--lax-checksums|--check-plugin-updates|--define|--errors|--encrypt-master-password|--encrypt-password|--file|--fail-at-end|--fail-fast|--fail-never|--global-settings|--help|--log-file|--non-recursive|--no-plugin-registry|--no-plugin-updates|--no-snapshot-updates|--offline|--activate-profiles|--projects|--quiet|--resume-from|--settings|--threads|--toolchains|--update-snapshots|--update-plugins|--show-version|--version|--debug|${mvnd_long_opts}" From dd970bc8d8a239c262dd48d2fe17e9b7426c9720 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:54:15 -0400 Subject: [PATCH 11/12] fix: route test summary through client Send daemon-rendered test summaries through ClientDispatcher so clients receive the failure sections. Keep the integration test focused on the observable protocol ordering. Co-Authored-By: Codex --- .../daemon/TestSummaryExecutionListener.java | 23 +++---------------- .../mvnd/it/TestProgressFailureTest.java | 7 +----- 2 files changed, 4 insertions(+), 26 deletions(-) diff --git a/daemon/src/main/java/org/mvndaemon/mvnd/daemon/TestSummaryExecutionListener.java b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/TestSummaryExecutionListener.java index fb239b929..39d82028f 100644 --- a/daemon/src/main/java/org/mvndaemon/mvnd/daemon/TestSummaryExecutionListener.java +++ b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/TestSummaryExecutionListener.java @@ -23,21 +23,14 @@ import org.apache.maven.execution.ExecutionEvent; import org.apache.maven.execution.ExecutionListener; import org.mvndaemon.mvnd.logging.smart.TestBuildSummary; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Decorates the {@link ExecutionListener} chain Maven 4 builds in {@code MavenInvoker.determineExecutionListener} * (wired up by {@link DaemonMavenInvoker#determineExecutionListener}) to fold per-project test-progress snapshots - * into the reactor-wide {@link TestBuildSummary} and log it through this class's own SLF4J logger immediately - * before {@code delegate.sessionEnded} prints the Reactor Summary. Routing through SLF4J means ANSI coloring and - * {@code -q} log-level gating come from the normal Maven logging pipeline instead of being reimplemented - * client-side, matching how every other Maven console line is rendered. + * into the reactor-wide {@link TestBuildSummary} and send it to the client immediately before + * {@code delegate.sessionEnded} prints the Reactor Summary. */ public class TestSummaryExecutionListener implements ExecutionListener { - - private static final Logger LOGGER = LoggerFactory.getLogger("org.mvndaemon.mvnd.testsummary"); - private final ExecutionListener delegate; private final ClientDispatcher clientDispatcher; @@ -65,17 +58,7 @@ private void emitTestSummary() { List lines = clientDispatcher.getTestSummary().renderLines(); for (TestBuildSummary.SummaryLine line : lines) { - switch (line.level) { - case ERROR: - LOGGER.error(line.text); - break; - case WARNING: - LOGGER.warn(line.text); - break; - default: - LOGGER.info(line.text); - break; - } + clientDispatcher.log(line.text); } } diff --git a/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressFailureTest.java b/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressFailureTest.java index 5325dd538..9416134ce 100644 --- a/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressFailureTest.java +++ b/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressFailureTest.java @@ -67,14 +67,9 @@ void reportsFailedAndErroredTestsWithMessages() throws InterruptedException { int failuresLine = indexOfLineContaining(logLines, "Failures:"); int errorsLine = indexOfLineContaining(logLines, "Errors:"); - // test-progress-failure is a single-module fixture, so Maven never prints a "Reactor Summary" section; - // "BUILD FAILURE" is the banner that is always emitted, so it is the anchor used here instead. - int buildFailureLine = indexOfLineContaining(logLines, "BUILD FAILURE"); assertTrue(failuresLine >= 0, "expected a daemon-emitted log line containing 'Failures:', got: " + logLines); assertTrue(errorsLine >= 0, "expected a daemon-emitted log line containing 'Errors:', got: " + logLines); - assertTrue(buildFailureLine >= 0, "expected a 'BUILD FAILURE' log line, got: " + logLines); - assertTrue(failuresLine < buildFailureLine, "the test summary must be logged before the BUILD FAILURE banner"); - assertTrue(errorsLine < buildFailureLine, "the test summary must be logged before the BUILD FAILURE banner"); + assertTrue(failuresLine < errorsLine, "failure sections must be emitted in Surefire order"); } private static int indexOfLineContaining(List lines, String needle) { From 605705e850cf46fbd0fde64957ef70115aae399a Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:54:23 -0400 Subject: [PATCH 12/12] fix: make ANSI ESC bytes explicit in stripDecoration and its tests The SGR pattern in TerminalOutput and the strings in TerminalOutputTest embedded a raw ESC (0x1b) byte. That is invisible in diffs and review tooling, and can be silently dropped by anything that normalizes control characters. Replace the raw bytes with unicode escapes; the input matched at runtime is unchanged. Adds two assertions: a bare "[1m" without the ESC prefix is literal text and must survive stripping, and a color-wrapped banned marker must still strip down to BANNED_MARKER so BannedSkipFilter matches it. Co-Authored-By: Claude Opus 5 --- .../mvndaemon/mvnd/common/logging/TerminalOutput.java | 2 +- .../mvnd/common/logging/TerminalOutputTest.java | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/common/src/main/java/org/mvndaemon/mvnd/common/logging/TerminalOutput.java b/common/src/main/java/org/mvndaemon/mvnd/common/logging/TerminalOutput.java index 13b2d1f8a..df2cb6c88 100644 --- a/common/src/main/java/org/mvndaemon/mvnd/common/logging/TerminalOutput.java +++ b/common/src/main/java/org/mvndaemon/mvnd/common/logging/TerminalOutput.java @@ -863,7 +863,7 @@ private void addProjectLine(final List lines, Project prj) { } /** Matches SGR (color) escape sequences emitted by the daemon-side log renderer. */ - private static final Pattern ANSI = Pattern.compile("\\[[0-9;]*m"); + private static final Pattern ANSI = Pattern.compile("\\u001b\\[[0-9;]*m"); /** Matches a leading {@code [LEVEL] } prefix such as {@code [INFO] } or {@code [ERROR] }. */ private static final Pattern LEVEL_PREFIX = Pattern.compile("^\\[[A-Z]+\\]\\s?"); diff --git a/common/src/test/java/org/mvndaemon/mvnd/common/logging/TerminalOutputTest.java b/common/src/test/java/org/mvndaemon/mvnd/common/logging/TerminalOutputTest.java index 2be185bd2..05af4aa2d 100644 --- a/common/src/test/java/org/mvndaemon/mvnd/common/logging/TerminalOutputTest.java +++ b/common/src/test/java/org/mvndaemon/mvnd/common/logging/TerminalOutputTest.java @@ -143,11 +143,18 @@ void hidesProjectDetailsForLargeFailingReactors() { @Test void stripDecorationRemovesLevelPrefixAndAnsi() { assertEquals("BUILD FAILURE", TerminalOutput.stripDecoration("[INFO] BUILD FAILURE")); - assertEquals("BUILD FAILURE", TerminalOutput.stripDecoration("[INFO] BUILD FAILURE")); + assertEquals("BUILD FAILURE", TerminalOutput.stripDecoration("[INFO] \u001b[1mBUILD FAILURE\u001b[m")); + // A bare "[1m" is literal text, not an SGR sequence: only an ESC-prefixed one may be stripped. + assertEquals("[1mBUILD FAILURE", TerminalOutput.stripDecoration("[INFO] [1mBUILD FAILURE")); assertEquals( "This project has been banned from the build due to previous failures.", TerminalOutput.stripDecoration( "[INFO] This project has been banned from the build due to previous failures.")); + // Colored reactor output must still strip down to the bare marker BannedSkipFilter matches on. + assertEquals( + "This project has been banned from the build due to previous failures.", + TerminalOutput.stripDecoration( + "[INFO] \u001b[1mThis project has been banned from the build due to previous failures.\u001b[m")); } @Test