diff --git a/.gitignore b/.gitignore
index 335a899a5..845398233 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,7 @@ dependency-reduced-pom.xml
.project
.classpath
.settings/
+.factorypath
# IDEA
.idea
@@ -31,4 +32,4 @@ nb-configuration.xml
.cache/
# https://ge.apache.org
-.mvn/.gradle-enterprise
\ No newline at end of file
+.mvn/.gradle-enterprise
diff --git a/README.adoc b/README.adoc
index f0935afb9..f20b52db9 100644
--- a/README.adoc
+++ b/README.adoc
@@ -55,8 +55,8 @@ If SDKMAN! supports your operating system, it is as easy as
$ sdk install mvnd
----
-If you used the manual install in the past, please make sure that the settings in `~/.m2/mvnd.properties` still make
-sense. With SDKMAN!, the `~/.m2/mvnd.properties` file is typically not needed at all, because both `JAVA_HOME` and
+If you used the manual installation in the past, please make sure that the settings in `~/.m2/mvnd.properties` still
+make sense. With SDKMAN!, the `~/.m2/mvnd.properties` file is typically not needed at all, because both `JAVA_HOME` and
`MVND_HOME` are managed by SDKMAN!.
=== Install using https://brew.sh/[Homebrew]
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 1737d2aef..3613a6f28 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/common/Message.java b/common/src/main/java/org/mvndaemon/mvnd/common/Message.java
index 459f771e2..ae197e09f 100644
--- a/common/src/main/java/org/mvndaemon/mvnd/common/Message.java
+++ b/common/src/main/java/org/mvndaemon/mvnd/common/Message.java
@@ -65,6 +65,12 @@ public abstract class Message {
public static final int PRINT_ERR = 26;
public static final int REQUEST_INPUT = 27;
public static final int INPUT_DATA = 28;
+ /**
+ * 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).
+ */
+ public static final int PROJECT_TEST_PROGRESS = 29;
final int type;
@@ -89,6 +95,8 @@ public static Message read(DataInputStream input) throws IOException {
case PROJECT_LOG_MESSAGE:
case DISPLAY:
return ProjectEvent.read(type, input);
+ case PROJECT_TEST_PROGRESS:
+ return ProjectTestProgressEvent.read(input);
case BUILD_EXCEPTION:
return BuildException.read(input);
case KEEP_ALIVE:
@@ -145,6 +153,7 @@ public static int getClassOrder(Message m) {
case PROMPT:
case PROMPT_RESPONSE:
case DISPLAY:
+ case PROJECT_TEST_PROGRESS:
case PRINT_OUT:
case PRINT_ERR:
case REQUEST_INPUT:
@@ -234,6 +243,17 @@ static Map readStringMap(DataInputStream input) throws IOExcepti
private static final int UTF_BUFS_BYTE_CNT = UTF_BUFS_CHAR_CNT * 3;
private static final ThreadLocal BUF_TLS = ThreadLocal.withInitial(() -> new byte[UTF_BUFS_BYTE_CNT]);
+ static void writeNullableUTF(DataOutputStream output, String value) throws IOException {
+ output.writeBoolean(value != null);
+ if (value != null) {
+ writeUTF(output, value);
+ }
+ }
+
+ static String readNullableUTF(DataInputStream input) throws IOException {
+ return input.readBoolean() ? readUTF(input) : null;
+ }
+
static String readUTF(DataInputStream input) throws IOException {
byte[] byteBuf = BUF_TLS.get();
int len = input.readInt();
@@ -615,6 +635,103 @@ public void write(DataOutputStream output) throws IOException {
}
}
+ public static class ProjectTestProgressEvent extends Message {
+ final String projectId;
+ final String testClass;
+ final String testMethod;
+ final int completed;
+ final int failures;
+ final int errors;
+ final int skipped;
+
+ public static ProjectTestProgressEvent read(DataInputStream input) throws IOException {
+ final String projectId = readUTF(input);
+ final String testClass = readNullableUTF(input);
+ final String testMethod = readNullableUTF(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);
+ }
+
+ public ProjectTestProgressEvent(
+ String projectId,
+ String testClass,
+ String testMethod,
+ int completed,
+ int failures,
+ int errors,
+ int skipped) {
+ super(PROJECT_TEST_PROGRESS);
+ this.projectId = Objects.requireNonNull(projectId, "projectId cannot be null");
+ this.testClass = testClass;
+ this.testMethod = testMethod;
+ this.completed = completed;
+ this.failures = failures;
+ this.errors = errors;
+ this.skipped = skipped;
+ }
+
+ public String getProjectId() {
+ return projectId;
+ }
+
+ public String getTestClass() {
+ return testClass;
+ }
+
+ public String getTestMethod() {
+ return testMethod;
+ }
+
+ public int getCompleted() {
+ return completed;
+ }
+
+ public int getFailures() {
+ return failures;
+ }
+
+ public int getErrors() {
+ return errors;
+ }
+
+ public int getSkipped() {
+ return skipped;
+ }
+
+ @Override
+ public void write(DataOutputStream output) throws IOException {
+ super.write(output);
+ writeUTF(output, projectId);
+ writeNullableUTF(output, testClass);
+ writeNullableUTF(output, testMethod);
+ output.writeInt(completed);
+ output.writeInt(failures);
+ output.writeInt(errors);
+ output.writeInt(skipped);
+ }
+
+ @Override
+ public String toString() {
+ return "ProjectTestProgress{projectId='" + projectId + "', testClass='" + testClass + "', testMethod='"
+ + testMethod + "', completed=" + completed + ", failures=" + failures + ", errors=" + errors
+ + ", skipped=" + skipped + "}";
+ }
+ }
+
+ public static ProjectTestProgressEvent projectTestProgress(
+ String projectId,
+ String testClass,
+ String testMethod,
+ int completed,
+ int failures,
+ int errors,
+ int skipped) {
+ return new ProjectTestProgressEvent(projectId, testClass, testMethod, completed, failures, errors, skipped);
+ }
+
public static class BuildStarted extends Message {
final String projectId;
@@ -993,8 +1110,8 @@ public String toString() {
+ repositoryUrl + '\'' + ", resourceName='"
+ resourceName + '\'' + ", contentLength="
+ contentLength + ", transferredBytes="
- + transferredBytes + ", exception='"
- + exception + '\'' + '}';
+ + transferredBytes
+ + (exception != null ? ", exception='" + exception + '\'' : "") + '}';
}
private String mnemonic() {
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 9f974f44c..f64f25a22 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
@@ -94,6 +94,8 @@ public class TerminalOutput implements ClientOutput {
private static final AttributedStyle GREEN_FOREGROUND = new AttributedStyle().foreground(AttributedStyle.GREEN);
private static final AttributedStyle CYAN_FOREGROUND = new AttributedStyle().foreground(AttributedStyle.CYAN);
+ private static final AttributedStyle BOLD_GREEN_FOREGROUND =
+ new AttributedStyle().bold().foreground(AttributedStyle.GREEN);
private final Terminal terminal;
private final Terminal.SignalHandler previousIntHandler;
@@ -147,6 +149,7 @@ public class TerminalOutput implements ClientOutput {
static class Project {
final String id;
MojoStartedEvent runningExecution;
+ Message.ProjectTestProgressEvent testProgress;
final List log = new ArrayList<>();
public Project(String id) {
@@ -279,6 +282,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;
break;
}
case Message.PROJECT_STOPPED: {
@@ -463,6 +467,14 @@ private boolean doAccept(Message entry) {
daemonDispatch.accept(entry);
break;
}
+ case Message.PROJECT_TEST_PROGRESS: {
+ final Message.ProjectTestProgressEvent e = (Message.ProjectTestProgressEvent) entry;
+ final Project prj = projects.get(e.getProjectId());
+ if (prj != null) {
+ prj.testProgress = e;
+ }
+ break;
+ }
default:
throw new IllegalStateException("Unexpected message " + entry);
}
@@ -626,8 +638,16 @@ private void update() {
lines.addAll(logs);
remLogLines -= logs.size();
}
- while (remLogLines-- > 0 && lines.size() <= maxThreads + 1) {
- lines.add(AttributedString.EMPTY);
+ 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;
@@ -753,10 +773,41 @@ public static String pathToMaven(String location) {
return location;
}
+ static String renderBar(int percent) {
+ final int width = 20;
+ int filled = (int) Math.round(percent / 100.0 * width);
+ StringBuilder sb = new StringBuilder(width + 2);
+ sb.append('[');
+ if (filled >= width) {
+ for (int i = 0; i < width; i++) {
+ sb.append('=');
+ }
+ } else if (filled > 0) {
+ for (int i = 0; i < filled - 1; i++) {
+ sb.append('=');
+ }
+ sb.append('>');
+ for (int i = 0; i < width - filled; i++) {
+ sb.append(' ');
+ }
+ } else {
+ for (int i = 0; i < width; i++) {
+ sb.append(' ');
+ }
+ }
+ sb.append(']');
+ return sb.toString();
+ }
+
private void addStatusLine(final List lines, int dispLines, final int projectsCount) {
if (name != null || buildStatus != null) {
AttributedStringBuilder asb = new AttributedStringBuilder();
if (name != null) {
+ int percent = doneProjects * 100 / totalProjects;
+ asb.append(renderBar(percent))
+ .append(' ')
+ .append(String.format("%3d", percent))
+ .append("% ");
asb.append("Building ");
asb.style(AttributedStyle.BOLD);
asb.append(name);
@@ -788,9 +839,6 @@ private void addStatusLine(final List lines, int dispLines, fi
.append(String.format(projectsDoneFomat, doneProjects))
.append('/')
.append(String.valueOf(totalProjects))
- .append(' ')
- .append(String.format("%3d", doneProjects * 100 / totalProjects))
- .append('%')
.style(AttributedStyle.DEFAULT);
} else if (buildStatus != null) {
@@ -811,38 +859,66 @@ private void addStatusLine(final List lines, int dispLines, fi
private void addProjectLine(final List lines, Project prj) {
final MojoStartedEvent execution = prj.runningExecution;
final AttributedStringBuilder asb = new AttributedStringBuilder();
+ asb.style(BOLD_GREEN_FOREGROUND).append("> ").style(AttributedStyle.DEFAULT);
AttributedString transfer = formatTransfers(prj.id);
if (transfer != null) {
- asb.append(':')
- .style(CYAN_FOREGROUND)
+ asb.style(CYAN_FOREGROUND)
+ .append(':')
.append(String.format(artifactIdFormat, prj.id))
.style(AttributedStyle.DEFAULT)
.append(transfer);
} else if (execution == null) {
- asb.append(':').style(CYAN_FOREGROUND).append(prj.id);
+ asb.style(CYAN_FOREGROUND).append(':').append(prj.id).style(AttributedStyle.DEFAULT);
} else {
- asb.append(':')
- .style(CYAN_FOREGROUND)
+ asb.style(CYAN_FOREGROUND)
+ .append(':')
.append(String.format(artifactIdFormat, prj.id))
.style(GREEN_FOREGROUND);
if (execution.getPluginGoalPrefix().isEmpty()) {
- asb.append(execution.getPluginGroupId()).append(':').append(execution.getPluginArtifactId());
+ asb.append(execution.getPluginArtifactId());
} else {
asb.append(execution.getPluginGoalPrefix());
}
asb.append(':')
- .append(execution.getPluginVersion())
- .append(':')
.append(execution.getMojo())
.append(' ')
.style(AttributedStyle.DEFAULT)
.append('(')
.append(execution.getExecutionId())
.append(')');
+ final Message.ProjectTestProgressEvent tp = prj.testProgress;
+ if (tp != null) {
+ appendTestProgress(asb, tp);
+ }
}
lines.add(asb.toAttributedString());
}
+ static void appendTestProgress(AttributedStringBuilder asb, Message.ProjectTestProgressEvent tp) {
+ final AttributedStyle faint = AttributedStyle.DEFAULT.faint();
+ final AttributedStyle red = AttributedStyle.DEFAULT.foreground(AttributedStyle.RED);
+ 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()));
+ }
+ if (tp.getErrors() > 0) {
+ asb.style(faint).append(", Errors: ").style(red).append(String.valueOf(tp.getErrors()));
+ }
+ if (tp.getSkipped() > 0) {
+ asb.style(faint).append(", Skipped: ").append(String.valueOf(tp.getSkipped()));
+ }
+ asb.style(faint).append("]");
+ final String testClass = tp.getTestClass();
+ if (testClass != null) {
+ final String simple = testClass.substring(testClass.lastIndexOf('.') + 1);
+ asb.append(' ').append(simple);
+ if (tp.getTestMethod() != null) {
+ asb.append('#').append(tp.getTestMethod());
+ }
+ }
+ asb.style(AttributedStyle.DEFAULT);
+ }
+
private static List lastN(List list, int n) {
return list.subList(Math.max(0, list.size() - n), list.size());
}
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();
+ }
+}
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 907f8ff1e..e876875c6 100644
--- a/common/src/test/java/org/mvndaemon/mvnd/common/MessageTest.java
+++ b/common/src/test/java/org/mvndaemon/mvnd/common/MessageTest.java
@@ -73,4 +73,44 @@ void buildExceptionSerialization() throws Exception {
assertTrue(msg2 instanceof Message.BuildException);
assertNull(((Message.BuildException) msg2).getMessage());
}
+
+ @Test
+ void projectTestProgressSerialization() throws IOException {
+ Message msg = Message.projectTestProgress("my-app", "com.acme.FooTest", "shouldWork", 3, 1, 0, 1);
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (DataOutputStream daos = new DataOutputStream(baos)) {
+ msg.write(daos);
+ }
+ Message msg2;
+ try (DataInputStream dis = new DataInputStream(new ByteArrayInputStream(baos.toByteArray()))) {
+ msg2 = Message.read(dis);
+ }
+
+ assertTrue(msg2 instanceof Message.ProjectTestProgressEvent);
+ Message.ProjectTestProgressEvent e = (Message.ProjectTestProgressEvent) msg2;
+ assertEquals("my-app", e.getProjectId());
+ 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());
+ }
+
+ @Test
+ void projectTestProgressNullClassAndMethod() throws IOException {
+ Message msg = Message.projectTestProgress("my-app", null, null, 0, 0, 0, 0);
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (DataOutputStream daos = new DataOutputStream(baos)) {
+ msg.write(daos);
+ }
+ Message msg2;
+ try (DataInputStream dis = new DataInputStream(new ByteArrayInputStream(baos.toByteArray()))) {
+ msg2 = Message.read(dis);
+ }
+ Message.ProjectTestProgressEvent e = (Message.ProjectTestProgressEvent) msg2;
+ assertNull(e.getTestClass());
+ assertNull(e.getTestMethod());
+ }
}
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
new file mode 100644
index 000000000..e517b16e9
--- /dev/null
+++ b/common/src/test/java/org/mvndaemon/mvnd/common/logging/TerminalOutputTest.java
@@ -0,0 +1,85 @@
+/*
+ * 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.common.logging;
+
+import org.jline.utils.AttributedString;
+import org.jline.utils.AttributedStringBuilder;
+import org.jline.utils.AttributedStyle;
+import org.junit.jupiter.api.Test;
+import org.mvndaemon.mvnd.common.Message;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class TerminalOutputTest {
+
+ @Test
+ void renderBarZero() {
+ assertEquals("[ ]", TerminalOutput.renderBar(0));
+ }
+
+ @Test
+ void renderBarPartial() {
+ // 30% -> filled = round(6.0) = 6 -> 5 '=' + '>' + 14 spaces
+ assertEquals("[=====> ]", TerminalOutput.renderBar(30));
+ }
+
+ @Test
+ void renderBarFull() {
+ assertEquals("[====================]", TerminalOutput.renderBar(100));
+ }
+
+ @Test
+ void suffixAllPassing() {
+ AttributedStringBuilder asb = new AttributedStringBuilder();
+ TerminalOutput.appendTestProgress(
+ asb, Message.projectTestProgress("app", "com.acme.FooTest", "shouldWork", 12, 0, 0, 0));
+ assertEquals(" [Tests: 12] FooTest#shouldWork", asb.toAttributedString().toString());
+ }
+
+ @Test
+ void suffixFailuresRenderRed() {
+ AttributedStringBuilder asb = new AttributedStringBuilder();
+ TerminalOutput.appendTestProgress(
+ asb, Message.projectTestProgress("app", "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();
+ assertEquals(AttributedStyle.DEFAULT.foreground(AttributedStyle.RED), s.styleAt(failureDigit));
+ int bracket = s.toString().indexOf('[');
+ assertEquals(AttributedStyle.DEFAULT.faint(), s.styleAt(bracket));
+ }
+
+ @Test
+ void suffixErrorsAndSkips() {
+ AttributedStringBuilder asb = new AttributedStringBuilder();
+ TerminalOutput.appendTestProgress(
+ asb, Message.projectTestProgress("app", "com.acme.FooTest", "shouldWork", 5, 0, 2, 1));
+ assertEquals(
+ " [Tests: 5, Errors: 2, Skipped: 1] FooTest#shouldWork",
+ asb.toAttributedString().toString());
+ }
+
+ @Test
+ void suffixClassOnly() {
+ AttributedStringBuilder asb = new AttributedStringBuilder();
+ TerminalOutput.appendTestProgress(
+ asb, Message.projectTestProgress("app", "com.acme.FooTest", null, 3, 0, 0, 0));
+ assertEquals(" [Tests: 3] FooTest", asb.toAttributedString().toString());
+ }
+}
diff --git a/daemon/pom.xml b/daemon/pom.xml
index d8f0a8ee4..dbf01e692 100644
--- a/daemon/pom.xml
+++ b/daemon/pom.xml
@@ -46,6 +46,10 @@
+
+ org.apache.maven.daemon
+ mvnd-surefire-progress
+
org.apache.maven.daemon
mvnd-native
@@ -58,6 +62,14 @@
org.apache.maven
maven-core
+
+
+ org.codehaus.plexus
+ plexus-xml
+ 4.0.4
+ provided
+
org.apache.maven
maven-embedder
diff --git a/daemon/src/main/java/org/apache/maven/cli/DaemonMavenCli.java b/daemon/src/main/java/org/apache/maven/cli/DaemonMavenCli.java
index ba5684792..fa4fbb215 100644
--- a/daemon/src/main/java/org/apache/maven/cli/DaemonMavenCli.java
+++ b/daemon/src/main/java/org/apache/maven/cli/DaemonMavenCli.java
@@ -682,6 +682,7 @@ DefaultPlexusContainer doCreateContainer(CliRequest cliRequest) throws Exception
}
exportedPackages.add("org.codehaus.plexus.components.interactivity");
exportedPackages.add("org.mvndaemon.mvnd.interactivity");
+ exportedPackages.add("org.mvndaemon.mvnd.testprogress");
exportedArtifacts.add("org.codehaus.plexus:plexus-interactivity-api");
final CoreExports exports = new CoreExports(containerRealm, exportedArtifacts, exportedPackages);
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/ClientDispatcher.java b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/ClientDispatcher.java
index f9bb805c1..ec1459cd3 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/MvndTestProgressLifecycleParticipant.java b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/MvndTestProgressLifecycleParticipant.java
new file mode 100644
index 000000000..f129b7ed6
--- /dev/null
+++ b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/MvndTestProgressLifecycleParticipant.java
@@ -0,0 +1,122 @@
+/*
+ * 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.AbstractMavenLifecycleParticipant;
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.model.Plugin;
+import org.apache.maven.model.PluginExecution;
+import org.apache.maven.project.MavenProject;
+import org.codehaus.plexus.util.xml.Xpp3Dom;
+import org.mvndaemon.mvnd.common.Environment;
+
+/**
+ * Under Maven 3.9, {@code afterProjectsRead} model mutation is honored by Surefire, so this participant injects an
+ * mvnd {@code } into surefire {@code maven-surefire-plugin} and {@code maven-failsafe-plugin}
+ * configurations. Surefire then loads {@code MvndForkNodeFactory} (added to its plugin realm by
+ * {@link org.mvndaemon.mvnd.cache.invalidating.InvalidatingPluginRealmCache}) and reports per-test events tagged
+ * with the injected {@code }. Skips projects where the user already configured a {@code } or
+ * when the feature is disabled.
+ */
+@Named
+@Singleton
+public class MvndTestProgressLifecycleParticipant extends AbstractMavenLifecycleParticipant {
+
+ private static final String FORK_NODE_IMPL = "org.mvndaemon.mvnd.forknode.MvndForkNodeFactory";
+ private static final String SUREFIRE_KEY = "org.apache.maven.plugins:maven-surefire-plugin";
+ private static final String FAILSAFE_KEY = "org.apache.maven.plugins:maven-failsafe-plugin";
+
+ @Override
+ public void afterProjectsRead(MavenSession session) {
+ if (!isEnabled()) {
+ return;
+ }
+ for (MavenProject project : session.getProjects()) {
+ injectForPlugin(project, SUREFIRE_KEY);
+ injectForPlugin(project, FAILSAFE_KEY);
+ }
+ }
+
+ private void injectForPlugin(MavenProject project, String pluginKey) {
+ if (project.getBuild() == null) {
+ return;
+ }
+ Plugin plugin = project.getBuild().getPluginsAsMap().get(pluginKey);
+ if (plugin == null) {
+ return;
+ }
+ if (!supportsForkNode(plugin.getVersion())) {
+ // Never inject into a Surefire/Failsafe that cannot load the fork-node SPI; it would fail the build.
+ return;
+ }
+ String projectId = project.getArtifactId();
+ plugin.setConfiguration(withForkNode((Xpp3Dom) plugin.getConfiguration(), projectId));
+ for (PluginExecution execution : plugin.getExecutions()) {
+ execution.setConfiguration(withForkNode((Xpp3Dom) execution.getConfiguration(), projectId));
+ }
+ }
+
+ private Xpp3Dom withForkNode(Xpp3Dom config, String projectId) {
+ if (config == null) {
+ config = new Xpp3Dom("configuration");
+ }
+ if (config.getChild("forkNode") != null) {
+ return config; // respect a user-configured fork node
+ }
+ Xpp3Dom forkNode = new Xpp3Dom("forkNode");
+ forkNode.setAttribute("implementation", FORK_NODE_IMPL);
+ Xpp3Dom pid = new Xpp3Dom("projectId");
+ pid.setValue(projectId);
+ forkNode.addChild(pid);
+ config.addChild(forkNode);
+ return config;
+ }
+
+ private static boolean isEnabled() {
+ return Environment.MVND_TEST_PROGRESS
+ .asOptional()
+ .map(Boolean::parseBoolean)
+ .orElse(Boolean.TRUE);
+ }
+
+ /**
+ * The {@code forkNode} extension SPI ({@code ForkNodeFactory} / {@code SurefireForkNodeFactory}) exists only in
+ * Surefire {@code >= 3.0.0-M5}. Injecting {@code } into anything older makes the build fail hard
+ * ("unknown parameter forkNode" or a missing implementation class), so guard on the resolved plugin version.
+ */
+ 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/main/java/org/mvndaemon/mvnd/daemon/Server.java b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/Server.java
index 819f9fbce..de3e650c5 100644
--- a/daemon/src/main/java/org/mvndaemon/mvnd/daemon/Server.java
+++ b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/Server.java
@@ -70,6 +70,7 @@
import org.mvndaemon.mvnd.logging.smart.BuildEventListener;
import org.mvndaemon.mvnd.logging.smart.LoggingOutputStream;
import org.mvndaemon.mvnd.logging.smart.ProjectBuildLogAppender;
+import org.mvndaemon.mvnd.testprogress.MvndTestProgress;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -510,6 +511,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 -> sendQueue.add(Message.requestInput(projectId)));
try (ProjectBuildLogAppender logAppender = new ProjectBuildLogAppender(buildEventListener)) {
@@ -643,6 +654,7 @@ public T request(Message request, Class responseType, Pre
} catch (Throwable t) {
LOGGER.error("Error while building project", t);
} finally {
+ MvndTestProgress.setListener(null);
if (!noDaemon) {
LOGGER.info("Daemon back to idle");
updateState(DaemonState.Idle);
diff --git a/daemon/src/test/java/org/mvndaemon/mvnd/daemon/MvndTestProgressLifecycleParticipantTest.java b/daemon/src/test/java/org/mvndaemon/mvnd/daemon/MvndTestProgressLifecycleParticipantTest.java
new file mode 100644
index 000000000..2f953085b
--- /dev/null
+++ b/daemon/src/test/java/org/mvndaemon/mvnd/daemon/MvndTestProgressLifecycleParticipantTest.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 MvndTestProgressLifecycleParticipantTest {
+
+ @Test
+ void injectsOnlyForSurefireVersionsThatSupportForkNode() {
+ // forkNode SPI exists since 3.0.0-M5 -> anything older must be skipped so the build never fails
+ assertFalse(MvndTestProgressLifecycleParticipant.supportsForkNode(null));
+ assertFalse(MvndTestProgressLifecycleParticipant.supportsForkNode("2.22.2"));
+ assertFalse(MvndTestProgressLifecycleParticipant.supportsForkNode("3.0.0-M4"));
+
+ assertTrue(MvndTestProgressLifecycleParticipant.supportsForkNode("3.0.0-M5"));
+ assertTrue(MvndTestProgressLifecycleParticipant.supportsForkNode("3.0.0-M8"));
+ assertTrue(MvndTestProgressLifecycleParticipant.supportsForkNode("3.5.6"));
+ assertTrue(MvndTestProgressLifecycleParticipant.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 692534a07..2256ba5c8 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|--raw-streams|--serial|--status|--stop"
- local mvnd_properties="-Djava.home|-Djdk.java.options|-Dmaven.multiModuleProjectDirectory|-Dmaven.repo.local|-Dmaven.settings|-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.rawStreams|-Dmvnd.registry|-Dmvnd.rollingWindowSize|-Dmvnd.serial|-Dmvnd.socketConnectTimeout|-Dmvnd.socketFamily|-Dmvnd.threadStackSize|-Dmvnd.threads|-Dstyle.color|-Duser.dir|-Duser.home"
+ local mvnd_properties="-Djava.home|-Djdk.java.options|-Dmaven.multiModuleProjectDirectory|-Dmaven.repo.local|-Dmaven.settings|-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.rawStreams|-Dmvnd.registry|-Dmvnd.rollingWindowSize|-Dmvnd.serial|-Dmvnd.socketConnectTimeout|-Dmvnd.socketFamily|-Dmvnd.testProgress|-Dmvnd.threadStackSize|-Dmvnd.threads|-Dstyle.color|-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 e7ff5d465..618c4c74b 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);
+ }
+}
diff --git a/pom.xml b/pom.xml
index 9e5cdd889..8071b3ba0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -49,6 +49,7 @@
agent
helper
common
+ surefire-progress
client
logging
daemon
@@ -250,6 +251,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..4bc79e9e9
--- /dev/null
+++ b/surefire-progress/pom.xml
@@ -0,0 +1,74 @@
+
+
+
+
+ 4.0.0
+
+ org.apache.maven.daemon
+ mvnd
+ 1.0.7-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());
+ }
+}