} 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";
+ private static final Pattern VERSION_PATTERN = Pattern.compile("^(\\d+)\\.(\\d+)\\.(\\d+)(?:-M(\\d+))?");
+
+ public MvndMojoExecutionConfigurator() {
+ super();
+ }
+
+ @Override
+ public void configure(MavenProject project, MojoExecution mojoExecution, boolean allowPluginLevelConfig) {
+ super.configure(project, mojoExecution, allowPluginLevelConfig);
+ 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;
+ }
+ 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);
+ }
+
+ /** 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)
+ .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;
+ }
+ Matcher m = VERSION_PATTERN.matcher(version);
+ if (!m.find()) {
+ return false;
+ }
+ int major = Integer.parseInt(m.group(1));
+ 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 3e386692c..ae290f600 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,36 @@ 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);
+ if (MvndMojoExecutionConfigurator.isTestProgressEnabled()) {
+ final ClientDispatcher clientDispatcher = (ClientDispatcher) buildEventListener;
+ 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)),
(projectId) -> sendQueue.add(Message.requestInputAvailable(projectId)));
@@ -649,6 +680,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");
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..39d82028f
--- /dev/null
+++ b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/TestSummaryExecutionListener.java
@@ -0,0 +1,139 @@
+/*
+ * 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;
+
+/**
+ * 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 send it to the client immediately before
+ * {@code delegate.sessionEnded} prints the Reactor Summary.
+ */
+public class TestSummaryExecutionListener implements ExecutionListener {
+ 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) {
+ clientDispatcher.log(line.text);
+ }
+ }
+
+ @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/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..792fc2912
--- /dev/null
+++ b/daemon/src/test/java/org/mvndaemon/mvnd/daemon/MvndMojoExecutionConfiguratorTest.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.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"));
+ }
+
+ @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/dist/src/main/distro/bin/mvnd-bash-completion.bash b/dist/src/main/distro/bin/mvnd-bash-completion.bash
index cce9a82f6..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.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}"
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/TestProgressFailureTest.java b/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressFailureTest.java
new file mode 100644
index 000000000..9416134ce
--- /dev/null
+++ b/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressFailureTest.java
@@ -0,0 +1,83 @@
+/*
+ * 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");
+
+ 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:");
+ 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(failuresLine < errorsLine, "failure sections must be emitted in Surefire order");
+ }
+
+ 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
new file mode 100644
index 000000000..4fae7aa7b
--- /dev/null
+++ b/integration-tests/src/test/java/org/mvndaemon/mvnd/it/TestProgressTest.java
@@ -0,0 +1,94 @@
+/*
+ * 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 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()
+ .flatMap(e -> e.getFlakyTests().stream())
+ .anyMatch(t -> t.startsWith("FlakyServiceTest#succeedsOnRetry")),
+ "expected the recovered test to be reported in the flaky test list");
+ }
+
+ @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-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
new file mode 100644
index 000000000..9b2156093
--- /dev/null
+++ b/integration-tests/src/test/projects/test-progress/pom.xml
@@ -0,0 +1,58 @@
+
+
+
+ 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}
+
+ 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/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);
+ }
+}
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