diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Options.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Options.java index d2bf596cd916..b7a05ffc2178 100644 --- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Options.java +++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Options.java @@ -182,6 +182,26 @@ public interface Options { @Nonnull Optional color(); + /** + * Returns the console output mode. + *

+ * Supported modes: + *

+ * + * @return an {@link Optional} containing the console mode, or empty if not set + * @since 4.1.0 + */ + @Nonnull + Optional console(); + /** * Indicates whether Maven should operate in offline mode. * diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/ExecutionEventLogger.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/ExecutionEventLogger.java index d85cc7188483..b42697e97b29 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/ExecutionEventLogger.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/ExecutionEventLogger.java @@ -31,6 +31,7 @@ import org.apache.maven.api.MonotonicClock; import org.apache.maven.api.services.MessageBuilder; import org.apache.maven.api.services.MessageBuilderFactory; +import org.apache.maven.cling.utils.CLIReportingUtils; import org.apache.maven.execution.AbstractExecutionListener; import org.apache.maven.execution.BuildFailure; import org.apache.maven.execution.BuildSuccess; @@ -304,6 +305,15 @@ private void logStats(MavenSession session) { logger.info("Total time: {}{}", formatDuration(time), wallClock); + // On failure, show Maven and Java version to help with bug reports (MNG-7372) + if (session.getResult().hasExceptions()) { + logger.info("Maven: {}", CLIReportingUtils.showVersionMinimal()); + logger.info( + "Java: {} ({})", + System.getProperty("java.version", ""), + System.getProperty("java.vendor", "")); + } + ZonedDateTime rounded = finish.truncatedTo(ChronoUnit.SECONDS).atZone(ZoneId.systemDefault()); logger.info("Finished at: {}", formatTimestamp(rounded)); } diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineBuildEventListener.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineBuildEventListener.java new file mode 100644 index 000000000000..c2c4e4585e7b --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineBuildEventListener.java @@ -0,0 +1,273 @@ +/* + * 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.apache.maven.cling.event; + +import java.util.function.Consumer; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.logging.BuildEventListener; +import org.eclipse.aether.transfer.TransferEvent; + +/** + * A machine-readable build event listener that outputs one JSON object per line + * to the configured writer. Each line is a self-contained JSON object with an + * {@code "event"} field identifying its type. + *

+ * This listener handles the {@link BuildEventListener} events: log messages, + * transfer progress, and execution failures. Session and project lifecycle events + * are emitted by the companion {@link MachineExecutionEventLogger}. + *

+ * The JSON lines format is designed for piping to external tools (CI systems, + * LLM agents, IDE integrations) that consume structured build events in real time. + *

+ * Example output: + *

+ * {"event":"log","timestamp":"...","message":"Compiling 42 source files"}
+ * {"event":"transfer.started","timestamp":"...","artifact":"core-4.1.0.jar","size":524288}
+ * {"event":"transfer.progressed","timestamp":"...","artifact":"core-4.1.0.jar","transferred":262144,"total":524288}
+ * {"event":"transfer.completed","timestamp":"...","artifact":"core-4.1.0.jar","transferred":524288}
+ * 
+ * + * Selected via {@code --console=machine}. + * + * @since 4.1.0 + * @see MachineExecutionEventLogger + */ +public class MachineBuildEventListener implements BuildEventListener { + + private final Consumer output; + + /** + * Creates a new MachineBuildEventListener. + * + * @param output the consumer that receives each JSON line (typically writes to terminal/stdout) + */ + public MachineBuildEventListener(Consumer output) { + this.output = output; + } + + /** + * Emit a pre-built JSON line to the output. Thread-safe — output is serialized + * to prevent interleaved lines from parallel builds. + * + * @param json the complete JSON object string (no trailing newline) + */ + public synchronized void emitEvent(String json) { + output.accept(json); + } + + @Override + public void sessionStarted(ExecutionEvent event) { + // Handled by MachineExecutionEventLogger.sessionStarted() + } + + @Override + public void projectStarted(String projectId) { + // Handled by MachineExecutionEventLogger.projectStarted() + } + + @Override + public void projectLogMessage(String projectId, LogEvent event) { + emitEvent(new JsonLine("log") + .field("level", event.level().name()) + .field("module", projectId) + .field("logger", event.loggerName()) + .field("message", event.message()) + .build()); + } + + @Override + public void projectFinished(String projectId) { + // Handled by MachineExecutionEventLogger.projectSucceeded/Failed/Skipped() + } + + @Override + public void executionFailure(String projectId, boolean halted, String exception) { + emitEvent(new JsonLine("execution.failure") + .field("module", projectId) + .field("halted", halted) + .field("error", exception) + .build()); + } + + @Override + public void mojoStarted(ExecutionEvent event) { + // Handled by MachineExecutionEventLogger.mojoStarted() + } + + @Override + public void finish(int exitCode) throws Exception { + // No-op — build.finished is emitted by MachineExecutionEventLogger.sessionEnded() + } + + @Override + public void fail(Throwable t) throws Exception { + // No-op — build.finished is emitted by MachineExecutionEventLogger.sessionEnded() + } + + @Override + public void log(String msg) { + emitEvent(new JsonLine("log").field("message", msg).build()); + } + + @Override + public void transfer(String projectId, TransferEvent event) { + String resource = event.getResource().getResourceName(); + String artifactName = extractArtifactName(resource); + long contentLength = event.getResource().getContentLength(); + + switch (event.getType()) { + case INITIATED: + case STARTED: + JsonLine started = new JsonLine("transfer.started").field("artifact", artifactName); + if (projectId != null) { + started.field("module", projectId); + } + if (contentLength > 0) { + started.field("size", contentLength); + } + started.field("url", resource); + emitEvent(started.build()); + break; + case PROGRESSED: + JsonLine progressed = new JsonLine("transfer.progressed").field("artifact", artifactName); + progressed.field("transferred", event.getTransferredBytes()); + if (contentLength > 0) { + progressed.field("total", contentLength); + } + emitEvent(progressed.build()); + break; + case SUCCEEDED: + JsonLine succeeded = new JsonLine("transfer.completed").field("artifact", artifactName); + succeeded.field("transferred", event.getTransferredBytes()); + emitEvent(succeeded.build()); + break; + case FAILED: + JsonLine failed = new JsonLine("transfer.failed").field("artifact", artifactName); + if (event.getException() != null) { + failed.field("error", event.getException().getMessage()); + } + emitEvent(failed.build()); + break; + default: + break; + } + } + + // ---- Helpers ---- + + private static String extractArtifactName(String resourceName) { + if (resourceName == null) { + return "unknown"; + } + int lastSlash = resourceName.lastIndexOf('/'); + return lastSlash >= 0 ? resourceName.substring(lastSlash + 1) : resourceName; + } + + // ---- JSON line builder ---- + + /** + * Lightweight builder for single-line JSON objects. Builds a flat JSON object + * with an {@code "event"} type and a {@code "timestamp"} field, plus any + * additional fields. Thread-safe when used within a single thread per instance. + */ + static class JsonLine { + private final StringBuilder sb; + + JsonLine(String eventType) { + sb = new StringBuilder(256); + sb.append("{\"event\":\""); + sb.append(eventType); + sb.append("\",\"timestamp\":\""); + sb.append(MonotonicClock.now().toString()); + sb.append('"'); + } + + JsonLine field(String key, String value) { + if (value != null) { + sb.append(",\"").append(key).append("\":"); + writeJsonString(sb, value); + } + return this; + } + + JsonLine field(String key, long value) { + sb.append(",\"").append(key).append("\":").append(value); + return this; + } + + JsonLine field(String key, double value) { + sb.append(",\"").append(key).append("\":").append(value); + return this; + } + + JsonLine field(String key, boolean value) { + sb.append(",\"").append(key).append("\":").append(value); + return this; + } + + String build() { + sb.append('}'); + return sb.toString(); + } + + /** + * Write a JSON-escaped string value (with surrounding quotes) to the builder. + */ + private static void writeJsonString(StringBuilder sb, String value) { + sb.append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + case '\b': + sb.append("\\b"); + break; + case '\f': + sb.append("\\f"); + break; + default: + if (c < 0x20) { + sb.append("\\u"); + sb.append(String.format("%04x", (int) c)); + } else { + sb.append(c); + } + } + } + sb.append('"'); + } + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineExecutionEventLogger.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineExecutionEventLogger.java new file mode 100644 index 000000000000..f44e9fa91ad1 --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineExecutionEventLogger.java @@ -0,0 +1,306 @@ +/* + * 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.apache.maven.cling.event; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.cling.event.MachineBuildEventListener.JsonLine; +import org.apache.maven.execution.AbstractExecutionListener; +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.BuildSummary; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugin.MojoExecution; +import org.apache.maven.project.MavenProject; + +/** + * Execution event logger for machine-readable output ({@code --console=machine}). + *

+ * Emits one JSON line per lifecycle event to the shared + * {@link MachineBuildEventListener#emitEvent(String)} writer. This logger + * handles the {@link org.apache.maven.execution.ExecutionListener} events: + * session start/end, project start/success/failure/skip, and mojo + * start/success/failure/skip. + *

+ * Together with {@link MachineBuildEventListener} (which handles log messages, + * transfers, and execution failures), this provides a complete, typed event + * stream suitable for piping to external tools, CI systems, and LLM agents. + *

+ * Example output: + *

+ * {"event":"build.started","timestamp":"...","projectCount":12,"goals":"clean install"}
+ * {"event":"module.started","timestamp":"...","module":"maven-core","groupId":"org.apache.maven","artifactId":"maven-core","version":"4.1.0-SNAPSHOT","index":1,"total":12}
+ * {"event":"mojo.started","timestamp":"...","module":"maven-core","plugin":"maven-compiler-plugin","goal":"compile","phase":"compile"}
+ * {"event":"mojo.succeeded","timestamp":"...","module":"maven-core","plugin":"maven-compiler-plugin","goal":"compile","duration":1.2}
+ * {"event":"module.succeeded","timestamp":"...","module":"maven-core","duration":2.1}
+ * {"event":"build.finished","timestamp":"...","status":"SUCCESS","duration":32.1,"total":12,"passed":12,"failed":0,"skipped":0}
+ * 
+ * + * Selected via {@code --console=machine}. + * + * @since 4.1.0 + * @see MachineBuildEventListener + */ +public class MachineExecutionEventLogger extends AbstractExecutionListener { + + private final MachineBuildEventListener machineBel; + + // Track mojo start times for duration calculation + private final Map mojoStartTimes = new ConcurrentHashMap<>(); + + // Reactor state + private volatile int totalProjects; + private volatile int currentVisitedProjectCount; + private volatile Instant buildStartTime; + + public MachineExecutionEventLogger(MachineBuildEventListener machineBel) { + this.machineBel = Objects.requireNonNull(machineBel, "machineBel cannot be null"); + } + + // ---- Session lifecycle ---- + + @Override + public void sessionStarted(ExecutionEvent event) { + MavenSession session = event.getSession(); + List projects = session.getProjects(); + List allProjects = session.getAllProjects(); + + totalProjects = allProjects.size(); + currentVisitedProjectCount = allProjects.size() - projects.size(); + buildStartTime = MonotonicClock.now(); + + String goals = session.getRequest().getGoals().stream().collect(Collectors.joining(" ")); + + JsonLine line = new JsonLine("build.started") + .field("projectCount", totalProjects) + .field("goals", goals); + + List profiles = session.getRequest().getActiveProfiles(); + if (profiles != null && !profiles.isEmpty()) { + line.field("profiles", String.join(",", profiles)); + } + + machineBel.emitEvent(line.build()); + } + + @Override + public void sessionEnded(ExecutionEvent event) { + MavenSession session = event.getSession(); + + int passed = 0; + int failed = 0; + int skipped = 0; + for (MavenProject project : session.getProjects()) { + BuildSummary summary = session.getResult().getBuildSummary(project); + if (summary instanceof BuildSuccess) { + passed++; + } else if (summary instanceof BuildFailure) { + failed++; + } else { + skipped++; + } + } + + String status = session.getResult().hasExceptions() ? "FAILURE" : "SUCCESS"; + double duration = 0; + if (buildStartTime != null) { + duration = Duration.between(buildStartTime, MonotonicClock.now()).toMillis() / 1000.0; + } + + machineBel.emitEvent(new JsonLine("build.finished") + .field("status", status) + .field("duration", duration) + .field("total", totalProjects) + .field("passed", passed) + .field("failed", failed) + .field("skipped", skipped) + .build()); + } + + // ---- Module lifecycle ---- + + @Override + public void projectStarted(ExecutionEvent event) { + MavenProject project = event.getProject(); + int index; + synchronized (this) { + index = ++currentVisitedProjectCount; + } + + machineBel.emitEvent(new JsonLine("module.started") + .field("module", project.getName()) + .field("groupId", project.getGroupId()) + .field("artifactId", project.getArtifactId()) + .field("version", project.getVersion()) + .field("index", index) + .field("total", totalProjects) + .build()); + } + + @Override + public void projectSucceeded(ExecutionEvent event) { + logModuleFinished(event, "module.succeeded"); + } + + @Override + public void projectFailed(ExecutionEvent event) { + logModuleFinished(event, "module.failed"); + } + + @Override + public void projectSkipped(ExecutionEvent event) { + MavenProject project = event.getProject(); + machineBel.emitEvent(new JsonLine("module.skipped") + .field("module", project.getName()) + .build()); + } + + // ---- Mojo lifecycle ---- + + @Override + public void mojoStarted(ExecutionEvent event) { + MavenProject project = event.getProject(); + MojoExecution mojo = event.getMojoExecution(); + + String mojoKey = project.getArtifactId() + ":" + mojo.getExecutionId() + ":" + mojo.getGoal(); + mojoStartTimes.put(mojoKey, MonotonicClock.now()); + + machineBel.emitEvent(new JsonLine("mojo.started") + .field("module", project.getName()) + .field("plugin", mojo.getArtifactId()) + .field("goal", mojo.getGoal()) + .field("phase", mojo.getLifecyclePhase()) + .field("executionId", mojo.getExecutionId()) + .build()); + } + + @Override + public void mojoSucceeded(ExecutionEvent event) { + logMojoFinished(event, "mojo.succeeded"); + } + + @Override + public void mojoFailed(ExecutionEvent event) { + MavenProject project = event.getProject(); + MojoExecution mojo = event.getMojoExecution(); + + String mojoKey = project.getArtifactId() + ":" + mojo.getExecutionId() + ":" + mojo.getGoal(); + Instant start = mojoStartTimes.remove(mojoKey); + + JsonLine line = new JsonLine("mojo.failed") + .field("module", project.getName()) + .field("plugin", mojo.getArtifactId()) + .field("goal", mojo.getGoal()); + if (start != null) { + double duration = Duration.between(start, MonotonicClock.now()).toMillis() / 1000.0; + line.field("duration", duration); + } + if (event.getException() != null) { + line.field("error", event.getException().getMessage()); + } + machineBel.emitEvent(line.build()); + } + + @Override + public void mojoSkipped(ExecutionEvent event) { + MavenProject project = event.getProject(); + MojoExecution mojo = event.getMojoExecution(); + + machineBel.emitEvent(new JsonLine("mojo.skipped") + .field("module", project.getName()) + .field("plugin", mojo.getArtifactId()) + .field("goal", mojo.getGoal()) + .build()); + } + + // ---- Fork lifecycle (machine mode emits these for completeness) ---- + + @Override + public void forkStarted(ExecutionEvent event) { + MavenProject project = event.getProject(); + MojoExecution mojo = event.getMojoExecution(); + + machineBel.emitEvent(new JsonLine("fork.started") + .field("module", project.getName()) + .field("plugin", mojo.getArtifactId()) + .field("goal", mojo.getGoal()) + .build()); + } + + @Override + public void forkSucceeded(ExecutionEvent event) { + MavenProject project = event.getProject(); + machineBel.emitEvent(new JsonLine("fork.succeeded") + .field("module", project.getName()) + .build()); + } + + @Override + public void forkFailed(ExecutionEvent event) { + MavenProject project = event.getProject(); + JsonLine line = new JsonLine("fork.failed").field("module", project.getName()); + if (event.getException() != null) { + line.field("error", event.getException().getMessage()); + } + machineBel.emitEvent(line.build()); + } + + // ---- Helpers ---- + + private void logModuleFinished(ExecutionEvent event, String eventType) { + MavenProject project = event.getProject(); + MavenSession session = event.getSession(); + BuildSummary summary = session.getResult().getBuildSummary(project); + + JsonLine line = new JsonLine(eventType).field("module", project.getName()); + if (summary != null) { + line.field("duration", summary.getExecTime().toMillis() / 1000.0); + } + if ("module.failed".equals(eventType) && event.getException() != null) { + line.field("error", event.getException().getMessage()); + } + machineBel.emitEvent(line.build()); + } + + private void logMojoFinished(ExecutionEvent event, String eventType) { + MavenProject project = event.getProject(); + MojoExecution mojo = event.getMojoExecution(); + + String mojoKey = project.getArtifactId() + ":" + mojo.getExecutionId() + ":" + mojo.getGoal(); + Instant start = mojoStartTimes.remove(mojoKey); + + JsonLine line = new JsonLine(eventType) + .field("module", project.getName()) + .field("plugin", mojo.getArtifactId()) + .field("goal", mojo.getGoal()); + if (start != null) { + double duration = Duration.between(start, MonotonicClock.now()).toMillis() / 1000.0; + line.field("duration", duration); + } + machineBel.emitEvent(line.build()); + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/PlainExecutionEventLogger.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/PlainExecutionEventLogger.java new file mode 100644 index 000000000000..f35588ff2e9e --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/PlainExecutionEventLogger.java @@ -0,0 +1,323 @@ +/* + * 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.apache.maven.cling.event; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.services.MessageBuilder; +import org.apache.maven.api.services.MessageBuilderFactory; +import org.apache.maven.cling.utils.CLIReportingUtils; +import org.apache.maven.execution.AbstractExecutionListener; +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.BuildSummary; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.project.MavenProject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.apache.maven.cling.utils.CLIReportingUtils.formatDuration; + +/** + * Compact execution event logger for CI and batch environments. + *

+ * Produces one line per completed module instead of the verbose per-mojo + * output of {@link ExecutionEventLogger}. Designed for CI log viewers + * and LLM-based tools where signal density matters more than verbosity. + *

+ * Example output: + *

+ * [INFO] maven-api-core ................................ SUCCESS [  2.1s]
+ * [INFO] maven-core ..................................... FAILURE [  5.3s]
+ * [INFO]
+ * [INFO] BUILD FAILURE
+ * [INFO] Total time:  32.1s
+ * 
+ * + * Selected via {@code --console=plain} or automatically in CI environments. + * + * @since 4.1.0 + * @see ExecutionEventLogger + */ +public class PlainExecutionEventLogger extends AbstractExecutionListener { + + private static final int MAX_LOG_PREFIX_SIZE = 8; // "[ERROR] " + private static final int PROJECT_STATUS_SUFFIX_SIZE = 20; // "SUCCESS [ 0.000 s]" + private static final int MIN_TERMINAL_WIDTH = 60; + private static final int DEFAULT_TERMINAL_WIDTH = 80; + private static final int MAX_TERMINAL_WIDTH = 130; + private static final int MAX_PADDED_BUILD_TIME_DURATION_LENGTH = 9; + + private final MessageBuilderFactory messageBuilderFactory; + private final Logger logger; + private int terminalWidth; + private int lineLength; + private int maxProjectNameLength; + private int totalProjects; + private volatile int currentVisitedProjectCount; + + public PlainExecutionEventLogger(MessageBuilderFactory messageBuilderFactory) { + this(messageBuilderFactory, LoggerFactory.getLogger(PlainExecutionEventLogger.class)); + } + + public PlainExecutionEventLogger(MessageBuilderFactory messageBuilderFactory, Logger logger) { + this(messageBuilderFactory, logger, -1); + } + + public PlainExecutionEventLogger(MessageBuilderFactory messageBuilderFactory, Logger logger, int terminalWidth) { + this.logger = Objects.requireNonNull(logger, "logger cannot be null"); + this.messageBuilderFactory = messageBuilderFactory; + this.terminalWidth = terminalWidth; + } + + private void init() { + if (maxProjectNameLength == 0) { + if (terminalWidth < 0) { + terminalWidth = messageBuilderFactory.getTerminalWidth(); + } + terminalWidth = Math.min( + MAX_TERMINAL_WIDTH, + Math.max(terminalWidth <= 0 ? DEFAULT_TERMINAL_WIDTH : terminalWidth, MIN_TERMINAL_WIDTH)); + lineLength = terminalWidth - MAX_LOG_PREFIX_SIZE; + maxProjectNameLength = lineLength - PROJECT_STATUS_SUFFIX_SIZE; + } + } + + private MessageBuilder builder() { + return messageBuilderFactory.builder(); + } + + private static String chars(char c, int count) { + return String.valueOf(c).repeat(Math.max(0, count)); + } + + private void infoMain(String msg) { + logger.info(builder().strong(msg).toString()); + } + + // ---- Session lifecycle ---- + + @Override + public void projectDiscoveryStarted(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logger.info("Scanning for projects..."); + } + } + + @Override + public void sessionStarted(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + List projects = event.getSession().getProjects(); + List allProjects = event.getSession().getAllProjects(); + + currentVisitedProjectCount = allProjects.size() - projects.size(); + totalProjects = allProjects.size(); + } + } + + @Override + public void sessionEnded(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logger.info(""); + logResult(event.getSession()); + logStats(event.getSession()); + } + } + + // ---- Module lifecycle: one line per completed module ---- + + @Override + public void projectStarted(ExecutionEvent event) { + // In plain mode, we only log when a project finishes (succeeded/failed/skipped) + } + + @Override + public void projectSucceeded(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logProjectLine(event, "SUCCESS"); + } + } + + @Override + public void projectFailed(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logProjectLine(event, "FAILURE"); + } + } + + @Override + public void projectSkipped(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logProjectLine(event, "SKIPPED"); + } + } + + // ---- Mojo lifecycle: suppressed in plain mode ---- + + @Override + public void mojoStarted(ExecutionEvent event) { + // Suppressed in plain mode — plugin execution details go to build report + } + + @Override + public void mojoSkipped(ExecutionEvent event) { + if (logger.isWarnEnabled()) { + logger.warn( + "Goal '{}' requires online mode for execution but Maven is currently offline, skipping", + event.getMojoExecution().getGoal()); + } + } + + @Override + public void forkStarted(ExecutionEvent event) { + // Suppressed in plain mode + } + + @Override + public void forkSucceeded(ExecutionEvent event) { + // Suppressed in plain mode + } + + // ---- Formatting helpers ---- + + private void logProjectLine(ExecutionEvent event, String status) { + MavenProject project = event.getProject(); + MavenSession session = event.getSession(); + MavenExecutionResult result = session.getResult(); + BuildSummary buildSummary = result.getBuildSummary(project); + + StringBuilder buffer = new StringBuilder(128); + buffer.append(project.getName()); + buffer.append(' '); + + if (totalProjects > 1) { + int number; + synchronized (this) { + number = ++currentVisitedProjectCount; + } + String progress = "[" + number + "/" + totalProjects + "]"; + buffer.append(progress); + buffer.append(' '); + } + + // Pad with dots to align status + if (buffer.length() <= maxProjectNameLength) { + while (buffer.length() < maxProjectNameLength) { + buffer.append('.'); + } + buffer.append(' '); + } + + // Status with color + MessageBuilder mb = builder(); + mb.a(buffer); + switch (status) { + case "SUCCESS": + mb.success(status); + break; + case "FAILURE": + mb.failure(status); + break; + default: + mb.warning(status); + break; + } + + // Duration + if (buildSummary != null) { + mb.a(" ["); + String duration = formatDuration(buildSummary.getExecTime()); + int padSize = MAX_PADDED_BUILD_TIME_DURATION_LENGTH - duration.length(); + if (padSize > 0) { + mb.a(chars(' ', padSize)); + } + mb.a(duration); + mb.a(']'); + } + + logger.info(mb.toString()); + } + + private void logResult(MavenSession session) { + MessageBuilder buffer = builder(); + if (session.getResult().hasExceptions()) { + buffer.failure("BUILD FAILURE"); + } else { + buffer.success("BUILD SUCCESS"); + } + + int passed = 0; + int failed = 0; + int skipped = 0; + for (MavenProject project : session.getProjects()) { + BuildSummary summary = session.getResult().getBuildSummary(project); + if (summary instanceof BuildSuccess) { + passed++; + } else if (summary instanceof BuildFailure) { + failed++; + } else { + skipped++; + } + } + + logger.info(buffer.toString()); + + // Compact stats line: "12 modules | 11 passed | 1 failed | 0 skipped" + if (totalProjects > 1) { + StringBuilder stats = new StringBuilder(); + stats.append(totalProjects).append(" modules"); + stats.append(" | ").append(passed).append(" passed"); + if (failed > 0) { + stats.append(" | ").append(failed).append(" failed"); + } + if (skipped > 0) { + stats.append(" | ").append(skipped).append(" skipped"); + } + logger.info(stats.toString()); + } + } + + private void logStats(MavenSession session) { + Duration time = Duration.between(session.getRequest().getStartInstant(), MonotonicClock.now()); + String wallClock = session.getRequest().getDegreeOfConcurrency() > 1 ? " (Wall Clock)" : ""; + logger.info("Total time: {}{}", formatDuration(time), wallClock); + + // On failure, show Maven and Java version to help with bug reports (MNG-7372) + if (session.getResult().hasExceptions()) { + logger.info("Maven: {}", CLIReportingUtils.showVersionMinimal()); + logger.info( + "Java: {} ({})", + System.getProperty("java.version", ""), + System.getProperty("java.vendor", "")); + } + + logger.info("Full report: target/build-reports/build-report-latest.json"); + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java new file mode 100644 index 000000000000..dc22c07d3b94 --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java @@ -0,0 +1,785 @@ +/* + * 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.apache.maven.cling.event; + +import java.io.PrintWriter; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.logging.BuildEventListener; +import org.apache.maven.project.MavenProject; +import org.eclipse.aether.transfer.TransferEvent; +import org.jline.terminal.Terminal; +import org.jline.utils.Display; + +/** + * A rich terminal build event listener using JLine's {@link Display} in + * non-fullscreen mode — the same approach as mvnd. + *

+ * The status area is rendered at the current cursor position using + * {@link Display#updateAnsi}. When log output arrives, the display is + * cleared (updated with empty lines), the log line is printed normally, + * and then the status is redrawn below it. JLine handles all the cursor + * math (moving up, erasing changed lines, etc.) and only repaints what + * actually changed. + *

+ * At the end of the build, the display is cleared and nothing remains + * on screen — the summary then prints as normal scrolling text. + *

+ * The status area has a fixed height based on the degree of concurrency, + * so the separator and summary line stay anchored at the bottom. Active + * projects are packed to the top of the slot area; empty lines fill the + * gap between the last active project and the separator. + *

+ * Falls back to simple log passthrough on dumb terminals. + * + * @since 4.1.0 + * @see PlainExecutionEventLogger + * @see ExecutionEventLogger + */ +public class RichBuildEventListener implements BuildEventListener { + + // ---- ANSI colors ---- + + private static final String ESC = "\033["; + private static final String CYAN = ESC + "36m"; + private static final String YELLOW = ESC + "33m"; + private static final String BLUE = ESC + "34m"; + private static final String GREEN = ESC + "32m"; + private static final String RED = ESC + "31m"; + private static final String BOLD = ESC + "1m"; + private static final String DIM = ESC + "2m"; + private static final String RESET = ESC + "0m"; + + // ---- Terminal & output ---- + + private final Terminal terminal; + private final PrintWriter writer; + private final boolean supported; + + // ---- JLine Display ---- + + /** JLine display in non-fullscreen mode — handles cursor math. */ + private volatile Display display; + /** Whether the display is currently active. */ + private volatile boolean displayActive; + /** Fixed number of lines in the status area (set once in initReactor). */ + private volatile int statusHeight; + + // ---- Reactor state ---- + + private volatile int totalProjects; + private volatile int completedProjects; + private volatile Instant buildStartTime; + /** One-line header shown at the top of the status area. */ + private volatile String headerLine; + + // ---- Project display ---- + + private final Map activeProjects = new ConcurrentHashMap<>(); + private final List projectOrder = new ArrayList<>(); + private final Map projectNames = new ConcurrentHashMap<>(); + + // ---- Active downloads ---- + + private final Map activeTransfers = new ConcurrentHashMap<>(); + + // ---- Synchronization ---- + + /** Guards all terminal output and slot mutations. */ + private final Object outputLock = new Object(); + + // ---- Periodic refresh ---- + + /** Scheduler for 1-second display refresh so elapsed timers stay live. */ + private volatile ScheduledExecutorService refreshScheduler; + /** Handle for the periodic refresh task. */ + private volatile ScheduledFuture refreshFuture; + + // ---- Warning tracking ---- + + /** Number of WARN-level messages seen during the build. */ + private final AtomicInteger warningCount = new AtomicInteger(); + + /** Number of ERROR-level messages seen during the build. */ + private final AtomicInteger errorCount = new AtomicInteger(); + + // ---- Constructor ---- + + /** + * Creates a new RichBuildEventListener. + * + * @param terminal the JLine terminal for output + * @param output fallback output consumer (unused — kept for API compat) + */ + public RichBuildEventListener(Terminal terminal, java.util.function.Consumer output) { + this.terminal = terminal; + this.writer = terminal.writer(); + // Support ANSI if terminal type is not "dumb" and has reasonable size + String type = terminal.getType(); + this.supported = type != null && !Terminal.TYPE_DUMB.equals(type) && terminal.getWidth() > 0; + } + + // ---- Reactor lifecycle ---- + + /** + * Initialize reactor state from the session. Called by {@link RichExecutionEventLogger} + * during {@code sessionStarted}. + */ + public void initReactor(MavenSession session) { + List allProjects = session.getAllProjects(); + List projects = session.getProjects(); + + this.totalProjects = allProjects.size(); + this.completedProjects = allProjects.size() - projects.size(); + this.buildStartTime = MonotonicClock.now(); + + for (MavenProject project : allProjects) { + projectOrder.add(project.getArtifactId()); + projectNames.put(project.getArtifactId(), project.getName()); + } + + // Build header line + this.headerLine = buildHeaderLine(session); + + // Slot count = degree of concurrency (capped for sanity) + int concurrency = 1; + try { + concurrency = Math.max(1, session.getRequest().getDegreeOfConcurrency()); + } catch (Exception e) { + // fallback to 1 + } + int slotCount = Math.min(concurrency, 8); + // Fixed height: 1 header + N project slots + 1 separator + 1 summary + this.statusHeight = slotCount + 3; + + if (supported) { + setupDisplay(); + } + } + + private String buildHeaderLine(MavenSession session) { + StringBuilder h = new StringBuilder(); + h.append(' ').append(BOLD); + + // Maven version + String mavenVersion = null; + if (session.getSystemProperties() != null) { + mavenVersion = session.getSystemProperties().getProperty("maven.version"); + } + if (mavenVersion != null) { + h.append("Maven ").append(mavenVersion); + } else { + h.append("Maven"); + } + h.append(RESET); + + // Project name + MavenProject top = session.getTopLevelProject(); + if (top != null) { + h.append(DIM).append(" ─ ").append(RESET); + h.append("building "); + h.append(CYAN).append(top.getName()).append(RESET); + if (top.getVersion() != null) { + h.append(' ').append(DIM).append(top.getVersion()).append(RESET); + } + } + + // Goals + List goals = session.getGoals(); + if (goals != null && !goals.isEmpty()) { + h.append(DIM).append(" ─ ").append(RESET); + h.append(YELLOW).append(String.join(" ", goals)).append(RESET); + } + + return h.toString(); + } + + /** + * Set up the JLine Display in non-fullscreen mode. + */ + private void setupDisplay() { + synchronized (outputLock) { + display = new Display(terminal, false); + display.resize(statusHeight, terminal.getWidth()); + displayActive = true; + display.updateAnsi(buildStatusLines(), 0); + } + + // Start a 1-second periodic refresh so that elapsed-time counters + // stay live even when no build events are arriving (e.g. during + // a slow mojo execution with no log output). + ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1, r -> { + Thread t = new Thread(r, "maven-rich-display-refresh"); + t.setDaemon(true); + return t; + }); + executor.setRemoveOnCancelPolicy(true); + refreshScheduler = executor; + refreshFuture = refreshScheduler.scheduleAtFixedRate(this::redraw, 1, 1, TimeUnit.SECONDS); + } + + /** + * Tear down the status display. Called by {@link RichExecutionEventLogger} + * during {@code sessionEnded} before printing the summary. + *

+ * The flush at the end is critical: {@link Display} writes through + * {@link Terminal#writer()} (a {@code PrintWriter} that does not auto-flush), + * while subsequent log output from SLF4J goes through {@code System.out} + * (which does auto-flush on {@code println}). Without the flush, + * the clear sequences sit in the writer's buffer while the summary text + * reaches the terminal first via {@code System.out} — then the belated + * clear erases the summary the user was supposed to see. + */ + public void tearDown() { + // Stop the periodic refresh first (outside outputLock to avoid deadlock) + if (refreshFuture != null) { + refreshFuture.cancel(false); + refreshFuture = null; + } + if (refreshScheduler != null) { + refreshScheduler.shutdownNow(); + refreshScheduler = null; + } + + synchronized (outputLock) { + if (!displayActive) { + return; + } + displayActive = false; + + // Clear the display area: update with empty lines, cursor at top + display.updateAnsi(Collections.nCopies(statusHeight, ""), 0); + // Erase from cursor to end of screen — removes any leftover artifacts + writer.print("\033[J"); + // Flush immediately so the clear reaches the terminal BEFORE + // any subsequent log output that goes through System.out + writer.flush(); + } + } + + // ---- BuildEventListener interface ---- + + @Override + public void sessionStarted(ExecutionEvent event) { + // Reactor init is handled via initReactor() called from RichExecutionEventLogger + } + + @Override + public void projectStarted(String projectId) { + activeProjects.put(projectId, new ProjectState(projectId, MonotonicClock.now())); + redraw(); + } + + @Override + public void projectFinished(String projectId) { + activeProjects.remove(projectId); + completedProjects++; + redraw(); + } + + @Override + public void projectLogMessage(String projectId, LogEvent event) { + // In rich mode, suppress INFO/DEBUG/TRACE/WARN — the status bar provides + // live progress and warnings are summarized at the end of the build. + // Only ERROR passes through to the terminal immediately. + if (event.level() == LogLevel.WARN) { + warningCount.incrementAndGet(); + return; + } + if (event.level() == LogLevel.INFO || event.level() == LogLevel.DEBUG || event.level() == LogLevel.TRACE) { + return; + } + if (event.level() == LogLevel.ERROR) { + errorCount.incrementAndGet(); + } + String output = event.formattedMessage(); + if (output == null) { + output = event.message(); + } + printAboveStatus(output); + } + + /** + * Returns the number of WARN-level log messages seen during the build. + */ + public int getWarningCount() { + return warningCount.get(); + } + + /** + * Returns the number of ERROR-level log messages seen during the build. + */ + public int getErrorCount() { + return errorCount.get(); + } + + @Override + public void log(String msg) { + printAboveStatus(msg); + } + + @Override + public void mojoStarted(ExecutionEvent event) { + String projectId = event.getProject().getArtifactId(); + ProjectState state = activeProjects.get(projectId); + if (state != null) { + state.currentMojo = event.getMojoExecution().getArtifactId() + ":" + + event.getMojoExecution().getGoal(); + } + synchronized (outputLock) { + if (displayActive) { + display.updateAnsi(buildStatusLines(), 0); + } + } + } + + @Override + public void executionFailure(String projectId, boolean halted, String exception) { + ProjectState state = activeProjects.get(projectId); + if (state != null) { + state.failed = true; + } + synchronized (outputLock) { + if (displayActive) { + display.updateAnsi(buildStatusLines(), 0); + } + } + } + + @Override + public void transfer(String projectId, TransferEvent event) { + String resource = event.getResource().getResourceName(); + + switch (event.getType()) { + case INITIATED: + case STARTED: + String artifactName = extractArtifactName(resource); + activeTransfers.put( + resource, + new TransferInfo(artifactName, 0, event.getResource().getContentLength())); + redraw(); + break; + case PROGRESSED: + TransferInfo info = activeTransfers.get(resource); + if (info != null) { + info.transferred = event.getTransferredBytes(); + // Only update every ~50KB to avoid too-frequent redraws + if (info.transferred - info.lastUpdateBytes > 51200) { + info.lastUpdateBytes = info.transferred; + redraw(); + } + } + break; + case SUCCEEDED: + case FAILED: + activeTransfers.remove(resource); + redraw(); + break; + default: + break; + } + } + + @Override + public void finish(int exitCode) throws Exception { + tearDown(); + } + + @Override + public void fail(Throwable t) throws Exception { + tearDown(); + } + + // ---- Display helpers ---- + + /** + * Print a message above the status area: clear the display, print + * the message as normal scrolling text, then redraw the status below. + */ + private void printAboveStatus(String msg) { + synchronized (outputLock) { + if (displayActive) { + // Clear status area so the message prints where it was + display.updateAnsi(Collections.nCopies(statusHeight, ""), 0); + display.reset(); + // Print the log message (scrolls normally) + writer.println(msg); + writer.flush(); + // Redraw status below the new output + display.updateAnsi(buildStatusLines(), 0); + } else { + writer.println(msg); + writer.flush(); + } + } + } + + private void redraw() { + synchronized (outputLock) { + if (displayActive) { + display.updateAnsi(buildStatusLines(), 0); + } + } + } + + // ---- Status line building ---- + + /** + * Build exactly {@link #statusHeight} status lines. + *

+ * For small reactors (modules fit as individual indicators): + * Layout: header + active slots + padding + separator + summary (indicators + counter). + *

+ * For large reactors (progress bar mode): + * Layout: header + active slots + padding + progress bar (full-width, acts as separator + counter). + * The progress bar replaces the separator — no redundant horizontal rule. + */ + private List buildStatusLines() { + int termWidth = Math.max(terminal.getWidth(), 40); + + // Collect active projects sorted by start time for visual stability + List active = new ArrayList<>(activeProjects.values()); + active.sort((a, b) -> a.startTime.compareTo(b.startTime)); + + // Determine if we're in progress bar mode (reactor too large for per-module indicators) + int maxIndicators = Math.min(projectOrder.size(), (termWidth - 40) / 3); + boolean useProgressBar = totalProjects > 1 && maxIndicators > 0 && totalProjects > maxIndicators; + + // Number of project slot lines: subtract header (1) + bottom lines (2 for separator+summary, 1 for bar) + int slotCount = statusHeight - (useProgressBar ? 2 : 3); + + List lines = new ArrayList<>(statusHeight); + + // Header line + lines.add(headerLine != null ? headerLine : ""); + + // Active projects packed to the top (up to slotCount) + int projectsShown = 0; + for (ProjectState state : active) { + if (projectsShown >= slotCount) { + break; + } + lines.add(formatProjectSlot(state)); + projectsShown++; + } + + // Empty padding lines at the bottom of the slot area + for (int i = projectsShown; i < slotCount; i++) { + lines.add(""); + } + + if (useProgressBar) { + // Progress bar replaces separator + summary as a single full-width line + lines.add(buildProgressBarLine(termWidth)); + } else { + // Separator line (always at the same position) + lines.add(DIM + "─".repeat(Math.min(termWidth, 120)) + RESET); + // Summary line (per-module indicators + counter + elapsed + downloads) + lines.add(buildSummaryLine(termWidth)); + } + + return lines; + } + + private String formatProjectSlot(ProjectState state) { + StringBuilder b = new StringBuilder(); + if (state.failed) { + b.append(RED).append(" ✗ ").append(RESET); + } else { + b.append(CYAN).append(" ● ").append(RESET); + } + b.append(BOLD); + b.append(projectNames.getOrDefault(state.projectId, state.projectId)); + b.append(RESET); + if (state.currentMojo != null) { + b.append(" ").append(YELLOW).append(state.currentMojo).append(RESET); + } + Duration elapsed = Duration.between(state.startTime, MonotonicClock.now()); + b.append(" ").append(DIM).append(formatCompactDuration(elapsed)).append(RESET); + return b.toString(); + } + + /** + * Build a full-width progress bar line for large reactors. + * Replaces both the separator and summary — one line with the proportional + * bar, counter, elapsed time, and download status. + */ + private String buildProgressBarLine(int termWidth) { + // Build the suffix first so we know how much width the bar can use + StringBuilder suffix = new StringBuilder(); + suffix.append(" ["); + suffix.append(completedProjects).append('/').append(totalProjects); + suffix.append(']'); + if (buildStartTime != null) { + Duration elapsed = Duration.between(buildStartTime, MonotonicClock.now()); + suffix.append(" ").append(formatCompactDuration(elapsed)); + } + if (!activeTransfers.isEmpty()) { + suffix.append(" ↓ "); + if (activeTransfers.size() == 1) { + TransferInfo ti = activeTransfers.values().iterator().next(); + suffix.append(ti.artifactName); + if (ti.totalBytes > 0) { + suffix.append(' ') + .append(formatBytes(ti.transferred)) + .append('/') + .append(formatBytes(ti.totalBytes)); + } + } else { + suffix.append(activeTransfers.size()).append(" artifacts"); + } + } + int suffixLen = suffix.length(); + + // Bar fills from column 0 to (lineWidth - suffixLen) + int lineWidth = Math.min(termWidth, 120); + int barWidth = Math.max(10, lineWidth - suffixLen); + + int activeCount = activeProjects.size(); + int doneChars = (int) ((long) completedProjects * barWidth / totalProjects); + int activeChars = (int) ((long) activeCount * barWidth / totalProjects); + if (activeCount > 0 && activeChars < 1) { + activeChars = 1; + } + if (doneChars + activeChars > barWidth) { + activeChars = barWidth - doneChars; + } + int remainChars = barWidth - doneChars - activeChars; + + StringBuilder s = new StringBuilder(); + s.append(GREEN).append("━".repeat(doneChars)).append(RESET); + s.append(YELLOW).append("━".repeat(activeChars)).append(RESET); + s.append(DIM).append("─".repeat(remainChars)).append(RESET); + + // Append suffix with styling + s.append(" ["); + s.append(BOLD) + .append(completedProjects) + .append('/') + .append(totalProjects) + .append(RESET); + s.append(']'); + if (buildStartTime != null) { + Duration elapsed = Duration.between(buildStartTime, MonotonicClock.now()); + s.append(" ").append(DIM).append(formatCompactDuration(elapsed)).append(RESET); + } + if (!activeTransfers.isEmpty()) { + s.append(" ").append(BLUE).append("↓ ").append(RESET); + if (activeTransfers.size() == 1) { + TransferInfo ti = activeTransfers.values().iterator().next(); + s.append(ti.artifactName); + if (ti.totalBytes > 0) { + s.append(' ') + .append(DIM) + .append(formatBytes(ti.transferred)) + .append('/') + .append(formatBytes(ti.totalBytes)) + .append(RESET); + } + } else { + s.append(activeTransfers.size()).append(" artifacts"); + } + } + + return s.toString(); + } + + /** + * Build the summary line for small reactors (per-module indicators + counter). + */ + private String buildSummaryLine(int termWidth) { + StringBuilder s = new StringBuilder(" "); + + // Per-module indicators — each module gets its own symbol + if (totalProjects > 1) { + for (String pid : projectOrder) { + if (activeProjects.containsKey(pid)) { + ProjectState ps = activeProjects.get(pid); + if (ps != null && ps.failed) { + s.append(RED).append("✗ ").append(RESET); + } else { + s.append(YELLOW).append("● ").append(RESET); + } + } else if (projectOrder.indexOf(pid) < completedProjects) { + s.append(GREEN).append("✓ ").append(RESET); + } else { + s.append(DIM).append("○ ").append(RESET); + } + } + } + + // Progress counter + s.append('['); + s.append(BOLD) + .append(completedProjects) + .append('/') + .append(totalProjects) + .append(RESET); + s.append(']'); + + // Elapsed time + if (buildStartTime != null) { + Duration elapsed = Duration.between(buildStartTime, MonotonicClock.now()); + s.append(" ").append(DIM).append(formatCompactDuration(elapsed)).append(RESET); + } + + // Downloads (merged into summary line to keep height fixed) + if (!activeTransfers.isEmpty()) { + s.append(" ").append(BLUE).append("↓ ").append(RESET); + if (activeTransfers.size() == 1) { + TransferInfo ti = activeTransfers.values().iterator().next(); + s.append(ti.artifactName); + if (ti.totalBytes > 0) { + s.append(' ') + .append(DIM) + .append(formatBytes(ti.transferred)) + .append('/') + .append(formatBytes(ti.totalBytes)) + .append(RESET); + } + } else { + s.append(activeTransfers.size()).append(" artifacts"); + } + } + + return s.toString(); + } + + // ---- Helpers ---- + + /** + * Truncate a string containing ANSI escape sequences to + * {@code maxVisible} visible characters. If truncation occurs, + * a RESET is appended to close any open styling. + */ + static String truncateAnsi(String s, int maxVisible) { + StringBuilder out = new StringBuilder(s.length()); + int visible = 0; + int i = 0; + while (i < s.length()) { + char c = s.charAt(i); + if (c == '\033') { + // Start of escape sequence — copy through without counting + out.append(c); + i++; + if (i < s.length()) { + char next = s.charAt(i); + if (next == '[') { + // CSI sequence: ESC [ ... + out.append(next); + i++; + while (i < s.length()) { + char cc = s.charAt(i); + out.append(cc); + i++; + if (Character.isLetter(cc)) { + break; + } + } + } else { + // Two-char escape (DECSC, DECRC, etc.) + out.append(next); + i++; + } + } + } else { + if (visible >= maxVisible) { + out.append(RESET); + break; + } + out.append(c); + visible++; + i++; + } + } + return out.toString(); + } + + private static String extractArtifactName(String resourceName) { + if (resourceName == null) { + return "unknown"; + } + int lastSlash = resourceName.lastIndexOf('/'); + return lastSlash >= 0 ? resourceName.substring(lastSlash + 1) : resourceName; + } + + private static String formatCompactDuration(Duration duration) { + long totalSeconds = duration.getSeconds(); + if (totalSeconds < 60) { + return totalSeconds + "s"; + } else if (totalSeconds < 3600) { + return (totalSeconds / 60) + "m " + (totalSeconds % 60) + "s"; + } else { + return (totalSeconds / 3600) + "h " + ((totalSeconds % 3600) / 60) + "m"; + } + } + + private static String formatBytes(long bytes) { + if (bytes < 1024) { + return bytes + " B"; + } else if (bytes < 1024 * 1024) { + return String.format("%.0f KB", bytes / 1024.0); + } else { + return String.format("%.1f MB", bytes / (1024.0 * 1024.0)); + } + } + + // ---- Inner state classes ---- + + private static class ProjectState { + final String projectId; + final Instant startTime; + volatile String currentMojo; + volatile boolean failed; + + ProjectState(String projectId, Instant startTime) { + this.projectId = projectId; + this.startTime = startTime; + } + } + + private static class TransferInfo { + final String artifactName; + final long totalBytes; + volatile long transferred; + volatile long lastUpdateBytes; + + TransferInfo(String artifactName, long transferred, long totalBytes) { + this.artifactName = artifactName; + this.transferred = transferred; + this.totalBytes = totalBytes; + } + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichExecutionEventLogger.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichExecutionEventLogger.java new file mode 100644 index 000000000000..87213a088a2c --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichExecutionEventLogger.java @@ -0,0 +1,377 @@ +/* + * 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.apache.maven.cling.event; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.services.MessageBuilder; +import org.apache.maven.api.services.MessageBuilderFactory; +import org.apache.maven.cling.utils.CLIReportingUtils; +import org.apache.maven.execution.AbstractExecutionListener; +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.BuildSummary; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.project.MavenProject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.apache.maven.cling.utils.CLIReportingUtils.formatDuration; + +/** + * Execution event logger for the rich terminal mode ({@code --console=rich}). + *

+ * In rich mode, the {@link RichBuildEventListener} manages a JLine status bar at + * the bottom of the terminal showing live reactor progress. This logger is deliberately + * minimal — it suppresses the verbose per-mojo and per-project banners that + * {@link ExecutionEventLogger} produces, since the status bar replaces them. + *

+ * What this logger DOES print (above the status bar): + *

    + *
  • One line per completed module (like {@link PlainExecutionEventLogger})
  • + *
  • Build result summary ({@code BUILD SUCCESS/FAILURE})
  • + *
  • Compact module statistics and timing
  • + *
  • Pointer to the structured build report
  • + *
+ *

+ * What the status bar shows (managed by {@link RichBuildEventListener}): + *

    + *
  • Currently building modules with active mojo name
  • + *
  • Reactor progress ({@code [n/total]}) and elapsed time
  • + *
  • Active downloads with progress
  • + *
+ * + * @since 4.1.0 + * @see RichBuildEventListener + * @see PlainExecutionEventLogger + */ +public class RichExecutionEventLogger extends AbstractExecutionListener { + + private static final int MAX_LOG_PREFIX_SIZE = 8; // "[ERROR] " + private static final int PROJECT_STATUS_SUFFIX_SIZE = 20; // "SUCCESS [ 0.000 s]" + private static final int MIN_TERMINAL_WIDTH = 60; + private static final int DEFAULT_TERMINAL_WIDTH = 80; + private static final int MAX_TERMINAL_WIDTH = 130; + private static final int MAX_PADDED_BUILD_TIME_DURATION_LENGTH = 9; + + private final MessageBuilderFactory messageBuilderFactory; + private final Logger logger; + private final RichBuildEventListener buildEventListener; + private int terminalWidth; + private int lineLength; + private int maxProjectNameLength; + private int totalProjects; + private volatile int currentVisitedProjectCount; + + public RichExecutionEventLogger( + MessageBuilderFactory messageBuilderFactory, RichBuildEventListener buildEventListener) { + this(messageBuilderFactory, buildEventListener, LoggerFactory.getLogger(RichExecutionEventLogger.class)); + } + + public RichExecutionEventLogger( + MessageBuilderFactory messageBuilderFactory, RichBuildEventListener buildEventListener, Logger logger) { + this(messageBuilderFactory, buildEventListener, logger, -1); + } + + public RichExecutionEventLogger( + MessageBuilderFactory messageBuilderFactory, + RichBuildEventListener buildEventListener, + Logger logger, + int terminalWidth) { + this.logger = Objects.requireNonNull(logger, "logger cannot be null"); + this.messageBuilderFactory = messageBuilderFactory; + this.buildEventListener = Objects.requireNonNull(buildEventListener, "buildEventListener cannot be null"); + this.terminalWidth = terminalWidth; + } + + private void init() { + if (maxProjectNameLength == 0) { + if (terminalWidth < 0) { + terminalWidth = messageBuilderFactory.getTerminalWidth(); + } + terminalWidth = Math.min( + MAX_TERMINAL_WIDTH, + Math.max(terminalWidth <= 0 ? DEFAULT_TERMINAL_WIDTH : terminalWidth, MIN_TERMINAL_WIDTH)); + lineLength = terminalWidth - MAX_LOG_PREFIX_SIZE; + maxProjectNameLength = lineLength - PROJECT_STATUS_SUFFIX_SIZE; + } + } + + private MessageBuilder builder() { + return messageBuilderFactory.builder(); + } + + private static String chars(char c, int count) { + return String.valueOf(c).repeat(Math.max(0, count)); + } + + // ---- Session lifecycle ---- + + @Override + public void projectDiscoveryStarted(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logger.info("Scanning for projects..."); + } + } + + @Override + public void sessionStarted(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + MavenSession session = event.getSession(); + List projects = session.getProjects(); + List allProjects = session.getAllProjects(); + + currentVisitedProjectCount = allProjects.size() - projects.size(); + totalProjects = allProjects.size(); + + // Initialize the status bar + buildEventListener.initReactor(session); + } + } + + @Override + public void sessionEnded(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + + // Tear down the status bar before printing summary + buildEventListener.tearDown(); + + // Write summary directly through the terminal writer (via buildEventListener.log) + // rather than logger.info() — all SLF4J output is routed through + // ProjectBuildLogAppender → projectLogMessage which filters INFO in rich mode. + buildEventListener.log(""); + logResult(event.getSession()); + logStats(event.getSession()); + } + } + + // ---- Module lifecycle ---- + // In rich mode, per-module success lines are suppressed — the status bar already + // shows ✓/●/○ indicators and the [n/total] counter for every module. + // Only FAILURE and SKIPPED scroll above the status bar since they're actionable. + + @Override + public void projectStarted(ExecutionEvent event) { + // Suppressed — the status bar shows active modules + } + + @Override + public void projectSucceeded(ExecutionEvent event) { + // Suppressed — the status bar checkmarks already indicate completion + } + + @Override + public void projectFailed(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logProjectLine(event, "FAILURE"); + } + } + + @Override + public void projectSkipped(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logProjectLine(event, "SKIPPED"); + } + } + + // ---- Mojo lifecycle: suppressed (status bar shows active mojo) ---- + + @Override + public void mojoStarted(ExecutionEvent event) { + // Suppressed — the status bar shows the active mojo + } + + @Override + public void mojoSkipped(ExecutionEvent event) { + if (logger.isWarnEnabled()) { + logger.warn( + "Goal '{}' requires online mode for execution but Maven is currently offline, skipping", + event.getMojoExecution().getGoal()); + } + } + + @Override + public void forkStarted(ExecutionEvent event) { + // Suppressed in rich mode + } + + @Override + public void forkSucceeded(ExecutionEvent event) { + // Suppressed in rich mode + } + + // ---- Formatting helpers (reuses PlainExecutionEventLogger patterns) ---- + + private void logProjectLine(ExecutionEvent event, String status) { + MavenProject project = event.getProject(); + MavenSession session = event.getSession(); + MavenExecutionResult result = session.getResult(); + BuildSummary buildSummary = result.getBuildSummary(project); + + StringBuilder buffer = new StringBuilder(128); + + // Status icon + switch (status) { + case "SUCCESS": + buffer.append(" ✓ "); + break; + case "FAILURE": + buffer.append(" ✗ "); + break; + default: + buffer.append(" ○ "); + break; + } + + buffer.append(project.getName()); + buffer.append(' '); + + if (totalProjects > 1) { + int number; + synchronized (this) { + number = ++currentVisitedProjectCount; + } + String progress = "[" + number + "/" + totalProjects + "]"; + buffer.append(progress); + buffer.append(' '); + } + + // Pad with dots to align status + int effectiveMax = maxProjectNameLength - 3; // account for status icon + if (buffer.length() <= effectiveMax) { + while (buffer.length() < effectiveMax) { + buffer.append('.'); + } + buffer.append(' '); + } + + // Status with color + MessageBuilder mb = builder(); + mb.a(buffer); + switch (status) { + case "SUCCESS": + mb.success(status); + break; + case "FAILURE": + mb.failure(status); + break; + default: + mb.warning(status); + break; + } + + // Duration + if (buildSummary != null) { + mb.a(" ["); + String duration = formatDuration(buildSummary.getExecTime()); + int padSize = MAX_PADDED_BUILD_TIME_DURATION_LENGTH - duration.length(); + if (padSize > 0) { + mb.a(chars(' ', padSize)); + } + mb.a(duration); + mb.a(']'); + } + + buildEventListener.log(mb.toString()); + } + + private void logResult(MavenSession session) { + MessageBuilder buffer = builder(); + if (session.getResult().hasExceptions()) { + buffer.failure("BUILD FAILURE"); + } else { + buffer.success("BUILD SUCCESS"); + } + + int passed = 0; + int failed = 0; + int skipped = 0; + for (MavenProject project : session.getProjects()) { + BuildSummary summary = session.getResult().getBuildSummary(project); + if (summary instanceof BuildSuccess) { + passed++; + } else if (summary instanceof BuildFailure) { + failed++; + } else { + skipped++; + } + } + + buildEventListener.log(buffer.toString()); + + // Compact stats line + if (totalProjects > 1) { + StringBuilder stats = new StringBuilder(); + stats.append(totalProjects).append(" modules"); + stats.append(" | ").append(passed).append(" passed"); + if (failed > 0) { + stats.append(" | ").append(failed).append(" failed"); + } + if (skipped > 0) { + stats.append(" | ").append(skipped).append(" skipped"); + } + buildEventListener.log(stats.toString()); + } + + // Warning/error summary — warnings are suppressed inline in rich mode, + // so the count and a command hint help the user find them. + int warnings = buildEventListener.getWarningCount(); + int errors = buildEventListener.getErrorCount(); + if (warnings > 0 || errors > 0) { + MessageBuilder diag = builder(); + diag.a("Diagnostics: "); + if (warnings > 0) { + diag.warning(warnings + " warning" + (warnings > 1 ? "s" : "")); + } + if (warnings > 0 && errors > 0) { + diag.a(", "); + } + if (errors > 0) { + diag.failure(errors + " error" + (errors > 1 ? "s" : "")); + } + diag.a(" — run ").strong("mvnlog").a(" to see details"); + buildEventListener.log(diag.toString()); + } + } + + private void logStats(MavenSession session) { + Duration time = Duration.between(session.getRequest().getStartInstant(), MonotonicClock.now()); + String wallClock = session.getRequest().getDegreeOfConcurrency() > 1 ? " (Wall Clock)" : ""; + buildEventListener.log("Total time: " + formatDuration(time) + wallClock); + + // On failure, show Maven and Java version to help with bug reports (MNG-7372) + if (session.getResult().hasExceptions()) { + buildEventListener.log("Maven: " + CLIReportingUtils.showVersionMinimal()); + buildEventListener.log("Java: " + System.getProperty("java.version", "") + " (" + + System.getProperty("java.vendor", "") + ")"); + } + + buildEventListener.log("Full report: target/build-reports/build-report-latest.json"); + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java index c417f24f40f0..969ee8a80089 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java @@ -212,6 +212,18 @@ public Optional color() { return Optional.empty(); } + @Override + public Optional console() { + if (commandLine.hasOption(CLIManager.CONSOLE)) { + if (commandLine.getOptionValue(CLIManager.CONSOLE) != null) { + return Optional.of(commandLine.getOptionValue(CLIManager.CONSOLE)); + } else { + return Optional.of("auto"); + } + } + return Optional.empty(); + } + @Override public Optional offline() { if (commandLine.hasOption(CLIManager.OFFLINE)) { @@ -315,6 +327,7 @@ protected static class CLIManager { public static final String LOG_FILE = "l"; public static final String RAW_STREAMS = "raw-streams"; public static final String COLOR = "color"; + public static final String CONSOLE = "console"; public static final String OFFLINE = "o"; public static final String HELP = "h"; @@ -344,6 +357,7 @@ protected CLIManager() { prepareOptions(options); } + @SuppressWarnings("checkstyle:MethodLength") protected void prepareOptions(org.apache.commons.cli.Options options) { options.addOption(Option.builder(HELP) .longOpt("help") @@ -432,6 +446,16 @@ protected void prepareOptions(org.apache.commons.cli.Options options) { .optionalArg(true) .desc("Defines the color mode of the output. Supported are 'auto', 'always', 'never'.") .get()); + options.addOption(Option.builder() + .longOpt(CONSOLE) + .hasArg() + .optionalArg(true) + .desc("Defines the console output mode. Supported are 'auto' (default)," + + " 'plain', 'rich', 'verbose', 'machine'." + + " In 'auto' mode, CI environments use 'plain'," + + " interactive TTYs use 'rich' (status bar)." + + " 'machine' outputs one JSON line per lifecycle event.") + .get()); options.addOption(Option.builder(OFFLINE) .longOpt("offline") .desc("Work offline") diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LayeredOptions.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LayeredOptions.java index 2f9c367c4786..0b6dbda78756 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LayeredOptions.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LayeredOptions.java @@ -132,6 +132,11 @@ public Optional color() { return returnFirstPresentOrEmpty(Options::color); } + @Override + public Optional console() { + return returnFirstPresentOrEmpty(Options::console); + } + @Override public Optional offline() { return returnFirstPresentOrEmpty(Options::offline); diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java index 44c7f89a258f..4a6fe8f18c55 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java @@ -155,6 +155,7 @@ protected int doInvoke(C context) throws Exception { pushUserProperties(context); setupGuiceClassLoading(context); configureLogging(context); + preliminaryInteractiveDetection(context); createTerminal(context); activateLogging(context); helpOrVersionAndMayExit(context); @@ -301,6 +302,30 @@ protected void configureLogging(C context) throws Exception { } } + /** + * Sets {@code context.interactive} based on CLI flags and CI detection before + * {@link #createTerminal(LookupContext)} runs. This is necessary because + * {@code createTerminal} caches the {@link BuildEventListener} (via + * {@link #determineBuildEventListener}), and the console-mode auto-detection + * in subclasses reads {@code context.interactive} to decide between rich/plain/verbose. + * + *

The full settings-based interactive-mode resolution still runs later in + * {@link #settings}, so this is a best-effort early pass using only CLI flags and + * CI environment detection — which is sufficient for the console-mode decision.

+ */ + protected void preliminaryInteractiveDetection(C context) { + if (context.options().forceInteractive().orElse(false)) { + context.interactive = true; + } else if (context.options().nonInteractive().orElse(false)) { + context.interactive = false; + } else if (context.invokerRequest.ciInfo().isPresent()) { + context.interactive = false; + } else { + // Default: assume interactive (settings may refine later) + context.interactive = true; + } + } + protected BuildEventListener determineBuildEventListener(C context) { if (context.buildEventListener == null) { context.buildEventListener = doDetermineBuildEventListener(context); diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java index e6372ccfd818..d4ea89d0db3c 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java @@ -48,6 +48,11 @@ import org.apache.maven.api.services.model.ModelProcessor; import org.apache.maven.api.toolchain.PersistedToolchains; import org.apache.maven.cling.event.ExecutionEventLogger; +import org.apache.maven.cling.event.MachineBuildEventListener; +import org.apache.maven.cling.event.MachineExecutionEventLogger; +import org.apache.maven.cling.event.PlainExecutionEventLogger; +import org.apache.maven.cling.event.RichBuildEventListener; +import org.apache.maven.cling.event.RichExecutionEventLogger; import org.apache.maven.cling.invoker.CliUtils; import org.apache.maven.cling.invoker.LookupContext; import org.apache.maven.cling.invoker.LookupInvoker; @@ -66,6 +71,7 @@ import org.apache.maven.execution.ProjectActivation; import org.apache.maven.jline.MessageUtils; import org.apache.maven.lifecycle.LifecycleExecutionException; +import org.apache.maven.logging.BuildEventListener; import org.apache.maven.logging.LoggingExecutionListener; import org.apache.maven.logging.MavenTransferListener; import org.apache.maven.project.MavenProject; @@ -343,21 +349,102 @@ protected String determineGlobalChecksumPolicy(MavenContext context) { } protected ExecutionListener determineExecutionListener(MavenContext context) { - ExecutionListener listener = new ExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + ExecutionListener listener; + String consoleMode = determineConsoleMode(context); + switch (consoleMode) { + case "machine": + BuildEventListener machineBel = determineBuildEventListener(context); + if (machineBel instanceof MachineBuildEventListener machineListener) { + listener = new MachineExecutionEventLogger(machineListener); + } else { + // Fallback if machine listener couldn't be created + listener = new PlainExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + } + break; + case "rich": + BuildEventListener richBel = determineBuildEventListener(context); + if (richBel instanceof RichBuildEventListener richListener) { + listener = + new RichExecutionEventLogger(context.invokerRequest.messageBuilderFactory(), richListener); + } else { + // Fallback if status bar couldn't be created + listener = new PlainExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + } + break; + case "plain": + listener = new PlainExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + break; + default: + listener = new ExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + break; + } if (context.eventSpyDispatcher != null) { listener = context.eventSpyDispatcher.chainListener(listener); } return new LoggingExecutionListener(listener, determineBuildEventListener(context)); } + @Override + protected BuildEventListener doDetermineBuildEventListener(MavenContext context) { + String consoleMode = determineConsoleMode(context); + if ("machine".equals(consoleMode)) { + return new MachineBuildEventListener(determineWriter(context)); + } + if ("rich".equals(consoleMode) && context.terminal != null) { + return new RichBuildEventListener(context.terminal, determineWriter(context)); + } + return super.doDetermineBuildEventListener(context); + } + + /** + * Resolves the effective console mode from the {@code --console} flag and CI/TTY detection. + *

+ * Resolution order: + *

    + *
  1. Explicit {@code --console=plain}, {@code --console=verbose}, {@code --console=rich}, + * or {@code --console=machine} — always honored
  2. + *
  3. {@code --console=auto} (or unset) — selects mode based on environment: + *
      + *
    • CI detected → "plain"
    • + *
    • Interactive TTY → "rich"
    • + *
    • Otherwise → "verbose"
    • + *
    + *
  4. + *
+ */ + String determineConsoleMode(MavenContext context) { + String consoleMode = context.options().console().orElse("auto"); + if ("plain".equalsIgnoreCase(consoleMode) + || "verbose".equalsIgnoreCase(consoleMode) + || "rich".equalsIgnoreCase(consoleMode) + || "machine".equalsIgnoreCase(consoleMode)) { + return consoleMode.toLowerCase(); + } + // "auto" mode: CI → plain, interactive TTY → rich, otherwise → verbose + if (context.invokerRequest.ciInfo().isPresent() + && !context.options().forceInteractive().orElse(false)) { + return "plain"; + } + if (context.interactive && context.terminal != null && !context.invokerRequest.embedded()) { + return "rich"; + } + return "verbose"; + } + protected TransferListener determineTransferListener(MavenContext context, boolean noTransferProgress) { boolean quiet = context.options().quiet().orElse(false); boolean logFile = context.options().logFile().isPresent(); boolean quietCI = context.invokerRequest.ciInfo().isPresent() && !context.options().forceInteractive().orElse(false); + String mode = determineConsoleMode(context); + boolean richMode = "rich".equals(mode); + boolean machineMode = "machine".equals(mode); TransferListener delegate; - if (quiet || noTransferProgress || quietCI) { + if (quiet || noTransferProgress || quietCI || richMode || machineMode) { + // In rich mode, transfer progress is shown in the JLine status bar. + // In machine mode, transfer events are emitted as JSON lines. + // In both cases, suppress the console transfer listener. delegate = new QuietMavenTransferListener(); } else if (context.interactive && !logFile) { if (context.simplexTransferListener == null) { diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineBuildEventListenerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineBuildEventListenerTest.java new file mode 100644 index 000000000000..67bbe36ff3bc --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineBuildEventListenerTest.java @@ -0,0 +1,239 @@ +/* + * 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.apache.maven.cling.event; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.internal.build.DefaultLogEvent; +import org.eclipse.aether.transfer.TransferEvent; +import org.eclipse.aether.transfer.TransferResource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.MockitoSession; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link MachineBuildEventListener}. + */ +class MachineBuildEventListenerTest { + + private MockitoSession mockitoSession; + private List capturedOutput; + private MachineBuildEventListener listener; + + @BeforeEach + void beforeEach() { + mockitoSession = Mockito.mockitoSession().startMocking(); + capturedOutput = new ArrayList<>(); + listener = new MachineBuildEventListener(capturedOutput::add); + } + + @org.junit.jupiter.api.AfterEach + void afterEach() { + mockitoSession.finishMocking(); + } + + @Test + void testLogEmitsJsonLine() { + listener.log("Hello world"); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.startsWith("{"), "Should be JSON object"); + assertTrue(json.endsWith("}"), "Should be JSON object"); + assertTrue(json.contains("\"event\":\"log\""), "Should have event type"); + assertTrue(json.contains("\"message\":\"Hello world\""), "Should have message"); + assertTrue(json.contains("\"timestamp\":\""), "Should have timestamp"); + } + + @Test + void testProjectLogMessageIncludesModule() { + listener.projectLogMessage( + "my-core", + new DefaultLogEvent( + MonotonicClock.now(), + LogLevel.INFO, + "Compiling 42 source files", + "compiler", + null, + "[INFO] Compiling 42 source files")); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"log\"")); + assertTrue(json.contains("\"level\":\"INFO\"")); + assertTrue(json.contains("\"module\":\"my-core\"")); + assertTrue(json.contains("\"message\":\"Compiling 42 source files\"")); + } + + @Test + void testExecutionFailureEmitsJsonLine() { + listener.executionFailure("my-core", true, "Compilation failed"); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"execution.failure\"")); + assertTrue(json.contains("\"module\":\"my-core\"")); + assertTrue(json.contains("\"halted\":true")); + assertTrue(json.contains("\"error\":\"Compilation failed\"")); + } + + @Test + void testTransferStartedEmitsJsonLine() { + TransferResource resource = mock(TransferResource.class); + when(resource.getResourceName()).thenReturn("org/apache/maven/core/4.1.0/core-4.1.0.jar"); + when(resource.getContentLength()).thenReturn(524288L); + + TransferEvent event = mock(TransferEvent.class); + when(event.getType()).thenReturn(TransferEvent.EventType.STARTED); + when(event.getResource()).thenReturn(resource); + + listener.transfer("my-core", event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"transfer.started\"")); + assertTrue(json.contains("\"artifact\":\"core-4.1.0.jar\"")); + assertTrue(json.contains("\"size\":524288")); + assertTrue(json.contains("\"module\":\"my-core\"")); + } + + @Test + void testTransferProgressedEmitsJsonLine() { + TransferResource resource = mock(TransferResource.class); + when(resource.getResourceName()).thenReturn("org/apache/maven/core/4.1.0/core-4.1.0.jar"); + when(resource.getContentLength()).thenReturn(524288L); + + TransferEvent event = mock(TransferEvent.class); + when(event.getType()).thenReturn(TransferEvent.EventType.PROGRESSED); + when(event.getResource()).thenReturn(resource); + when(event.getTransferredBytes()).thenReturn(262144L); + + listener.transfer("my-core", event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"transfer.progressed\"")); + assertTrue(json.contains("\"transferred\":262144")); + assertTrue(json.contains("\"total\":524288")); + } + + @Test + void testTransferCompletedEmitsJsonLine() { + TransferResource resource = mock(TransferResource.class); + when(resource.getResourceName()).thenReturn("org/apache/maven/core/4.1.0/core-4.1.0.jar"); + when(resource.getContentLength()).thenReturn(524288L); + + TransferEvent event = mock(TransferEvent.class); + when(event.getType()).thenReturn(TransferEvent.EventType.SUCCEEDED); + when(event.getResource()).thenReturn(resource); + when(event.getTransferredBytes()).thenReturn(524288L); + + listener.transfer("my-core", event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"transfer.completed\"")); + assertTrue(json.contains("\"transferred\":524288")); + } + + @Test + void testTransferFailedEmitsJsonLine() { + TransferResource resource = mock(TransferResource.class); + when(resource.getResourceName()).thenReturn("org/apache/maven/core/4.1.0/core-4.1.0.jar"); + when(resource.getContentLength()).thenReturn(524288L); + + TransferEvent event = mock(TransferEvent.class); + when(event.getType()).thenReturn(TransferEvent.EventType.FAILED); + when(event.getResource()).thenReturn(resource); + when(event.getException()).thenReturn(new RuntimeException("Connection timed out")); + + listener.transfer("my-core", event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"transfer.failed\"")); + assertTrue(json.contains("\"error\":\"Connection timed out\"")); + } + + @Test + void testJsonEscapingInMessages() { + listener.log("Message with \"quotes\" and \\backslash and\nnewline"); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + // Verify the JSON is properly escaped + assertTrue(json.contains("\\\"quotes\\\""), "Quotes should be escaped"); + assertTrue(json.contains("\\\\backslash"), "Backslash should be escaped"); + assertTrue(json.contains("\\n"), "Newline should be escaped"); + // Verify it's parseable as a single line (no raw newlines) + assertFalse(json.contains("\n"), "JSON line should not contain raw newlines"); + } + + @Test + void testEmitEventIsSynchronized() throws Exception { + // Verify that concurrent calls to emitEvent don't interleave + List output = new ArrayList<>(); + MachineBuildEventListener concurrentListener = new MachineBuildEventListener(output::add); + + Thread t1 = new Thread(() -> { + for (int i = 0; i < 100; i++) { + concurrentListener.log("Thread1-" + i); + } + }); + Thread t2 = new Thread(() -> { + for (int i = 0; i < 100; i++) { + concurrentListener.log("Thread2-" + i); + } + }); + + t1.start(); + t2.start(); + t1.join(); + t2.join(); + + assertEquals(200, output.size(), "All events should be emitted"); + // Each line should be a complete JSON object + for (String line : output) { + assertTrue(line.startsWith("{") && line.endsWith("}"), "Each line should be a complete JSON object"); + } + } + + @Test + void testNoOpMethods() throws Exception { + // These should not produce any output (handled by MachineExecutionEventLogger) + listener.sessionStarted(null); + listener.projectStarted("my-core"); + listener.projectFinished("my-core"); + listener.mojoStarted(null); + listener.finish(0); + listener.fail(new RuntimeException("error")); + + assertEquals(0, capturedOutput.size(), "No-op methods should not produce output"); + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineExecutionEventLoggerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineExecutionEventLoggerTest.java new file mode 100644 index 000000000000..825f81aa3af0 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineExecutionEventLoggerTest.java @@ -0,0 +1,445 @@ +/* + * 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.apache.maven.cling.event; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.DefaultMavenExecutionRequest; +import org.apache.maven.execution.DefaultMavenExecutionResult; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugin.MojoExecution; +import org.apache.maven.project.MavenProject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.MockitoSession; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link MachineExecutionEventLogger}. + */ +class MachineExecutionEventLoggerTest { + + private MockitoSession mockitoSession; + private List capturedOutput; + private MachineBuildEventListener machineBel; + private MachineExecutionEventLogger logger; + + @BeforeEach + void beforeEach() { + mockitoSession = Mockito.mockitoSession().startMocking(); + capturedOutput = new ArrayList<>(); + machineBel = new MachineBuildEventListener(capturedOutput::add); + logger = new MachineExecutionEventLogger(machineBel); + } + + @AfterEach + void afterEach() { + mockitoSession.finishMocking(); + } + + @Test + void testSessionStartedEmitsBuildStarted() { + MavenProject project1 = createProject("API", "api"); + MavenProject project2 = createProject("Core", "core"); + + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setGoals(List.of("clean", "install")); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project1, project2)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2)); + when(session.getRequest()).thenReturn(request); + + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getSession()).thenReturn(session); + + logger.sessionStarted(event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"build.started\"")); + assertTrue(json.contains("\"projectCount\":2")); + assertTrue(json.contains("\"goals\":\"clean install\"")); + } + + @Test + void testSessionEndedEmitsBuildFinished() { + MavenProject project1 = createProject("API", "api"); + MavenProject project2 = createProject("Core", "core"); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + result.addBuildSummary(new BuildSuccess(project1, 1000)); + result.addBuildSummary(new BuildSuccess(project2, 2000)); + + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setGoals(List.of("install")); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project1, project2)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2)); + when(session.getResult()).thenReturn(result); + when(session.getRequest()).thenReturn(request); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + logger.sessionStarted(sessionEvent); + capturedOutput.clear(); + + logger.sessionEnded(sessionEvent); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"build.finished\"")); + assertTrue(json.contains("\"status\":\"SUCCESS\"")); + assertTrue(json.contains("\"total\":2")); + assertTrue(json.contains("\"passed\":2")); + assertTrue(json.contains("\"failed\":0")); + assertTrue(json.contains("\"skipped\":0")); + assertTrue(json.contains("\"duration\":")); + } + + @Test + void testSessionEndedWithFailures() { + MavenProject project1 = createProject("API", "api"); + MavenProject project2 = createProject("Core", "core"); + MavenProject project3 = createProject("CLI", "cli"); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + result.addBuildSummary(new BuildSuccess(project1, 1000)); + result.addBuildSummary(new BuildFailure(project2, 2000, new Exception("Compile error"))); + result.addException(new Exception("Compile error")); + + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setGoals(List.of("install")); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getResult()).thenReturn(result); + when(session.getRequest()).thenReturn(request); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + logger.sessionStarted(sessionEvent); + capturedOutput.clear(); + + logger.sessionEnded(sessionEvent); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"status\":\"FAILURE\"")); + assertTrue(json.contains("\"passed\":1")); + assertTrue(json.contains("\"failed\":1")); + assertTrue(json.contains("\"skipped\":1")); + } + + @Test + void testProjectStartedEmitsModuleStarted() { + MavenProject project = createProject("Maven Core", "maven-core"); + setupSessionForProjectEvents(project); + + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + + logger.projectStarted(event); + + // build.started + module.started + assertEquals(2, capturedOutput.size()); + String json = capturedOutput.get(1); + assertTrue(json.contains("\"event\":\"module.started\"")); + assertTrue(json.contains("\"module\":\"Maven Core\"")); + assertTrue(json.contains("\"groupId\":\"org.apache.maven\"")); + assertTrue(json.contains("\"artifactId\":\"maven-core\"")); + assertTrue(json.contains("\"version\":\"4.1.0-SNAPSHOT\"")); + assertTrue(json.contains("\"index\":1")); + assertTrue(json.contains("\"total\":1")); + } + + @Test + void testProjectSucceededEmitsModuleSucceeded() { + MavenProject project = createProject("Core", "core"); + MavenSession session = setupSessionForProjectEvents(project); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + result.addBuildSummary(new BuildSuccess(project, 2100)); + when(session.getResult()).thenReturn(result); + + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + when(event.getSession()).thenReturn(session); + + logger.projectSucceeded(event); + + // build.started + module.succeeded + assertEquals(2, capturedOutput.size()); + String json = capturedOutput.get(1); + assertTrue(json.contains("\"event\":\"module.succeeded\"")); + assertTrue(json.contains("\"module\":\"Core\"")); + assertTrue(json.contains("\"duration\":2.1")); + } + + @Test + void testProjectFailedEmitsModuleFailed() { + MavenProject project = createProject("Core", "core"); + MavenSession session = setupSessionForProjectEvents(project); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + result.addBuildSummary(new BuildFailure(project, 5000, new Exception("Compile error"))); + when(session.getResult()).thenReturn(result); + + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + when(event.getSession()).thenReturn(session); + when(event.getException()).thenReturn(new Exception("Compile error")); + + logger.projectFailed(event); + + assertEquals(2, capturedOutput.size()); + String json = capturedOutput.get(1); + assertTrue(json.contains("\"event\":\"module.failed\"")); + assertTrue(json.contains("\"duration\":5.0")); + assertTrue(json.contains("\"error\":\"Compile error\"")); + } + + @Test + void testMojoStartedEmitsJsonLine() { + MavenProject project = createProject("Core", "core"); + MojoExecution mojo = createMojoExecution("maven-compiler-plugin", "compile", "compile", "default-compile"); + + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + when(event.getMojoExecution()).thenReturn(mojo); + + logger.mojoStarted(event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"mojo.started\"")); + assertTrue(json.contains("\"module\":\"Core\"")); + assertTrue(json.contains("\"plugin\":\"maven-compiler-plugin\"")); + assertTrue(json.contains("\"goal\":\"compile\"")); + assertTrue(json.contains("\"phase\":\"compile\"")); + assertTrue(json.contains("\"executionId\":\"default-compile\"")); + } + + @Test + void testMojoSucceededIncludesDuration() { + MavenProject project = createProject("Core", "core"); + MojoExecution mojo = createMojoExecution("maven-compiler-plugin", "compile", "compile", "default-compile"); + + ExecutionEvent startEvent = mock(ExecutionEvent.class); + when(startEvent.getProject()).thenReturn(project); + when(startEvent.getMojoExecution()).thenReturn(mojo); + + ExecutionEvent endEvent = mock(ExecutionEvent.class); + when(endEvent.getProject()).thenReturn(project); + when(endEvent.getMojoExecution()).thenReturn(mojo); + + logger.mojoStarted(startEvent); + capturedOutput.clear(); + + logger.mojoSucceeded(endEvent); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"mojo.succeeded\"")); + assertTrue(json.contains("\"duration\":")); + } + + @Test + void testMojoFailedIncludesError() { + MavenProject project = createProject("Core", "core"); + MojoExecution mojo = createMojoExecution("maven-compiler-plugin", "compile", "compile", "default-compile"); + + ExecutionEvent startEvent = mock(ExecutionEvent.class); + when(startEvent.getProject()).thenReturn(project); + when(startEvent.getMojoExecution()).thenReturn(mojo); + + ExecutionEvent endEvent = mock(ExecutionEvent.class); + when(endEvent.getProject()).thenReturn(project); + when(endEvent.getMojoExecution()).thenReturn(mojo); + when(endEvent.getException()).thenReturn(new Exception("Cannot find symbol: class Foo")); + + logger.mojoStarted(startEvent); + capturedOutput.clear(); + + logger.mojoFailed(endEvent); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"mojo.failed\"")); + assertTrue(json.contains("\"error\":\"Cannot find symbol: class Foo\"")); + } + + @Test + void testMojoSkippedEmitsJsonLine() { + MavenProject project = createProject("Core", "core"); + MojoExecution mojo = createMojoExecution("maven-deploy-plugin", "deploy", "deploy", "default-deploy"); + + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + when(event.getMojoExecution()).thenReturn(mojo); + + logger.mojoSkipped(event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"mojo.skipped\"")); + assertTrue(json.contains("\"plugin\":\"maven-deploy-plugin\"")); + assertTrue(json.contains("\"goal\":\"deploy\"")); + } + + @Test + void testMultiModuleLifecycle() { + MavenProject project1 = createProject("API", "api"); + MavenProject project2 = createProject("Core", "core"); + MavenProject project3 = createProject("CLI", "cli"); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + result.addBuildSummary(new BuildSuccess(project1, 1000)); + result.addBuildSummary(new BuildSuccess(project2, 3000)); + result.addBuildSummary(new BuildSuccess(project3, 2000)); + + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setGoals(List.of("install")); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getResult()).thenReturn(result); + when(session.getRequest()).thenReturn(request); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + // Full lifecycle + logger.sessionStarted(sessionEvent); + + for (MavenProject p : List.of(project1, project2, project3)) { + ExecutionEvent pe = mock(ExecutionEvent.class); + when(pe.getProject()).thenReturn(p); + when(pe.getSession()).thenReturn(session); + logger.projectStarted(pe); + logger.projectSucceeded(pe); + } + + logger.sessionEnded(sessionEvent); + + // build.started + 3*(module.started + module.succeeded) + build.finished = 8 + assertEquals(8, capturedOutput.size()); + + // First event is build.started + assertTrue(capturedOutput.get(0).contains("\"event\":\"build.started\"")); + // Check module indices + assertTrue(capturedOutput.get(1).contains("\"index\":1")); + assertTrue(capturedOutput.get(3).contains("\"index\":2")); + assertTrue(capturedOutput.get(5).contains("\"index\":3")); + // Last event is build.finished + assertTrue(capturedOutput.get(7).contains("\"event\":\"build.finished\"")); + } + + @Test + void testAllOutputIsValidJsonLines() { + MavenProject project = createProject("Core", "core"); + MavenSession session = setupSessionForProjectEvents(project); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + result.addBuildSummary(new BuildSuccess(project, 1000)); + when(session.getResult()).thenReturn(result); + + MojoExecution mojo = createMojoExecution("maven-compiler-plugin", "compile", "compile", "default-compile"); + + ExecutionEvent projectEvent = mock(ExecutionEvent.class); + when(projectEvent.getProject()).thenReturn(project); + when(projectEvent.getSession()).thenReturn(session); + when(projectEvent.getMojoExecution()).thenReturn(mojo); + + // Generate various events + logger.projectStarted(projectEvent); + logger.mojoStarted(projectEvent); + machineBel.log("Some log message"); + logger.mojoSucceeded(projectEvent); + logger.projectSucceeded(projectEvent); + + // All lines should be valid JSON (start with { and end with }) + for (String line : capturedOutput) { + assertTrue(line.startsWith("{"), "Should start with {: " + line); + assertTrue(line.endsWith("}"), "Should end with }: " + line); + // Should not contain raw newlines + assertFalse(line.contains("\n"), "Should not contain raw newlines: " + line); + assertFalse(line.contains("\r"), "Should not contain raw carriage returns: " + line); + } + } + + // ---- Helpers ---- + + private static MavenProject createProject(String name, String artifactId) { + MavenProject project = mock(MavenProject.class); + lenient().when(project.getName()).thenReturn(name); + lenient().when(project.getArtifactId()).thenReturn(artifactId); + lenient().when(project.getGroupId()).thenReturn("org.apache.maven"); + lenient().when(project.getVersion()).thenReturn("4.1.0-SNAPSHOT"); + lenient().when(project.getPackaging()).thenReturn("jar"); + return project; + } + + private static MojoExecution createMojoExecution(String artifactId, String goal, String phase, String executionId) { + MojoExecution mojo = mock(MojoExecution.class); + lenient().when(mojo.getArtifactId()).thenReturn(artifactId); + lenient().when(mojo.getGoal()).thenReturn(goal); + lenient().when(mojo.getLifecyclePhase()).thenReturn(phase); + lenient().when(mojo.getExecutionId()).thenReturn(executionId); + return mojo; + } + + private MavenSession setupSessionForProjectEvents(MavenProject project) { + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setGoals(List.of("install")); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + lenient().when(session.getResult()).thenReturn(result); + when(session.getRequest()).thenReturn(request); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + logger.sessionStarted(sessionEvent); + + return session; + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/PlainExecutionEventLoggerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/PlainExecutionEventLoggerTest.java new file mode 100644 index 000000000000..4503ba4e2615 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/PlainExecutionEventLoggerTest.java @@ -0,0 +1,317 @@ +/* + * 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.apache.maven.cling.event; + +import java.util.List; + +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.DefaultMavenExecutionRequest; +import org.apache.maven.execution.DefaultMavenExecutionResult; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.jline.JLineMessageBuilderFactory; +import org.apache.maven.jline.MessageUtils; +import org.apache.maven.project.MavenProject; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.mockito.Mockito; +import org.mockito.MockitoSession; +import org.slf4j.Logger; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.matches; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link PlainExecutionEventLogger} — the compact one-line-per-module renderer. + */ +class PlainExecutionEventLoggerTest { + + private MockitoSession mockitoSession; + + private Logger logger; + private PlainExecutionEventLogger plainLogger; + private final JLineMessageBuilderFactory messageBuilderFactory = new JLineMessageBuilderFactory(); + + @BeforeAll + static void setUp() { + MessageUtils.setColorEnabled(false); + } + + @AfterAll + static void tearDown() { + MessageUtils.setColorEnabled(true); + } + + @BeforeEach + void beforeEach() { + mockitoSession = Mockito.mockitoSession().startMocking(); + logger = mock(Logger.class); + lenient().when(logger.isInfoEnabled()).thenReturn(true); + lenient().when(logger.isWarnEnabled()).thenReturn(true); + plainLogger = new PlainExecutionEventLogger(messageBuilderFactory, logger); + } + + @AfterEach + void afterEach() { + mockitoSession.finishMocking(); + } + + @Test + void testProjectStartedSuppressed() { + // In plain mode, projectStarted should produce NO output + ExecutionEvent event = mock(ExecutionEvent.class); + + plainLogger.projectStarted(event); + + // Verify no logger calls were made + verify(logger, never()).info(anyString()); + } + + @Test + void testMojoStartedSuppressed() { + // In plain mode, mojoStarted should produce NO output + ExecutionEvent event = mock(ExecutionEvent.class); + + plainLogger.mojoStarted(event); + + verify(logger, never()).info(anyString()); + } + + @Test + void testSingleProjectSucceeded() { + MavenProject project = generateMavenProject("Maven Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project, 2100)); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + ExecutionEvent projectEvent = mock(ExecutionEvent.class); + when(projectEvent.getProject()).thenReturn(project); + when(projectEvent.getSession()).thenReturn(session); + + plainLogger.sessionStarted(sessionEvent); + plainLogger.projectSucceeded(projectEvent); + + // Single project: no progress counter + verify(logger).info(matches("Maven Core.*SUCCESS.*2\\.1")); + } + + @Test + void testMultiModuleProjectOneLinePerModule() { + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + MavenProject project3 = generateMavenProject("CLI"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project1, 1000)); + executionResult.addBuildSummary(new BuildSuccess(project2, 3000)); + executionResult.addBuildSummary(new BuildSuccess(project3, 2000)); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + plainLogger.sessionStarted(sessionEvent); + + // Simulate lifecycle + ExecutionEvent event1 = mockProjectEvent(project1, session); + ExecutionEvent event2 = mockProjectEvent(project2, session); + ExecutionEvent event3 = mockProjectEvent(project3, session); + + plainLogger.projectSucceeded(event1); + plainLogger.projectSucceeded(event2); + plainLogger.projectSucceeded(event3); + + // Multi-module: each line has progress counter [n/total] + InOrder inOrder = inOrder(logger); + inOrder.verify(logger).info(matches("API.*\\[1/3\\].*SUCCESS.*1\\.0")); + inOrder.verify(logger).info(matches("Core.*\\[2/3\\].*SUCCESS.*3\\.0")); + inOrder.verify(logger).info(matches("CLI.*\\[3/3\\].*SUCCESS.*2\\.0")); + } + + @Test + void testProjectFailedShowsFailure() { + MavenProject project = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildFailure(project, 5000, new Exception("Compile error"))); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + plainLogger.sessionStarted(sessionEvent); + + ExecutionEvent event = mockProjectEvent(project, session); + plainLogger.projectFailed(event); + + verify(logger).info(matches("Core.*FAILURE.*5\\.0")); + } + + @Test + void testSessionEndedShowsSummary() { + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project1, 1000)); + executionResult.addBuildSummary(new BuildSuccess(project2, 2000)); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + when(session.getProjects()).thenReturn(List.of(project1, project2)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2)); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + plainLogger.sessionStarted(sessionEvent); + plainLogger.sessionEnded(sessionEvent); + + // Verify BUILD SUCCESS and stats + InOrder inOrder = inOrder(logger); + inOrder.verify(logger).info(""); + inOrder.verify(logger).info("BUILD SUCCESS"); + inOrder.verify(logger).info(matches("2 modules.*2 passed")); + inOrder.verify(logger).info(eq("Total time: {}{}"), anyString(), anyString()); + inOrder.verify(logger).info("Full report: target/build-reports/build-report-latest.json"); + } + + @Test + void testSessionEndedWithFailures() { + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + MavenProject project3 = generateMavenProject("CLI"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project1, 1000)); + executionResult.addBuildSummary(new BuildFailure(project2, 2000, new Exception("Error"))); + executionResult.addException(new Exception("Error")); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + when(session.getProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + plainLogger.sessionStarted(sessionEvent); + plainLogger.sessionEnded(sessionEvent); + + InOrder inOrder = inOrder(logger); + inOrder.verify(logger).info(""); + inOrder.verify(logger).info("BUILD FAILURE"); + inOrder.verify(logger).info(matches("3 modules.*1 passed.*1 failed.*1 skipped")); + } + + @Test + void testMojoSkippedStillWarns() { + ExecutionEvent event = mock(ExecutionEvent.class); + var mojoExec = mock(org.apache.maven.plugin.MojoExecution.class); + when(mojoExec.getGoal()).thenReturn("deploy"); + when(event.getMojoExecution()).thenReturn(mojoExec); + + plainLogger.mojoSkipped(event); + + verify(logger).warn(anyString(), eq("deploy")); + } + + @Test + void testResumeFromProgress() { + // When resuming, allProjects > projects (some already built) + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + MavenProject project3 = generateMavenProject("CLI"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project2, 1000)); + executionResult.addBuildSummary(new BuildSuccess(project3, 2000)); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project2, project3)); // resumed from project2 + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + plainLogger.sessionStarted(sessionEvent); + + ExecutionEvent event2 = mockProjectEvent(project2, session); + ExecutionEvent event3 = mockProjectEvent(project3, session); + + plainLogger.projectSucceeded(event2); + plainLogger.projectSucceeded(event3); + + // Progress should start from 2/3, not 1/3 + InOrder inOrder = inOrder(logger); + inOrder.verify(logger).info(matches("Core.*\\[2/3\\].*SUCCESS")); + inOrder.verify(logger).info(matches("CLI.*\\[3/3\\].*SUCCESS")); + } + + // ---- Helpers ---- + + private static MavenProject generateMavenProject(String projectName) { + MavenProject project = mock(MavenProject.class); + lenient().when(project.getPackaging()).thenReturn("jar"); + lenient().when(project.getVersion()).thenReturn("4.1.0-SNAPSHOT"); + lenient().when(project.getName()).thenReturn(projectName); + return project; + } + + private static ExecutionEvent mockProjectEvent(MavenProject project, MavenSession session) { + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + when(event.getSession()).thenReturn(session); + return event; + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichBuildEventListenerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichBuildEventListenerTest.java new file mode 100644 index 000000000000..1c7dafe2e950 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichBuildEventListenerTest.java @@ -0,0 +1,332 @@ +/* + * 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.apache.maven.cling.event; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.List; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.internal.build.DefaultLogEvent; +import org.apache.maven.project.MavenProject; +import org.eclipse.aether.transfer.TransferEvent; +import org.eclipse.aether.transfer.TransferResource; +import org.jline.terminal.Size; +import org.jline.terminal.Terminal; +import org.jline.terminal.impl.DumbTerminal; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.MockitoSession; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link RichBuildEventListener}. + */ +class RichBuildEventListenerTest { + + private MockitoSession mockitoSession; + private Terminal terminal; + private ByteArrayOutputStream terminalOutput; + private RichBuildEventListener listener; + + @BeforeEach + void beforeEach() throws Exception { + mockitoSession = Mockito.mockitoSession().startMocking(); + terminalOutput = new ByteArrayOutputStream(); + // DumbTerminal: supported=false (fallback mode), output goes to terminalOutput + terminal = new DumbTerminal(new ByteArrayInputStream(new byte[0]), terminalOutput); + terminal.setSize(new Size(120, 40)); + listener = new RichBuildEventListener(terminal, msg -> {}); + } + + @AfterEach + void afterEach() throws Exception { + terminal.close(); + mockitoSession.finishMocking(); + } + + @Test + void testProjectStartedAndFinished() { + MavenSession session = + createSession(List.of(createProject("api"), createProject("core"), createProject("cli"))); + + listener.initReactor(session); + + listener.projectStarted("api"); + listener.projectFinished("api"); + } + + @Test + void testLogMessagePassthrough() { + listener.log("Test log message"); + String output = terminalOutput.toString(); + assertTrue(output.contains("Test log message"), "Expected log message in output: " + output); + } + + @Test + void testProjectLogMessageFiltersInfo() { + LogEvent infoEvent = new DefaultLogEvent( + MonotonicClock.now(), + LogLevel.INFO, + "Compiling 42 source files", + "compiler", + null, + "[INFO] Compiling 42 source files"); + listener.projectLogMessage("api", infoEvent); + String output = terminalOutput.toString(); + assertFalse( + output.contains("Compiling 42 source files"), + "INFO messages should be suppressed in rich mode: " + output); + } + + @Test + void testProjectLogMessageSuppressesWarningInline() { + // In rich mode, warnings are suppressed inline and only counted for the + // end-of-build summary — they don't scroll above the status bar. + LogEvent warnEvent = new DefaultLogEvent( + MonotonicClock.now(), + LogLevel.WARN, + "Deprecated API usage", + "compiler", + null, + "[WARNING] Deprecated API usage"); + listener.projectLogMessage("api", warnEvent); + String output = terminalOutput.toString(); + assertFalse( + output.contains("[WARNING] Deprecated API usage"), + "WARN messages should be suppressed inline in rich mode: " + output); + assertEquals(1, listener.getWarningCount(), "Warning count should be tracked"); + } + + @Test + void testProjectLogMessageShowsError() { + LogEvent errorEvent = new DefaultLogEvent( + MonotonicClock.now(), + LogLevel.ERROR, + "Compilation failure", + "compiler", + null, + "[ERROR] Compilation failure"); + listener.projectLogMessage("api", errorEvent); + String output = terminalOutput.toString(); + assertTrue(output.contains("[ERROR] Compilation failure"), "ERROR messages should pass through: " + output); + } + + @Test + void testMojoStartedUpdatesState() { + MavenSession session = createSession(List.of(createProject("api"), createProject("core"))); + listener.initReactor(session); + + listener.projectStarted("api"); + + ExecutionEvent mojoEvent = mock(ExecutionEvent.class); + MavenProject project = createProject("api"); + when(mojoEvent.getProject()).thenReturn(project); + var mojoExec = mock(org.apache.maven.plugin.MojoExecution.class); + when(mojoExec.getArtifactId()).thenReturn("maven-compiler-plugin"); + when(mojoExec.getGoal()).thenReturn("compile"); + when(mojoEvent.getMojoExecution()).thenReturn(mojoExec); + + listener.mojoStarted(mojoEvent); + + listener.projectFinished("api"); + } + + @Test + void testTransferEvents() { + MavenSession session = createSession(List.of(createProject("api"))); + listener.initReactor(session); + + TransferResource resource = mock(TransferResource.class); + when(resource.getResourceName()).thenReturn("org/apache/maven/core/4.1.0/core-4.1.0.jar"); + when(resource.getContentLength()).thenReturn(524288L); + + TransferEvent startEvent = mock(TransferEvent.class); + when(startEvent.getType()).thenReturn(TransferEvent.EventType.STARTED); + when(startEvent.getResource()).thenReturn(resource); + + listener.transfer("api", startEvent); + + TransferEvent progressEvent = mock(TransferEvent.class); + when(progressEvent.getType()).thenReturn(TransferEvent.EventType.PROGRESSED); + when(progressEvent.getResource()).thenReturn(resource); + when(progressEvent.getTransferredBytes()).thenReturn(262144L); + + listener.transfer("api", progressEvent); + + TransferEvent doneEvent = mock(TransferEvent.class); + when(doneEvent.getType()).thenReturn(TransferEvent.EventType.SUCCEEDED); + when(doneEvent.getResource()).thenReturn(resource); + + listener.transfer("api", doneEvent); + } + + @Test + void testParallelProjects() { + MavenSession session = + createSession(List.of(createProject("api"), createProject("core"), createProject("cli"))); + listener.initReactor(session); + + listener.projectStarted("api"); + listener.projectStarted("core"); + + listener.projectFinished("api"); + + listener.projectStarted("cli"); + + listener.projectFinished("core"); + listener.projectFinished("cli"); + } + + @Test + void testExecutionFailureUpdatesState() { + MavenSession session = createSession(List.of(createProject("api"))); + listener.initReactor(session); + + listener.projectStarted("api"); + listener.executionFailure("api", true, "Compilation error"); + listener.projectFinished("api"); + } + + @Test + void testTearDown() throws Exception { + MavenSession session = createSession(List.of(createProject("api"))); + listener.initReactor(session); + listener.projectStarted("api"); + + listener.tearDown(); + } + + @Test + void testFinishCallsTearDown() throws Exception { + MavenSession session = createSession(List.of(createProject("api"))); + listener.initReactor(session); + + listener.finish(0); + } + + @Test + void testFailCallsTearDown() throws Exception { + MavenSession session = createSession(List.of(createProject("api"))); + listener.initReactor(session); + + listener.fail(new RuntimeException("build error")); + } + + @Test + void testLargeReactorProgressBar() { + // With 50 modules on a 120-char terminal, maxIndicators = (120-40)/3 = 26. + // 50 > 26, so the summary line should use a progress bar instead of + // per-module ✓/●/○ indicators. + List projects = new java.util.ArrayList<>(); + for (int i = 0; i < 50; i++) { + projects.add(createProject("module-" + i)); + } + MavenSession session = createSession(projects); + listener.initReactor(session); + + // Start and finish some modules to exercise the progress bar rendering + for (int i = 0; i < 10; i++) { + listener.projectStarted("module-" + i); + } + for (int i = 0; i < 5; i++) { + listener.projectFinished("module-" + i); + } + // 5 completed, 5 active, 40 pending — should render without error + for (int i = 5; i < 10; i++) { + listener.projectFinished("module-" + i); + } + } + + @Test + void testSmallReactorUsesIndicators() { + // With 3 modules on a 120-char terminal, maxIndicators = 26 > 3, + // so the summary line should use per-module ✓/●/○ indicators. + MavenSession session = + createSession(List.of(createProject("api"), createProject("core"), createProject("cli"))); + listener.initReactor(session); + + listener.projectStarted("api"); + listener.projectFinished("api"); + listener.projectStarted("core"); + listener.projectFinished("core"); + listener.projectStarted("cli"); + listener.projectFinished("cli"); + } + + @Test + void testTruncateAnsiPlainText() { + String truncated = RichBuildEventListener.truncateAnsi("hello world", 5); + // When truncated, a RESET escape is appended to close any open styling + assertTrue(truncated.startsWith("hello"), "Should start with 'hello': " + truncated); + // Should not contain characters beyond "hello" (except ANSI reset) + String stripped = truncated.replaceAll("\033\\[[^a-zA-Z]*[a-zA-Z]", ""); + assertEquals("hello", stripped); + } + + @Test + void testTruncateAnsiPreservesEscapeSequences() { + // ANSI color codes should not count toward visible length + String colored = "\033[1mhello\033[0m world"; + String truncated = RichBuildEventListener.truncateAnsi(colored, 5); + // Should keep "hello" (5 visible chars) with the bold prefix + assertTrue(truncated.contains("hello"), "Truncated should contain 'hello': " + truncated); + assertTrue(truncated.contains("\033[1m"), "Truncated should preserve ANSI prefix"); + } + + @Test + void testTruncateAnsiNoTruncationNeeded() { + String s = "short"; + assertEquals(s, RichBuildEventListener.truncateAnsi(s, 100)); + } + + // ---- Helpers ---- + + private static MavenProject createProject(String artifactId) { + MavenProject project = mock(MavenProject.class); + lenient().when(project.getArtifactId()).thenReturn(artifactId); + lenient().when(project.getName()).thenReturn(artifactId); + lenient().when(project.getPackaging()).thenReturn("jar"); + lenient().when(project.getVersion()).thenReturn("4.1.0-SNAPSHOT"); + return project; + } + + private static MavenSession createSession(List projects) { + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(projects); + when(session.getAllProjects()).thenReturn(projects); + MavenExecutionRequest request = mock(MavenExecutionRequest.class); + lenient().when(request.getDegreeOfConcurrency()).thenReturn(1); + lenient().when(session.getRequest()).thenReturn(request); + return session; + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichExecutionEventLoggerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichExecutionEventLoggerTest.java new file mode 100644 index 000000000000..762d71548430 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichExecutionEventLoggerTest.java @@ -0,0 +1,424 @@ +/* + * 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.apache.maven.cling.event; + +import java.io.ByteArrayOutputStream; +import java.time.Instant; +import java.util.List; + +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.DefaultMavenExecutionRequest; +import org.apache.maven.execution.DefaultMavenExecutionResult; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.internal.build.DefaultLogEvent; +import org.apache.maven.jline.JLineMessageBuilderFactory; +import org.apache.maven.jline.MessageUtils; +import org.apache.maven.project.MavenProject; +import org.jline.terminal.Size; +import org.jline.terminal.Terminal; +import org.jline.terminal.impl.DumbTerminal; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.MockitoSession; +import org.slf4j.Logger; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link RichExecutionEventLogger}. + *

+ * In rich mode, all output goes directly to the terminal writer via + * {@link RichBuildEventListener#log(String)}, bypassing SLF4J entirely. + * Tests capture the terminal output to verify content. + */ +class RichExecutionEventLoggerTest { + + private MockitoSession mockitoSession; + private Logger logger; + private Terminal terminal; + private ByteArrayOutputStream terminalOutput; + private RichBuildEventListener buildEventListener; + private RichExecutionEventLogger richLogger; + private final JLineMessageBuilderFactory messageBuilderFactory = new JLineMessageBuilderFactory(); + + @BeforeAll + static void setUp() { + MessageUtils.setColorEnabled(false); + } + + @AfterAll + static void tearDown() { + MessageUtils.setColorEnabled(true); + } + + @BeforeEach + void beforeEach() throws Exception { + mockitoSession = Mockito.mockitoSession().startMocking(); + logger = mock(Logger.class); + lenient().when(logger.isInfoEnabled()).thenReturn(true); + lenient().when(logger.isWarnEnabled()).thenReturn(true); + terminalOutput = new ByteArrayOutputStream(); + terminal = new DumbTerminal(System.in, terminalOutput); + terminal.setSize(new Size(120, 40)); + buildEventListener = new RichBuildEventListener(terminal, msg -> {}); + richLogger = new RichExecutionEventLogger(messageBuilderFactory, buildEventListener, logger); + } + + @AfterEach + void afterEach() throws Exception { + terminal.close(); + mockitoSession.finishMocking(); + } + + @Test + void testProjectStartedSuppressed() { + // In rich mode, projectStarted should produce NO output (status bar handles it) + ExecutionEvent event = mock(ExecutionEvent.class); + + richLogger.projectStarted(event); + + verify(logger, never()).info(anyString()); + assertTrue(terminalOutput.toString().isEmpty(), "No terminal output expected"); + } + + @Test + void testMojoStartedSuppressed() { + // In rich mode, mojoStarted should produce NO output (status bar handles it) + ExecutionEvent event = mock(ExecutionEvent.class); + + richLogger.mojoStarted(event); + + verify(logger, never()).info(anyString()); + assertTrue(terminalOutput.toString().isEmpty(), "No terminal output expected"); + } + + @Test + void testProjectSucceededSuppressed() { + // In rich mode, projectSucceeded produces NO scrolling output — + // the status bar checkmarks already indicate completion. + MavenProject project = generateMavenProject("Maven Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project, 2100)); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + lenient().when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + richLogger.sessionStarted(sessionEvent); + + // Clear terminal output accumulated from sessionStarted (status bar init) + terminalOutput.reset(); + + ExecutionEvent projectEvent = mock(ExecutionEvent.class); + lenient().when(projectEvent.getProject()).thenReturn(project); + lenient().when(projectEvent.getSession()).thenReturn(session); + + richLogger.projectSucceeded(projectEvent); + + // No output — SUCCESS lines are suppressed in rich mode + String output = terminalOutput.toString(); + assertFalse(output.contains("SUCCESS"), "SUCCESS line should be suppressed in rich mode"); + } + + @Test + void testProjectFailedShowsCross() { + MavenProject project = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildFailure(project, 5000, new Exception("Compile error"))); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + richLogger.sessionStarted(sessionEvent); + + ExecutionEvent event = mockProjectEvent(project, session); + richLogger.projectFailed(event); + + String output = terminalOutput.toString(); + assertTrue(output.contains("Core"), "Should contain project name"); + assertTrue(output.contains("FAILURE"), "Should contain FAILURE status"); + } + + @Test + void testMultiModuleSuccessSuppressed() { + // In rich mode, per-module SUCCESS lines are suppressed — the status bar + // already shows ✓/●/○ indicators and the [n/total] counter. + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + MavenProject project3 = generateMavenProject("CLI"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project1, 1000)); + executionResult.addBuildSummary(new BuildSuccess(project2, 3000)); + executionResult.addBuildSummary(new BuildSuccess(project3, 2000)); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + lenient().when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + richLogger.sessionStarted(sessionEvent); + + // Clear terminal output from sessionStarted + terminalOutput.reset(); + + // mockProjectEvent stubs are unused since projectSucceeded is a no-op; + // call with a plain mock to avoid UnnecessaryStubbing errors. + richLogger.projectSucceeded(mock(ExecutionEvent.class)); + richLogger.projectSucceeded(mock(ExecutionEvent.class)); + richLogger.projectSucceeded(mock(ExecutionEvent.class)); + + String output = terminalOutput.toString(); + // No per-module SUCCESS lines should appear + assertFalse(output.contains("SUCCESS"), "SUCCESS lines should be suppressed in rich mode"); + assertFalse(output.contains("[1/3]"), "Progress counters should not appear"); + } + + @Test + void testSessionEndedShowsSummary() { + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project1, 1000)); + executionResult.addBuildSummary(new BuildSuccess(project2, 2000)); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + when(session.getProjects()).thenReturn(List.of(project1, project2)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2)); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + richLogger.sessionStarted(sessionEvent); + richLogger.sessionEnded(sessionEvent); + + // Summary goes to terminal writer, not logger + String output = terminalOutput.toString(); + assertTrue(output.contains("BUILD SUCCESS"), "Should contain BUILD SUCCESS"); + assertTrue(output.contains("2 modules"), "Should contain module count"); + assertTrue(output.contains("2 passed"), "Should contain passed count"); + assertTrue(output.contains("Total time:"), "Should contain total time"); + assertTrue( + output.contains("Full report: target/build-reports/build-report-latest.json"), + "Should contain report path"); + // MNG-7372: version info only on failure, not success + assertFalse(output.contains("Maven:"), "Should NOT contain Maven version on success"); + } + + @Test + void testSessionEndedWithFailures() { + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + MavenProject project3 = generateMavenProject("CLI"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project1, 1000)); + executionResult.addBuildSummary(new BuildFailure(project2, 2000, new Exception("Error"))); + executionResult.addException(new Exception("Error")); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + when(session.getProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + richLogger.sessionStarted(sessionEvent); + richLogger.sessionEnded(sessionEvent); + + String output = terminalOutput.toString(); + assertTrue(output.contains("BUILD FAILURE"), "Should contain BUILD FAILURE"); + assertTrue(output.contains("3 modules"), "Should contain module count"); + assertTrue(output.contains("1 passed"), "Should contain passed count"); + assertTrue(output.contains("1 failed"), "Should contain failed count"); + assertTrue(output.contains("1 skipped"), "Should contain skipped count"); + // MNG-7372: version info shown on failure + assertTrue(output.contains("Maven:"), "Should contain Maven version on failure"); + assertTrue(output.contains("Java:"), "Should contain Java version on failure"); + } + + @Test + void testMojoSkippedStillWarns() { + // mojoSkipped still uses logger.warn (goes through SLF4J for WARN level) + ExecutionEvent event = mock(ExecutionEvent.class); + var mojoExec = mock(org.apache.maven.plugin.MojoExecution.class); + when(mojoExec.getGoal()).thenReturn("deploy"); + when(event.getMojoExecution()).thenReturn(mojoExec); + + richLogger.mojoSkipped(event); + + verify(logger).warn(anyString(), eq("deploy")); + } + + @Test + void testOutputBypassesSLF4J() { + // Verify that summary output does NOT go through logger.info + MavenProject project = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project, 1000)); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + richLogger.sessionStarted(sessionEvent); + richLogger.sessionEnded(sessionEvent); + + // No logger.info calls — all output goes through terminal writer + verify(logger, never()).info(anyString()); + + // But the output IS in the terminal + String output = terminalOutput.toString(); + assertFalse(output.isEmpty(), "Terminal should have output"); + assertTrue(output.contains("BUILD SUCCESS"), "Terminal should contain BUILD SUCCESS"); + } + + @Test + void testWarningSummaryShown() { + MavenProject project = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project, 1000)); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + richLogger.sessionStarted(sessionEvent); + + // Simulate 3 warnings arriving during the build + Instant now = Instant.now(); + buildEventListener.projectLogMessage( + "core", + new DefaultLogEvent(now, LogLevel.WARN, "unchecked cast", "javac", null, "[WARNING] unchecked cast")); + buildEventListener.projectLogMessage( + "core", + new DefaultLogEvent(now, LogLevel.WARN, "deprecated API", "javac", null, "[WARNING] deprecated API")); + buildEventListener.projectLogMessage( + "core", + new DefaultLogEvent(now, LogLevel.WARN, "unused import", "javac", null, "[WARNING] unused import")); + + richLogger.sessionEnded(sessionEvent); + + String output = terminalOutput.toString(); + assertTrue(output.contains("BUILD SUCCESS"), "Should contain BUILD SUCCESS"); + assertTrue(output.contains("3 warning"), "Should contain warning count"); + assertTrue(output.contains("Diagnostics:"), "Should contain Diagnostics label"); + assertTrue(output.contains("mvnlog"), "Should hint how to see warning details"); + } + + @Test + void testNoWarningSummaryWhenClean() { + MavenProject project = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project, 1000)); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + richLogger.sessionStarted(sessionEvent); + richLogger.sessionEnded(sessionEvent); + + String output = terminalOutput.toString(); + assertTrue(output.contains("BUILD SUCCESS"), "Should contain BUILD SUCCESS"); + assertFalse(output.contains("Diagnostics:"), "Should NOT contain Diagnostics when no warnings"); + } + + // ---- Helpers ---- + + private static MavenProject generateMavenProject(String projectName) { + MavenProject project = mock(MavenProject.class); + lenient() + .when(project.getArtifactId()) + .thenReturn(projectName.toLowerCase().replace(" ", "-")); + lenient().when(project.getPackaging()).thenReturn("jar"); + lenient().when(project.getVersion()).thenReturn("4.1.0-SNAPSHOT"); + lenient().when(project.getName()).thenReturn(projectName); + return project; + } + + private static ExecutionEvent mockProjectEvent(MavenProject project, MavenSession session) { + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + when(event.getSession()).thenReturn(session); + return event; + } +}