From 60b4f20abdd071018b7ec919c4638a08eff9d6ab Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:57:55 -0400 Subject: [PATCH 1/3] feat: visual improvements to worker lines with live test-progress suffix on 2.x Reshape the live terminal display: a drawn progress bar with percent on the status line, concise arrow-style worker lines (> :module goal (execution)), and dimmed > IDLE slots for free threads. At addProjectLine, append a styled live test-progress suffix (tests/failures/errors/skipped counts plus the current test class#method) driven by the new PROJECT_TEST_PROGRESS message. Co-Authored-By: Auto Co-authored-by: Cursor --- .../org/mvndaemon/mvnd/common/Message.java | 117 ++++++++++++++++++ .../mvnd/common/logging/TerminalOutput.java | 102 +++++++++++++-- .../mvndaemon/mvnd/common/MessageTest.java | 40 ++++++ .../common/logging/TerminalOutputTest.java | 85 +++++++++++++ 4 files changed, 331 insertions(+), 13 deletions(-) create mode 100644 common/src/test/java/org/mvndaemon/mvnd/common/logging/TerminalOutputTest.java 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 def1d8ac6..53265502d 100644 --- a/common/src/main/java/org/mvndaemon/mvnd/common/Message.java +++ b/common/src/main/java/org/mvndaemon/mvnd/common/Message.java @@ -67,6 +67,12 @@ public abstract class Message { public static final int INPUT_DATA = 28; public static final int REQUEST_INPUT_AVAILABLE = 29; public static final int INPUT_AVAILABLE_DATA = 30; + /** + * Live per-test progress for a project's line while surefire/failsafe run. + * TODO: the daemon-side feed that emits this message is not implemented on mvnd-1.x yet; until it + * lands in a follow-up commit, the client render stays dormant (no test-progress suffix is shown). + */ + public static final int PROJECT_TEST_PROGRESS = 31; final int type; @@ -91,6 +97,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: @@ -151,6 +159,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: @@ -242,6 +251,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(); @@ -623,6 +643,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; 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 1c0b9a28b..d39944e9a 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 @@ -91,6 +91,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; @@ -141,6 +143,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) { @@ -269,6 +272,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: { @@ -424,6 +428,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); } @@ -546,8 +558,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; @@ -681,10 +701,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); @@ -714,9 +765,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 { @@ -737,38 +785,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/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()); + } +} From 223ea7ab04a222447a553270ec60b8071f501b8b Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:55:09 -0400 Subject: [PATCH 2/3] Use existing readUTF/writeUTF null handling in ProjectTestProgressEvent readUTF/writeUTF already encode null via a -1 length sentinel, so the boolean-prefixed readNullableUTF/writeNullableUTF helpers duplicated an existing convention. Remove them and use readUTF/writeUTF directly. --- .../org/mvndaemon/mvnd/common/Message.java | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/common/src/main/java/org/mvndaemon/mvnd/common/Message.java b/common/src/main/java/org/mvndaemon/mvnd/common/Message.java index 53265502d..db89ecab9 100644 --- a/common/src/main/java/org/mvndaemon/mvnd/common/Message.java +++ b/common/src/main/java/org/mvndaemon/mvnd/common/Message.java @@ -251,17 +251,6 @@ 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(); @@ -654,8 +643,8 @@ public static class ProjectTestProgressEvent extends Message { public static ProjectTestProgressEvent read(DataInputStream input) throws IOException { final String projectId = readUTF(input); - final String testClass = readNullableUTF(input); - final String testMethod = readNullableUTF(input); + final String testClass = readUTF(input); + final String testMethod = readUTF(input); final int completed = input.readInt(); final int failures = input.readInt(); final int errors = input.readInt(); @@ -713,8 +702,8 @@ public int getSkipped() { public void write(DataOutputStream output) throws IOException { super.write(output); writeUTF(output, projectId); - writeNullableUTF(output, testClass); - writeNullableUTF(output, testMethod); + writeUTF(output, testClass); + writeUTF(output, testMethod); output.writeInt(completed); output.writeInt(failures); output.writeInt(errors); From 5ff75e7cfae967ae8ef3796ecf6a2e00d034532f Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:09:45 -0400 Subject: [PATCH 3/3] Address review feedback: fix test-progress class order, restore plugin version in worker line PROJECT_TEST_PROGRESS now sorts after PROJECT_STARTED/MOJO_STARTED in the sendQueue to avoid a message racing ahead of its project's PROJECT_STARTED event. Also restores the plugin groupId/version in the worker line display, which was dropped by the 2.x visual redesign. Co-Authored-By: Claude Sonnet 5 --- common/src/main/java/org/mvndaemon/mvnd/common/Message.java | 3 ++- .../org/mvndaemon/mvnd/common/logging/TerminalOutput.java | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/common/src/main/java/org/mvndaemon/mvnd/common/Message.java b/common/src/main/java/org/mvndaemon/mvnd/common/Message.java index db89ecab9..6ca27d4aa 100644 --- a/common/src/main/java/org/mvndaemon/mvnd/common/Message.java +++ b/common/src/main/java/org/mvndaemon/mvnd/common/Message.java @@ -159,7 +159,6 @@ 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: @@ -171,6 +170,8 @@ public static int getClassOrder(Message m) { return 3; case MOJO_STARTED: return 4; + case PROJECT_TEST_PROGRESS: + return 5; case EXECUTION_FAILURE: return 10; case TRANSFER_INITIATED: 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 d39944e9a..646759dce 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 @@ -801,11 +801,13 @@ private void addProjectLine(final List lines, Project prj) { .append(String.format(artifactIdFormat, prj.id)) .style(GREEN_FOREGROUND); if (execution.getPluginGoalPrefix().isEmpty()) { - asb.append(execution.getPluginArtifactId()); + asb.append(execution.getPluginGroupId()).append(':').append(execution.getPluginArtifactId()); } else { asb.append(execution.getPluginGoalPrefix()); } asb.append(':') + .append(execution.getPluginVersion()) + .append(':') .append(execution.getMojo()) .append(' ') .style(AttributedStyle.DEFAULT)