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..6ca27d4aa 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: @@ -162,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: @@ -623,6 +633,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 = readUTF(input); + final String testMethod = readUTF(input); + final int completed = input.readInt(); + final int failures = input.readInt(); + final int errors = input.readInt(); + final int skipped = input.readInt(); + return new ProjectTestProgressEvent(projectId, testClass, testMethod, completed, failures, errors, skipped); + } + + 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); + writeUTF(output, testClass); + writeUTF(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..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 @@ -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,18 +785,19 @@ 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()) { @@ -765,10 +814,39 @@ private void addProjectLine(final List lines, Project prj) { .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()); + } +}