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()); + } +}