From 6b5da2ba0b1883ac9c1306e54e4ca3e66301e452 Mon Sep 17 00:00:00 2001 From: Vaclav Haisman Date: Sun, 20 Sep 2026 09:22:50 +0200 Subject: [PATCH] Expose Maven logging output capabilities Provide a singleton service describing the current logging destination and charset, plus an immutable execution-request data map for plugins compiled against older Maven APIs. Capture the map at the start of every Maven execution, preserving retained maps through request reuse and cleanup. Capture the actual logging route, preserve initialization and cleanup behavior, and treat negative exec-provider probes as unknown. Cover injection, request compatibility access, resident lifecycle where supported, and terminal detection. --- .../maven/cling/invoker/LookupContext.java | 6 + .../maven/cling/invoker/LookupInvoker.java | 12 + .../cling/invoker/PlexusContainerCapsule.java | 10 + .../internal/TerminalOutputCapabilities.java | 138 +++++++++++ .../LookupInvokerOutputCapabilitiesTest.java | 129 +++++++++++ .../ResidentOutputCapabilitiesTest.java | 177 ++++++++++++++ .../logging/internal/ExecTerminalProbe.java | 54 +++++ .../TerminalOutputCapabilitiesTest.java | 158 +++++++++++++ .../java/org/apache/maven/DefaultMaven.java | 6 + .../maven/logging/OutputCapabilities.java | 77 +++++++ .../internal/DefaultOutputCapabilities.java | 110 +++++++++ .../resources/META-INF/maven/extension.xml | 1 + .../maven/DefaultMavenSessionScopeTest.java | 2 + .../maven/OutputCapabilitiesRequestTest.java | 217 ++++++++++++++++++ .../DefaultOutputCapabilitiesTest.java | 111 +++++++++ .../internal/OutputCapabilitiesMapTest.java | 183 +++++++++++++++ .../it/MavenITOutputCapabilitiesTest.java | 61 +++++ .../consumer/child/pom.xml | 25 ++ .../output-capabilities/consumer/pom.xml | 56 +++++ .../output-capabilities/legacy-plugin/pom.xml | 63 +++++ .../maven/its/output/LegacyOutputMojo.java | 82 +++++++ .../output-capabilities/plugin/pom.xml | 69 ++++++ .../apache/maven/its/output/OutputMojo.java | 60 +++++ 23 files changed, 1807 insertions(+) create mode 100644 impl/maven-cli/src/main/java/org/apache/maven/logging/internal/TerminalOutputCapabilities.java create mode 100644 impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/LookupInvokerOutputCapabilitiesTest.java create mode 100644 impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/resident/ResidentOutputCapabilitiesTest.java create mode 100644 impl/maven-cli/src/test/java/org/apache/maven/logging/internal/ExecTerminalProbe.java create mode 100644 impl/maven-cli/src/test/java/org/apache/maven/logging/internal/TerminalOutputCapabilitiesTest.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/logging/OutputCapabilities.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/logging/internal/DefaultOutputCapabilities.java create mode 100644 impl/maven-core/src/test/java/org/apache/maven/OutputCapabilitiesRequestTest.java create mode 100644 impl/maven-core/src/test/java/org/apache/maven/logging/internal/DefaultOutputCapabilitiesTest.java create mode 100644 impl/maven-core/src/test/java/org/apache/maven/logging/internal/OutputCapabilitiesMapTest.java create mode 100644 its/core-it-suite/src/test/java/org/apache/maven/it/MavenITOutputCapabilitiesTest.java create mode 100644 its/core-it-suite/src/test/resources/output-capabilities/consumer/child/pom.xml create mode 100644 its/core-it-suite/src/test/resources/output-capabilities/consumer/pom.xml create mode 100644 its/core-it-suite/src/test/resources/output-capabilities/legacy-plugin/pom.xml create mode 100644 its/core-it-suite/src/test/resources/output-capabilities/legacy-plugin/src/main/java/org/apache/maven/its/output/LegacyOutputMojo.java create mode 100644 its/core-it-suite/src/test/resources/output-capabilities/plugin/pom.xml create mode 100644 its/core-it-suite/src/test/resources/output-capabilities/plugin/src/main/java/org/apache/maven/its/output/OutputMojo.java diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupContext.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupContext.java index bf156b69498d..defddb125499 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupContext.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupContext.java @@ -26,6 +26,7 @@ import java.util.Map; import java.util.Set; import java.util.function.Consumer; +import java.util.function.Supplier; import org.apache.maven.api.ProtoSession; import org.apache.maven.api.cli.InvokerException; @@ -38,6 +39,8 @@ import org.apache.maven.cling.logging.Slf4jConfiguration; import org.apache.maven.eventspy.internal.EventSpyDispatcher; import org.apache.maven.logging.BuildEventListener; +import org.apache.maven.logging.OutputCapabilities; +import org.apache.maven.logging.internal.DefaultOutputCapabilities; import org.jline.terminal.Terminal; import org.slf4j.ILoggerFactory; @@ -90,6 +93,9 @@ public LookupContext(InvokerRequest invokerRequest, boolean containerCapsuleMana public Terminal terminal; public Consumer writer; + // Set only by a known writer route; custom listeners and writers remain unknown. + public Supplier outputCapabilities = () -> DefaultOutputCapabilities.UNKNOWN; + public ContainerCapsule containerCapsule; public Lookup lookup; public EventSpyDispatcher eventSpyDispatcher; 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 3e027ef4bbf2..304cbb804100 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 @@ -24,6 +24,7 @@ import java.io.OutputStream; import java.io.PrintStream; import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; @@ -81,9 +82,12 @@ import org.apache.maven.logging.AsyncDrainWriter; import org.apache.maven.logging.BuildEventListener; import org.apache.maven.logging.LoggingOutputStream; +import org.apache.maven.logging.OutputCapabilities.Destination; import org.apache.maven.logging.ProjectBuildLogAppender; import org.apache.maven.logging.SimpleBuildEventListener; import org.apache.maven.logging.api.LogLevelRecorder; +import org.apache.maven.logging.internal.DefaultOutputCapabilities; +import org.apache.maven.logging.internal.TerminalOutputCapabilities; import org.apache.maven.slf4j.MavenSimpleLogger; import org.codehaus.plexus.PlexusContainer; import org.jline.terminal.Terminal; @@ -427,10 +431,18 @@ protected Consumer doDetermineWriter(C context) { PrintWriter printWriter = new PrintWriter(Files.newBufferedWriter(logFile), true); context.closeables.add(printWriter); raw = printWriter::println; + context.outputCapabilities = + () -> DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8); } catch (IOException e) { throw new MavenException("Unable to redirect logging to " + logFile, e); } } else { + // Resolve this after terminal initialization, when the container is ready. + context.outputCapabilities = () -> DefaultOutputCapabilities.snapshot( + context.invokerRequest.embedded() + ? Destination.UNKNOWN + : TerminalOutputCapabilities.destination(context.terminal), + context.terminal.outputEncoding()); // Given the terminal creation has been offloaded to a different thread, // do not pass directly the terminal writer raw = msg -> { diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/PlexusContainerCapsule.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/PlexusContainerCapsule.java index 99effbf4e260..9964066ccd49 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/PlexusContainerCapsule.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/PlexusContainerCapsule.java @@ -22,6 +22,9 @@ import org.apache.maven.api.services.Lookup; import org.apache.maven.internal.impl.DefaultLookup; +import org.apache.maven.logging.OutputCapabilities; +import org.apache.maven.logging.internal.DefaultOutputCapabilities; +import org.apache.maven.slf4j.MavenLoggerFactory; import org.codehaus.plexus.DefaultPlexusContainer; import static java.util.Objects.requireNonNull; @@ -46,6 +49,13 @@ public PlexusContainerCapsule( @Override public void updateLogging(LookupContext context) { plexusContainer.getLoggerManager().setThresholds(toPlexusLoggingLevel(context.loggerLevel)); + OutputCapabilities capabilities = lookup.lookup(OutputCapabilities.class); + if (capabilities instanceof DefaultOutputCapabilities defaultCapabilities) { + context.closeables.add(defaultCapabilities.install( + context.loggerFactory instanceof MavenLoggerFactory + ? context.outputCapabilities.get() + : DefaultOutputCapabilities.UNKNOWN)); + } } @Override diff --git a/impl/maven-cli/src/main/java/org/apache/maven/logging/internal/TerminalOutputCapabilities.java b/impl/maven-cli/src/main/java/org/apache/maven/logging/internal/TerminalOutputCapabilities.java new file mode 100644 index 000000000000..ac01909b43d3 --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/logging/internal/TerminalOutputCapabilities.java @@ -0,0 +1,138 @@ +/* + * 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.logging.internal; + +import java.util.List; + +import org.apache.maven.jline.FastTerminal; +import org.apache.maven.logging.OutputCapabilities.Destination; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; +import org.jline.terminal.impl.DumbTerminalProvider; +import org.jline.terminal.impl.exec.ExecTerminalProvider; +import org.jline.terminal.spi.SystemStream; +import org.jline.terminal.spi.TerminalExt; +import org.jline.terminal.spi.TerminalProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Internal terminal attachment detection for the CLI's system-output route. */ +public final class TerminalOutputCapabilities { + private static final Logger LOGGER = LoggerFactory.getLogger(TerminalOutputCapabilities.class); + + private TerminalOutputCapabilities() {} + + public static Destination destination(Terminal terminal) { + try { + if (terminal instanceof FastTerminal fastTerminal) { + LOGGER.debug("Resolving asynchronous terminal for logging output detection"); + terminal = fastTerminal.getTerminal(); + } + if (!(terminal instanceof TerminalExt extended)) { + LOGGER.debug( + "Logging destination is UNKNOWN: terminal {} does not expose its provider and system stream", + terminal == null ? null : terminal.getClass().getName()); + return Destination.UNKNOWN; + } + SystemStream stream = extended.getSystemStream(); + TerminalProvider provider = extended.getProvider(); + LOGGER.debug( + "Inspecting logging output: terminal={}, provider={}, stream={}", + terminal.getClass().getName(), + provider == null ? null : provider.getClass().getName(), + stream); + if (provider != null && !(provider instanceof DumbTerminalProvider)) { + // A non-system terminal may be backed by arbitrary embedder streams. + if (stream == null) { + LOGGER.debug("Logging destination is UNKNOWN: terminal provider has no system stream"); + return Destination.UNKNOWN; + } + return probe(provider, stream); + } + if (!(provider instanceof DumbTerminalProvider)) { + LOGGER.debug("Logging destination is UNKNOWN: terminal has no provider"); + return Destination.UNKNOWN; + } + // A dumb terminal can also result from redirected stdin or failed terminal + // creation. Ask the configured providers about output, not terminal type. + SystemStream outputStream = stream != null ? stream : SystemStream.Output; + String configuredProviders = System.getProperty(TerminalBuilder.PROP_PROVIDER); + LOGGER.debug( + "Dumb terminal provider: probing configured providers {} for logging stream {}", + configuredProviders, + outputStream); + IllegalStateException failure = new IllegalStateException("Unable to inspect terminal output"); + List providers = TerminalBuilder.builder().getProviders(configuredProviders, failure); + if (failure.getSuppressed().length > 0) { + LOGGER.debug("Some terminal providers could not be loaded for logging output detection", failure); + } + return probe(providers, outputStream); + } catch (RuntimeException | LinkageError e) { + LOGGER.debug("Logging destination is UNKNOWN: terminal output detection failed", e); + return Destination.UNKNOWN; + } + } + + static Destination probe(List providers, SystemStream stream) { + boolean redirected = false; + for (TerminalProvider provider : providers) { + if (provider instanceof DumbTerminalProvider) { + LOGGER.debug("Skipping dumb terminal provider when probing logging stream {}", stream); + continue; + } + Destination result = probe(provider, stream); + if (result == Destination.CONSOLE) { + return result; + } + redirected |= result == Destination.REDIRECTED; + } + Destination result = redirected ? Destination.REDIRECTED : Destination.UNKNOWN; + LOGGER.debug("Logging destination for stream {} after probing configured providers: {}", stream, result); + return result; + } + + private static Destination probe(TerminalProvider provider, SystemStream stream) { + String providerClass = provider == null ? null : provider.getClass().getName(); + try { + Destination result; + if (provider.isSystemStream(stream)) { + result = Destination.CONSOLE; + } else if (provider instanceof ExecTerminalProvider) { + // The exec provider also returns false when its external probe fails. + // A negative result cannot distinguish redirection from failed detection. + LOGGER.debug( + "Logging destination is UNKNOWN: exec terminal provider {} returned an inconclusive negative result for stream {}", + providerClass, + stream); + return Destination.UNKNOWN; + } else { + result = Destination.REDIRECTED; + } + LOGGER.debug("Terminal provider {} reports logging stream {} as {}", providerClass, stream, result); + return result; + } catch (RuntimeException | LinkageError e) { + LOGGER.debug( + "Logging destination is UNKNOWN: terminal provider {} failed to inspect stream {}", + providerClass, + stream, + e); + return Destination.UNKNOWN; + } + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/LookupInvokerOutputCapabilitiesTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/LookupInvokerOutputCapabilitiesTest.java new file mode 100644 index 000000000000..1d75d1abf432 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/LookupInvokerOutputCapabilitiesTest.java @@ -0,0 +1,129 @@ +/* + * 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.invoker; + +import java.io.ByteArrayOutputStream; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.function.Consumer; + +import org.apache.maven.api.cli.InvokerRequest; +import org.apache.maven.api.cli.ParserRequest; +import org.apache.maven.api.cli.mvn.MavenOptions; +import org.apache.maven.api.services.MavenException; +import org.apache.maven.cling.invoker.mvn.MavenContext; +import org.apache.maven.cling.invoker.mvn.MavenInvoker; +import org.apache.maven.logging.OutputCapabilities; +import org.apache.maven.logging.OutputCapabilities.Destination; +import org.jline.terminal.Terminal; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LookupInvokerOutputCapabilitiesTest { + @TempDir + Path directory; + + @Test + void logFileUsesUtf8AndDrainsBeforeClosing() throws Exception { + Path file = directory.resolve("build.log"); + try (MavenContext context = context(Optional.of(file.toString()))) { + Consumer writer = new TestInvoker().writer(context); + OutputCapabilities capabilities = context.outputCapabilities.get(); + assertEquals(Destination.FILE, capabilities.getDestination()); + assertEquals(Optional.of(StandardCharsets.UTF_8), capabilities.getEncoding()); + writer.accept("caf\u00e9"); + } + assertArrayEquals( + ("caf\u00e9" + System.lineSeparator()).getBytes(StandardCharsets.UTF_8), Files.readAllBytes(file)); + } + + @Test + void failedFileDoesNotPublishFileCapabilities() throws Exception { + try (MavenContext context = context(Optional.of(directory.toString()))) { + assertThrows(MavenException.class, () -> new TestInvoker().writer(context)); + assertEquals(Destination.UNKNOWN, context.outputCapabilities.get().getDestination()); + assertEquals(Optional.empty(), context.outputCapabilities.get().getEncoding()); + } + } + + @Test + void embeddedWriterHasKnownEncodingButUnknownDestination() throws Exception { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (MavenContext context = context(Optional.empty())) { + Terminal terminal = mock(Terminal.class); + when(terminal.outputEncoding()).thenReturn(StandardCharsets.ISO_8859_1); + when(terminal.writer()) + .thenReturn(new PrintWriter(new OutputStreamWriter(bytes, StandardCharsets.ISO_8859_1))); + context.terminal = terminal; + Consumer writer = new TestInvoker().writer(context); + assertEquals(Destination.UNKNOWN, context.outputCapabilities.get().getDestination()); + assertEquals( + Optional.of(StandardCharsets.ISO_8859_1), + context.outputCapabilities.get().getEncoding()); + writer.accept("caf\u00e9"); + } + assertArrayEquals( + ("caf\u00e9" + System.lineSeparator()).getBytes(StandardCharsets.ISO_8859_1), bytes.toByteArray()); + } + + @Test + void customWriterRemainsUnknown() throws Exception { + try (MavenContext context = context(Optional.empty())) { + Consumer custom = ignored -> {}; + context.writer = custom; + assertSame(custom, new TestInvoker().writer(context)); + assertEquals(Destination.UNKNOWN, context.outputCapabilities.get().getDestination()); + assertEquals(Optional.empty(), context.outputCapabilities.get().getEncoding()); + } + } + + private MavenContext context(Optional logFile) { + InvokerRequest request = mock(InvokerRequest.class); + when(request.cwd()).thenReturn(directory); + when(request.installationDirectory()).thenReturn(directory); + when(request.userHomeDirectory()).thenReturn(directory); + when(request.topDirectory()).thenReturn(directory); + when(request.parserRequest()).thenReturn(mock(ParserRequest.class)); + when(request.embedded()).thenReturn(true); + MavenOptions options = mock(MavenOptions.class); + when(options.logFile()).thenReturn(logFile); + return new MavenContext(request, false, options); + } + + private static class TestInvoker extends MavenInvoker { + TestInvoker() { + super(ProtoLookup.builder().build(), null); + } + + Consumer writer(MavenContext context) { + return determineWriter(context); + } + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/resident/ResidentOutputCapabilitiesTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/resident/ResidentOutputCapabilitiesTest.java new file mode 100644 index 000000000000..aa7f8be54090 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/resident/ResidentOutputCapabilitiesTest.java @@ -0,0 +1,177 @@ +/* + * 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.invoker.mvn.resident; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.apache.maven.api.cli.InvokerException; +import org.apache.maven.api.cli.InvokerRequest; +import org.apache.maven.api.cli.ParserRequest; +import org.apache.maven.cling.invoker.ProtoLookup; +import org.apache.maven.cling.invoker.mvn.MavenContext; +import org.apache.maven.cling.invoker.mvn.MavenParser; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.jline.JLineMessageBuilderFactory; +import org.apache.maven.logging.OutputCapabilities; +import org.apache.maven.logging.OutputCapabilities.Destination; +import org.apache.maven.logging.SimpleBuildEventListener; +import org.apache.maven.logging.internal.DefaultOutputCapabilities; +import org.codehaus.plexus.classworlds.ClassWorld; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ResidentOutputCapabilitiesTest { + @TempDir + Path directory; + + @Test + void reusedContainerRefreshesCapabilitiesButPreservesEarlierMaps() throws Exception { + Files.createDirectories(directory.resolve(".mvn")); + Files.writeString( + directory.resolve("pom.xml"), + "4.0.0test" + + "output-capabilities1" + + "pom"); + Path file = directory.resolve("build.log"); + try (ClassWorld world = new ClassWorld("plexus.core", getClass().getClassLoader()); + InspectingInvoker invoker = new InspectingInvoker(world)) { + assertEquals(0, invoker.invoke(request(List.of("-l", file.toString(), "--color=always", "validate")))); + OutputCapabilities component = invoker.component; + Map fileMap = invoker.captured; + assertEquals("FILE", invoker.captured.get("destination")); + assertEquals("UTF-8", invoker.captured.get("encoding")); + assertEquals(Destination.FILE, invoker.destination); + assertEquals(Optional.of(StandardCharsets.UTF_8), invoker.encoding); + assertEquals(Destination.UNKNOWN, component.getDestination()); + assertEquals(Optional.empty(), component.getEncoding()); + assertEquals("FILE", fileMap.get("destination")); + assertEquals("UTF-8", fileMap.get("encoding")); + assertTrue(Files.readString(file).contains("output-capabilities-marker")); + + assertEquals(0, invoker.invoke(request(List.of("validate")))); + assertSame(component, invoker.component); + assertNotSame(fileMap, invoker.captured); + assertEquals(Destination.UNKNOWN, invoker.destination); // embedded byte stream + assertTrue(invoker.encoding.isPresent()); + Map embeddedMap = invoker.captured; + assertEquals("UNKNOWN", embeddedMap.get("destination")); + assertEquals(invoker.encoding.get().name(), embeddedMap.get("encoding")); + assertEquals(Optional.empty(), component.getEncoding()); + assertEquals("FILE", fileMap.get("destination")); + assertEquals("UTF-8", fileMap.get("encoding")); + + invoker.fail = true; + InvokerException.ExitException failure = assertThrows( + InvokerException.ExitException.class, + () -> invoker.invoke(request(List.of("-l", file.toString(), "validate")))); + assertEquals(2, failure.getExitCode()); + assertSame(component, invoker.component); + assertNotSame(fileMap, invoker.captured); + assertEquals(Destination.FILE, invoker.destination); + assertEquals(fileMap, invoker.captured); + assertEquals("UNKNOWN", embeddedMap.get("destination")); + assertTrue(embeddedMap.containsKey("encoding")); + assertEquals(Destination.UNKNOWN, component.getDestination()); + assertEquals(Optional.empty(), component.getEncoding()); + assertEquals("FILE", fileMap.get("destination")); + assertEquals("UTF-8", fileMap.get("encoding")); + } + } + + @Test + void customBuildListenerDoesNotInheritTerminalMetadata() throws Exception { + Files.createDirectories(directory.resolve(".mvn")); + Files.writeString( + directory.resolve("pom.xml"), + "4.0.0test" + + "output-capabilities1" + + "pom"); + try (ClassWorld world = new ClassWorld("plexus.core", getClass().getClassLoader()); + InspectingInvoker invoker = new InspectingInvoker(world)) { + invoker.customListener = true; + assertEquals(0, invoker.invoke(request(List.of("validate")))); + assertEquals(Destination.UNKNOWN, invoker.destination); + assertEquals(Optional.empty(), invoker.encoding); + } + } + + private InvokerRequest request(List arguments) throws Exception { + return new MavenParser() + .parseInvocation(ParserRequest.mvn(arguments, new JLineMessageBuilderFactory()) + .cwd(directory) + .userHome(directory) + .stdOut(new ByteArrayOutputStream()) + .stdErr(new ByteArrayOutputStream()) + .embedded(true) + .build()); + } + + private static class InspectingInvoker extends ResidentMavenInvoker { + private OutputCapabilities component; + private Destination destination; + private Optional encoding; + private Map captured; + private boolean fail; + private boolean customListener; + + InspectingInvoker(ClassWorld world) { + super(ProtoLookup.builder().addMapping(ClassWorld.class, world).build(), null); + } + + @Override + protected MavenContext createContext(InvokerRequest request) { + MavenContext context = super.createContext(request); + if (customListener) { + context.buildEventListener = new SimpleBuildEventListener(ignored -> {}); + } + return context; + } + + @Override + protected int doExecute(MavenContext context, MavenExecutionRequest request) throws Exception { + component = context.lookup.lookup(OutputCapabilities.class); + destination = component.getDestination(); + encoding = component.getEncoding(); + request.setLocalRepositoryPath(context.cwd.resolve("repository").toFile()); + int result = super.doExecute(context, request); + assertEquals(0, result); + captured = (Map) request.getData().get("maven.logging.outputCapabilities"); + assertSame(((DefaultOutputCapabilities) component).asMap(), captured); + assertEquals(destination.name(), captured.get("destination")); + assertEquals(encoding.map(java.nio.charset.Charset::name).orElse(null), captured.get("encoding")); + context.logger.info("output-capabilities-marker"); + if (fail) { + throw new IllegalStateException("test invocation failure"); + } + return result; + } + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/logging/internal/ExecTerminalProbe.java b/impl/maven-cli/src/test/java/org/apache/maven/logging/internal/ExecTerminalProbe.java new file mode 100644 index 000000000000..a4d634107198 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/logging/internal/ExecTerminalProbe.java @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.logging.internal; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; + +import org.apache.maven.logging.OutputCapabilities.Destination; +import org.jline.terminal.impl.exec.ExecTerminalProvider; +import org.jline.terminal.spi.SystemStream; +import org.jline.utils.OSUtils; + +/** Subprocess entry point for testing the real exec provider with an empty PATH. */ +public final class ExecTerminalProbe { + private ExecTerminalProbe() {} + + public static void main(String[] args) throws Exception { + ExecTerminalProvider provider = new ExecTerminalProvider(); + for (SystemStream stream : Arrays.asList(SystemStream.Output, SystemStream.Error)) { + try { + Process unexpected = + new ProcessBuilder(OSUtils.TEST_COMMAND, "-t", Integer.toString(stream.ordinal())).start(); + unexpected.destroyForcibly(); + throw new AssertionError("Expected the external terminal probe command to be unavailable"); + } catch (IOException expected) { + // The provider swallows this failure and returns false instead of throwing. + } + if (provider.isSystemStream(stream)) { + throw new AssertionError("Expected an inconclusive negative exec probe for " + stream); + } + Destination destination = TerminalOutputCapabilities.probe(Collections.singletonList(provider), stream); + if (destination != Destination.UNKNOWN) { + throw new AssertionError(stream + ": expected UNKNOWN, got " + destination); + } + } + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/logging/internal/TerminalOutputCapabilitiesTest.java b/impl/maven-cli/src/test/java/org/apache/maven/logging/internal/TerminalOutputCapabilitiesTest.java new file mode 100644 index 000000000000..706f67c7968a --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/logging/internal/TerminalOutputCapabilitiesTest.java @@ -0,0 +1,158 @@ +/* + * 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.logging.internal; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +import org.apache.maven.logging.OutputCapabilities.Destination; +import org.jline.terminal.Terminal; +import org.jline.terminal.impl.DumbTerminalProvider; +import org.jline.terminal.impl.exec.ExecTerminalProvider; +import org.jline.terminal.spi.SystemStream; +import org.jline.terminal.spi.TerminalExt; +import org.jline.terminal.spi.TerminalProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class TerminalOutputCapabilitiesTest { + @TempDir + Path directory; + + @Test + void negativeExecProbesAreInconclusive() { + for (SystemStream stream : Arrays.asList(SystemStream.Output, SystemStream.Error)) { + ExecTerminalProvider provider = mock(ExecTerminalProvider.class); + TerminalExt terminal = mock(TerminalExt.class); + when(terminal.getProvider()).thenReturn(provider); + when(terminal.getSystemStream()).thenReturn(stream); + when(provider.isSystemStream(stream)).thenReturn(true); + assertEquals(Destination.CONSOLE, TerminalOutputCapabilities.destination(terminal)); + assertEquals( + Destination.CONSOLE, TerminalOutputCapabilities.probe(Collections.singletonList(provider), stream)); + when(provider.isSystemStream(stream)).thenReturn(false); + assertEquals(Destination.UNKNOWN, TerminalOutputCapabilities.destination(terminal)); + assertEquals( + Destination.UNKNOWN, TerminalOutputCapabilities.probe(Collections.singletonList(provider), stream)); + } + } + + @Test + void inconclusiveExecProbesDoNotOverrideOtherProviders() { + for (SystemStream stream : Arrays.asList(SystemStream.Output, SystemStream.Error)) { + ExecTerminalProvider exec = mock(ExecTerminalProvider.class); + TerminalProvider other = mock(TerminalProvider.class); + for (boolean console : Arrays.asList(false, true)) { + when(other.isSystemStream(stream)).thenReturn(console); + Destination expected = console ? Destination.CONSOLE : Destination.REDIRECTED; + assertEquals(expected, TerminalOutputCapabilities.probe(Arrays.asList(exec, other), stream)); + assertEquals(expected, TerminalOutputCapabilities.probe(Arrays.asList(other, exec), stream)); + } + when(other.isSystemStream(stream)).thenThrow(new UnsatisfiedLinkError()); + assertEquals(Destination.UNKNOWN, TerminalOutputCapabilities.probe(Arrays.asList(exec, other), stream)); + assertEquals(Destination.UNKNOWN, TerminalOutputCapabilities.probe(Arrays.asList(other, exec), stream)); + } + } + + @Test + @EnabledOnOs({OS.LINUX, OS.MAC}) + void missingExecCommandDoesNotMeanRedirection() throws Exception { + Path emptyPath = Files.createDirectory(directory.resolve("empty-path")); + Path output = directory.resolve("probe.log"); + String java = Paths.get(System.getProperty("java.home"), "bin", "java").toString(); + String classpath = System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")); + ProcessBuilder builder = new ProcessBuilder(java, "-cp", classpath, ExecTerminalProbe.class.getName()); + builder.environment().put("PATH", emptyPath.toString()); + builder.redirectErrorStream(true).redirectOutput(output.toFile()); + Process process = builder.start(); + try { + process.getOutputStream().close(); + boolean exited = process.waitFor(30, TimeUnit.SECONDS); + String diagnostics = new String(Files.readAllBytes(output), StandardCharsets.UTF_8); + assertTrue(exited, "Terminal probe timed out: " + diagnostics); + assertEquals(0, process.exitValue(), diagnostics); + } finally { + if (process.isAlive()) { + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + } + } + } + + @Test + void unavailableProvidersDoNotMeanRedirection() { + assertEquals( + Destination.UNKNOWN, TerminalOutputCapabilities.probe(Collections.emptyList(), SystemStream.Output)); + assertEquals( + Destination.UNKNOWN, + TerminalOutputCapabilities.probe( + Collections.singletonList(new DumbTerminalProvider()), SystemStream.Output)); + TerminalProvider failing = mock(TerminalProvider.class); + when(failing.isSystemStream(SystemStream.Output)).thenThrow(new UnsatisfiedLinkError()); + assertEquals( + Destination.UNKNOWN, + TerminalOutputCapabilities.probe(Collections.singletonList(failing), SystemStream.Output)); + } + + @Test + void probesTheOutputStreamEvenForDumbTerminalType() { + TerminalExt terminal = mock(TerminalExt.class); + TerminalProvider provider = mock(TerminalProvider.class); + when(terminal.getProvider()).thenReturn(provider); + when(terminal.getSystemStream()).thenReturn(SystemStream.Error); + when(terminal.getType()).thenReturn(Terminal.TYPE_DUMB); + when(provider.isSystemStream(SystemStream.Error)).thenReturn(true); + assertEquals(Destination.CONSOLE, TerminalOutputCapabilities.destination(terminal)); + when(provider.isSystemStream(SystemStream.Error)).thenReturn(false); + assertEquals(Destination.REDIRECTED, TerminalOutputCapabilities.destination(terminal)); + } + + @Test + void customTerminalDoesNotImplyConsole() { + TerminalExt terminal = mock(TerminalExt.class); + when(terminal.getProvider()).thenReturn(mock(TerminalProvider.class)); + assertEquals(Destination.UNKNOWN, TerminalOutputCapabilities.destination(terminal)); + assertEquals(Destination.UNKNOWN, TerminalOutputCapabilities.destination(mock(Terminal.class))); + } + + @Test + void oneSuccessfulProviderCanEstablishConsoleAttachment() { + TerminalProvider redirected = mock(TerminalProvider.class); + TerminalProvider console = mock(TerminalProvider.class); + when(console.isSystemStream(SystemStream.Output)).thenReturn(true); + assertEquals( + Destination.CONSOLE, + TerminalOutputCapabilities.probe(Arrays.asList(redirected, console), SystemStream.Output)); + assertEquals( + Destination.REDIRECTED, + TerminalOutputCapabilities.probe(Collections.singletonList(redirected), SystemStream.Output)); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java b/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java index ba5ea30d1db0..cae5ef99160c 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java +++ b/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java @@ -64,6 +64,7 @@ import org.apache.maven.lifecycle.LifecycleExecutionException; import org.apache.maven.lifecycle.internal.ExecutionEventCatapult; import org.apache.maven.lifecycle.internal.LifecycleStarter; +import org.apache.maven.logging.internal.DefaultOutputCapabilities; import org.apache.maven.model.building.ModelProblem; import org.apache.maven.model.building.Result; import org.apache.maven.model.superpom.SuperPomProvider; @@ -109,6 +110,8 @@ public class DefaultMaven implements Maven { private final DefaultSessionFactory defaultSessionFactory; + private final DefaultOutputCapabilities outputCapabilities; + private final WorkspaceReader ideWorkspaceReader; private final ProjectSelector projectSelector; @@ -126,6 +129,7 @@ public DefaultMaven( BuildResumptionDataRepository buildResumptionDataRepository, SuperPomProvider superPomProvider, DefaultSessionFactory defaultSessionFactory, + DefaultOutputCapabilities outputCapabilities, @Nullable @Named("ide") WorkspaceReader ideWorkspaceReader) { this.lookup = lookup; this.eventCatapult = eventCatapult; @@ -138,6 +142,7 @@ public DefaultMaven( this.superPomProvider = superPomProvider; this.ideWorkspaceReader = ideWorkspaceReader; this.defaultSessionFactory = defaultSessionFactory; + this.outputCapabilities = outputCapabilities; this.projectSelector = new ProjectSelector(); // if necessary switch to DI } @@ -146,6 +151,7 @@ public MavenExecutionResult execute(MavenExecutionRequest request) { MavenExecutionResult result; try { + request.getData().put(DefaultOutputCapabilities.REQUEST_DATA_KEY, outputCapabilities.asMap()); result = doExecute(request); } catch (OutOfMemoryError e) { result = addExceptionToResult(new DefaultMavenExecutionResult(), e); diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/OutputCapabilities.java b/impl/maven-core/src/main/java/org/apache/maven/logging/OutputCapabilities.java new file mode 100644 index 000000000000..d7b1189cdc61 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/logging/OutputCapabilities.java @@ -0,0 +1,77 @@ +/* + * 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.logging; + +import java.nio.charset.Charset; +import java.util.Optional; + +/** + * Describes the current destination of Maven logging, including plugin log messages. + * The information follows logging configuration and is shared by all projects in a build. + * It does not describe files or streams written directly by a plugin. + * + *

Plugins compiled against older Maven APIs can obtain the same information without + * referencing this interface through + * {@code session.getRequest().getData().get("maven.logging.outputCapabilities")}. + * The value is an immutable {@code Map}. Its {@code destination} + * entry is always present and contains {@code CONSOLE}, {@code FILE}, {@code REDIRECTED}, + * or {@code UNKNOWN}. Its {@code encoding} entry contains the canonical + * {@link Charset#name()} when known and is otherwise absent, independently of destination. + * An absent request-data entry means the service is unavailable (for example, on older + * Maven versions); a present map with {@code UNKNOWN} means detection is unavailable. + * Clients should ignore additional keys and treat unfamiliar destinations as unknown. + * + *

The map captures the logging configuration when + * {@link org.apache.maven.Maven#execute(org.apache.maven.execution.MavenExecutionRequest)} + * begins, before validation and lifecycle callbacks. The entry is absent before execution + * unless it remains from an earlier execution of the same request. Each execution replaces + * the entry with a fresh capture, including when the request is copied or reused. + * The map and its collection views remain unchanged through logging cleanup, + * reconfiguration, and subsequent invocations. A retained map captured while information + * is unknown remains unknown. Cleanup does not remove the entry from the request. + * Unlike the map, this component's getters describe the current logging configuration + * and may therefore differ from a retained request's map after reconfiguration or cleanup. + */ +public interface OutputCapabilities { + /** The known kind of logging destination. */ + enum Destination { + /** Output is attached to a terminal, independently of whether color is enabled. */ + CONSOLE, + /** Maven or its logging provider opened an explicit file destination. */ + FILE, + /** Output is not attached to a terminal; its final destination is not known. */ + REDIRECTED, + /** The logging destination could not be determined. */ + UNKNOWN + } + + /** + * Returns the known kind of logging destination. + * @return the destination, never {@code null} + */ + Destination getDestination(); + + /** + * Returns the charset selected for the logging output route, if known. + * Encoding may be known even when the destination is unknown. It does not imply + * support for terminal control sequences or guarantee that a font contains a glyph. + * @return the output charset, or an empty optional when it cannot be determined + */ + Optional getEncoding(); +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/internal/DefaultOutputCapabilities.java b/impl/maven-core/src/main/java/org/apache/maven/logging/internal/DefaultOutputCapabilities.java new file mode 100644 index 000000000000..917c3557f024 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/logging/internal/DefaultOutputCapabilities.java @@ -0,0 +1,110 @@ +/* + * 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.logging.internal; + +import javax.inject.Named; +import javax.inject.Singleton; + +import java.nio.charset.Charset; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import org.apache.maven.logging.OutputCapabilities; + +/** + * Internal bridge between logging setup and the read-only component exposed to plugins. + * Publication and restoration follow the logging lifecycle, not the build session. + */ +@Named +@Singleton +public class DefaultOutputCapabilities implements OutputCapabilities { + public static final String REQUEST_DATA_KEY = "maven.logging.outputCapabilities"; + + public static final OutputCapabilities UNKNOWN = snapshot(Destination.UNKNOWN, null); + + private volatile Snapshot current = (Snapshot) UNKNOWN; + + /** Internal access to an immutable description of the current logging configuration. */ + public Map asMap() { + return current.properties; + } + + @Override + public Destination getDestination() { + return current.getDestination(); + } + + @Override + public Optional getEncoding() { + return current.getEncoding(); + } + + /** Creates an immutable description for transport from logging setup to the container. */ + public static OutputCapabilities snapshot(Destination destination, Charset encoding) { + return new Snapshot(Objects.requireNonNull(destination), encoding); + } + + /** + * Installs the current output description and returns its logging-lifecycle cleanup. + * An obsolete cleanup must not overwrite a subsequently installed configuration. + */ + public synchronized AutoCloseable install(OutputCapabilities capabilities) { + Snapshot previous = current; + Snapshot installed = new Snapshot( + capabilities.getDestination(), capabilities.getEncoding().orElse(null)); + current = installed; + return () -> { + synchronized (DefaultOutputCapabilities.this) { + if (current == installed) { + current = previous; + } + } + }; + } + + private static final class Snapshot implements OutputCapabilities { + private final Destination destination; + private final Optional encoding; + private final Map properties; + + private Snapshot(Destination destination, Charset encoding) { + this.destination = destination; + this.encoding = Optional.ofNullable(encoding); + Map values = new LinkedHashMap<>(); + values.put("destination", destination.name()); + if (encoding != null) { + values.put("encoding", encoding.name()); + } + this.properties = Collections.unmodifiableMap(values); + } + + @Override + public Destination getDestination() { + return destination; + } + + @Override + public Optional getEncoding() { + return encoding; + } + } +} diff --git a/impl/maven-core/src/main/resources/META-INF/maven/extension.xml b/impl/maven-core/src/main/resources/META-INF/maven/extension.xml index 56667ed69e35..da0f5d703e7e 100644 --- a/impl/maven-core/src/main/resources/META-INF/maven/extension.xml +++ b/impl/maven-core/src/main/resources/META-INF/maven/extension.xml @@ -35,6 +35,7 @@ under the License. org.apache.maven.feature org.apache.maven.graph org.apache.maven.lifecycle + org.apache.maven.logging.* org.apache.maven.model org.apache.maven.monitor org.apache.maven.plugin diff --git a/impl/maven-core/src/test/java/org/apache/maven/DefaultMavenSessionScopeTest.java b/impl/maven-core/src/test/java/org/apache/maven/DefaultMavenSessionScopeTest.java index 67d3eb3e6d21..c13374459216 100644 --- a/impl/maven-core/src/test/java/org/apache/maven/DefaultMavenSessionScopeTest.java +++ b/impl/maven-core/src/test/java/org/apache/maven/DefaultMavenSessionScopeTest.java @@ -29,6 +29,7 @@ import org.apache.maven.graph.GraphBuilder; import org.apache.maven.internal.impl.DefaultSessionFactory; import org.apache.maven.lifecycle.internal.ExecutionEventCatapult; +import org.apache.maven.logging.internal.DefaultOutputCapabilities; import org.apache.maven.plugin.LegacySupport; import org.apache.maven.resolver.RepositorySystemSessionFactory; import org.apache.maven.session.scope.internal.SessionScope; @@ -77,6 +78,7 @@ void testSessionScopeIsExitedOnWorkspaceReaderError() throws Exception { mock(BuildResumptionDataRepository.class), null, mock(DefaultSessionFactory.class), + new DefaultOutputCapabilities(), null); MavenExecutionResult result = defaultMaven.execute(request); diff --git a/impl/maven-core/src/test/java/org/apache/maven/OutputCapabilitiesRequestTest.java b/impl/maven-core/src/test/java/org/apache/maven/OutputCapabilitiesRequestTest.java new file mode 100644 index 000000000000..f530d4bfcc66 --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/OutputCapabilitiesRequestTest.java @@ -0,0 +1,217 @@ +/* + * 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; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.apache.maven.execution.DefaultMavenExecutionRequest; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.logging.OutputCapabilities.Destination; +import org.apache.maven.logging.internal.DefaultOutputCapabilities; +import org.codehaus.plexus.testing.PlexusTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@PlexusTest +class OutputCapabilitiesRequestTest extends AbstractCoreMavenComponentTestCase { + private static final String KEY = "maven.logging.outputCapabilities"; + + @Inject + private Maven maven; + + @Inject + private DefaultOutputCapabilities capabilities; + + @Override + protected String getProjectsDirectory() { + return "src/test/projects/default-maven"; + } + + @Test + void capturesEveryConfigurationBeforeRepositoryValidation(@TempDir Path directory) throws Exception { + Path invalidRepository = Files.createFile(directory.resolve("repository-file")); + MavenExecutionRequest request = + new DefaultMavenExecutionRequest().setLocalRepositoryPath(invalidRepository.toFile()); + request.getData().put("unrelated", "retained"); + assertNull(request.getData().get(KEY)); + for (Destination destination : Destination.values()) { + for (Charset encoding : Arrays.asList(null, StandardCharsets.UTF_8, Charset.forName("latin1"))) { + Map captured; + try (AutoCloseable cleanup = + capabilities.install(DefaultOutputCapabilities.snapshot(destination, encoding))) { + captured = capabilities.asMap(); + MavenExecutionResult result = maven.execute(request); + assertFalse(result.getExceptions().isEmpty()); + assertSame(captured, request.getData().get(KEY)); + assertEquals(destination.name(), captured.get("destination")); + assertEquals(encoding == null ? null : encoding.name(), captured.get("encoding")); + assertEquals("retained", request.getData().get("unrelated")); + } + assertSame(captured, request.getData().get(KEY)); + assertEquals(destination.name(), captured.get("destination")); + assertEquals(encoding == null ? null : encoding.name(), captured.get("encoding")); + assertEquals(Destination.UNKNOWN, capabilities.getDestination()); + } + } + } + + @Test + void publishesBeforeLifecycleCallbacksAndParallelReaders() throws Exception { + MavenExecutionRequest request = request("simple"); + assertNull(request.getData().get(KEY)); + RequestObserver observer = observer(); + try (AutoCloseable cleanup = + capabilities.install(DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8))) { + executeSuccessfully(request); + Map captured = capabilities.asMap(); + assertSame(request, observer.session.getRequest()); + assertSame(captured, observer.captured); + assertNull(observer.resolverMetadata); + assertSame( + captured, + CompletableFuture.supplyAsync(() -> observer.session + .clone() + .getRequest() + .getData() + .get(KEY)) + .get(10, TimeUnit.SECONDS)); + assertEquals("UTF-8", captured.get("encoding")); + } + assertEquals("FILE", observer.captured.get("destination")); + assertEquals(Destination.UNKNOWN, capabilities.getDestination()); + assertEquals(Optional.empty(), capabilities.getEncoding()); + } + + @Test + void copiedIndependentAndReusedRequestsCaptureTheirOwnExecution() throws Exception { + MavenExecutionRequest original = request("simple"); + original.getData().put("unrelated", "original"); + Map fileMap; + Map consoleMap; + try (AutoCloseable file = + capabilities.install(DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8))) { + executeSuccessfully(original); + fileMap = capabilities.asMap(); + MavenExecutionRequest copy = DefaultMavenExecutionRequest.copy(original); + assertTrue(copy.getData().isEmpty()); + copy.getData().put("unrelated", "copy"); + MavenExecutionRequest independent = request("simple"); + assertNull(independent.getData().get(KEY)); + try (AutoCloseable console = capabilities.install( + DefaultOutputCapabilities.snapshot(Destination.CONSOLE, StandardCharsets.ISO_8859_1))) { + consoleMap = capabilities.asMap(); + executeSuccessfully(copy); + executeSuccessfully(independent); + assertSame(consoleMap, copy.getData().get(KEY)); + assertSame(consoleMap, independent.getData().get(KEY)); + assertSame(fileMap, original.getData().get(KEY)); + assertEquals("original", original.getData().get("unrelated")); + assertEquals("copy", copy.getData().get("unrelated")); + assertNull(independent.getData().get("unrelated")); + executeSuccessfully(original); + assertSame(consoleMap, original.getData().get(KEY)); + assertEquals("original", original.getData().get("unrelated")); + assertEquals("FILE", fileMap.get("destination")); + } + assertSame(consoleMap, original.getData().get(KEY)); + executeSuccessfully(original); + assertSame(fileMap, original.getData().get(KEY)); + assertEquals("CONSOLE", consoleMap.get("destination")); + assertEquals("ISO-8859-1", consoleMap.get("encoding")); + } + assertSame(fileMap, original.getData().get(KEY)); + executeSuccessfully(original); + assertSame(capabilities.asMap(), original.getData().get(KEY)); + assertEquals("UNKNOWN", capabilities.asMap().get("destination")); + assertFalse(capabilities.asMap().containsKey("encoding")); + assertEquals("original", original.getData().get("unrelated")); + assertEquals("FILE", fileMap.get("destination")); + assertEquals("UTF-8", fileMap.get("encoding")); + assertEquals("CONSOLE", consoleMap.get("destination")); + } + + @Test + void projectDiscoveryFailurePreservesTheCapturedMap() throws Exception { + MavenExecutionRequest request = request("cyclic-reference"); + RequestObserver observer = observer(); + Map captured; + try (AutoCloseable cleanup = capabilities.install( + DefaultOutputCapabilities.snapshot(Destination.UNKNOWN, StandardCharsets.ISO_8859_1))) { + captured = capabilities.asMap(); + MavenExecutionResult result = maven.execute(request); + assertEquals( + ProjectCycleException.class, result.getExceptions().get(0).getClass()); + assertSame(captured, observer.captured); + assertNull(observer.resolverMetadata); + } + assertSame(captured, request.getData().get(KEY)); + assertEquals("UNKNOWN", captured.get("destination")); + assertEquals("ISO-8859-1", captured.get("encoding")); + assertEquals(Optional.empty(), capabilities.getEncoding()); + } + + private MavenExecutionRequest request(String project) throws Exception { + return createMavenExecutionRequest(getProject(project)).setGoals(Collections.singletonList("validate")); + } + + private void executeSuccessfully(MavenExecutionRequest request) { + assertEquals(Collections.emptyList(), maven.execute(request).getExceptions()); + } + + private RequestObserver observer() throws Exception { + return (RequestObserver) + container.lookup(AbstractMavenLifecycleParticipant.class, "output-capabilities-request"); + } + + @Named("output-capabilities-request") + @Singleton + private static final class RequestObserver extends AbstractMavenLifecycleParticipant { + private MavenSession session; + private Map captured; + private Object resolverMetadata; + + @Override + public void afterSessionStart(MavenSession session) { + this.session = session; + captured = (Map) session.getRequest().getData().get(KEY); + resolverMetadata = session.getRepositorySession().getData().get(KEY); + } + } +} diff --git a/impl/maven-core/src/test/java/org/apache/maven/logging/internal/DefaultOutputCapabilitiesTest.java b/impl/maven-core/src/test/java/org/apache/maven/logging/internal/DefaultOutputCapabilitiesTest.java new file mode 100644 index 000000000000..100c2c213799 --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/logging/internal/DefaultOutputCapabilitiesTest.java @@ -0,0 +1,111 @@ +/* + * 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.logging.internal; + +import javax.inject.Inject; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.apache.maven.logging.OutputCapabilities; +import org.apache.maven.logging.OutputCapabilities.Destination; +import org.codehaus.plexus.PlexusContainer; +import org.codehaus.plexus.testing.PlexusTest; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +@PlexusTest +class DefaultOutputCapabilitiesTest { + @Inject + private OutputCapabilities capabilities; + + @Inject + private PlexusContainer container; + + @Test + void injectableSingletonWithoutSession() throws Exception { + assertSame(capabilities, container.lookup(OutputCapabilities.class)); + assertEquals(Destination.UNKNOWN, capabilities.getDestination()); + assertEquals(Optional.empty(), capabilities.getEncoding()); + } + + @Test + void availableThroughMaven4LookupWithoutSession() throws Exception { + assertSame( + capabilities, + container.lookup(org.apache.maven.di.Injector.class).getInstance(DefaultOutputCapabilities.class)); + assertSame( + capabilities, + container.lookup(org.apache.maven.api.services.Lookup.class).lookup(OutputCapabilities.class)); + assertSame( + capabilities, + container.lookup(org.apache.maven.di.Injector.class).getInstance(OutputCapabilities.class)); + } + + @Test + void updatesAreVisibleToParallelClientsAndRestoredOnClose() throws Exception { + DefaultOutputCapabilities component = (DefaultOutputCapabilities) capabilities; + try (AutoCloseable cleanup = + component.install(DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8))) { + assertEquals( + Destination.FILE, + CompletableFuture.supplyAsync(capabilities::getDestination).get(10, TimeUnit.SECONDS)); + assertEquals( + Optional.of(StandardCharsets.UTF_8), + CompletableFuture.supplyAsync(capabilities::getEncoding).get(10, TimeUnit.SECONDS)); + try (AutoCloseable nested = component.install( + DefaultOutputCapabilities.snapshot(Destination.CONSOLE, StandardCharsets.ISO_8859_1))) { + assertEquals(Destination.CONSOLE, capabilities.getDestination()); + assertEquals(Optional.of(StandardCharsets.ISO_8859_1), capabilities.getEncoding()); + } + assertEquals(Destination.FILE, capabilities.getDestination()); + assertEquals(Optional.of(StandardCharsets.UTF_8), capabilities.getEncoding()); + } + assertEquals(Destination.UNKNOWN, capabilities.getDestination()); + assertEquals(Optional.empty(), capabilities.getEncoding()); + } + + @Test + void unknownDestinationCanHaveKnownEncoding() throws Exception { + try (AutoCloseable cleanup = ((DefaultOutputCapabilities) capabilities) + .install(DefaultOutputCapabilities.snapshot(Destination.UNKNOWN, StandardCharsets.UTF_8))) { + assertEquals(Destination.UNKNOWN, capabilities.getDestination()); + assertEquals(Optional.of(StandardCharsets.UTF_8), capabilities.getEncoding()); + } + } + + @Test + void repeatedCleanupDoesNotResetNextInvocation() throws Exception { + DefaultOutputCapabilities component = (DefaultOutputCapabilities) capabilities; + AutoCloseable first = + component.install(DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8)); + first.close(); + try (AutoCloseable second = component.install( + DefaultOutputCapabilities.snapshot(Destination.REDIRECTED, StandardCharsets.US_ASCII))) { + first.close(); + assertEquals(Destination.REDIRECTED, capabilities.getDestination()); + assertEquals(Optional.of(StandardCharsets.US_ASCII), capabilities.getEncoding()); + } + assertEquals(Destination.UNKNOWN, capabilities.getDestination()); + } +} diff --git a/impl/maven-core/src/test/java/org/apache/maven/logging/internal/OutputCapabilitiesMapTest.java b/impl/maven-core/src/test/java/org/apache/maven/logging/internal/OutputCapabilitiesMapTest.java new file mode 100644 index 000000000000..bdf69afb43e1 --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/logging/internal/OutputCapabilitiesMapTest.java @@ -0,0 +1,183 @@ +/* + * 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.logging.internal; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.apache.maven.logging.OutputCapabilities.Destination; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OutputCapabilitiesMapTest { + private final DefaultOutputCapabilities component = new DefaultOutputCapabilities(); + + @Test + void destinationsAndCanonicalEncodingAreIndependent() throws Exception { + Map initial = component.asMap(); + assertEquals(Collections.singletonMap("destination", "UNKNOWN"), initial); + for (Destination destination : Destination.values()) { + try (AutoCloseable cleanup = component.install(DefaultOutputCapabilities.snapshot(destination, null))) { + assertEquals(Collections.singletonMap("destination", destination.name()), component.asMap()); + assertFalse(component.asMap().containsKey("encoding")); + } + try (AutoCloseable cleanup = + component.install(DefaultOutputCapabilities.snapshot(destination, Charset.forName("latin1")))) { + Map captured = component.asMap(); + assertEquals(destination.name(), captured.get("destination")); + assertEquals("ISO-8859-1", captured.get("encoding")); + assertEquals(2, captured.size()); + assertEquals(Collections.singletonMap("destination", "UNKNOWN"), initial); + } + } + assertSame(initial, component.asMap()); + } + + @Test + void retainedMapsAndCollectionsSurviveReconfigurationAndCleanup() throws Exception { + Map initial = component.asMap(); + Map file; + Set> entries; + Set keys; + Collection values; + Iterator> iterator; + try (AutoCloseable cleanup = + component.install(DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8))) { + file = component.asMap(); + entries = file.entrySet(); + keys = file.keySet(); + values = file.values(); + iterator = entries.iterator(); + assertNotSame(initial, file); + try (AutoCloseable nested = component.install( + DefaultOutputCapabilities.snapshot(Destination.CONSOLE, StandardCharsets.ISO_8859_1))) { + assertEquals("CONSOLE", component.asMap().get("destination")); + assertEquals("ISO-8859-1", component.asMap().get("encoding")); + assertEquals(Arrays.asList("FILE", "UTF-8"), new ArrayList<>(values)); + } + assertSame(file, component.asMap()); + } + assertSame(initial, component.asMap()); + assertEquals(Collections.singletonMap("destination", "UNKNOWN"), initial); + assertEquals("FILE", file.get("destination")); + assertEquals("UTF-8", file.get("encoding")); + assertEquals(file.entrySet(), entries); + assertEquals(2, entries.size()); + assertEquals(Arrays.asList("destination", "encoding"), new ArrayList<>(keys)); + assertEquals(Arrays.asList("FILE", "UTF-8"), new ArrayList<>(values)); + Map fromIterator = new HashMap<>(); + iterator.forEachRemaining(entry -> fromIterator.put(entry.getKey(), entry.getValue())); + assertEquals(file, fromIterator); + } + + @Test + void mutationIsRejectedThroughMapCollectionsAndEntries() throws Exception { + try (AutoCloseable cleanup = + component.install(DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8))) { + Map captured = component.asMap(); + assertThrows(UnsupportedOperationException.class, () -> captured.put("destination", "CONSOLE")); + assertThrows( + UnsupportedOperationException.class, () -> captured.compute("encoding", (key, value) -> "ASCII")); + assertThrows( + UnsupportedOperationException.class, + () -> captured.entrySet().iterator().next().setValue("changed")); + for (Collection collection : Arrays.asList(captured.entrySet(), captured.keySet(), captured.values())) { + assertThrows(UnsupportedOperationException.class, collection::clear); + assertThrows( + UnsupportedOperationException.class, + () -> collection.remove(collection.iterator().next())); + Iterator iterator = collection.iterator(); + iterator.next(); + assertThrows(UnsupportedOperationException.class, iterator::remove); + } + } + } + + @Test + void failureAndRepeatedCleanupDoNotChangeCapturedMaps() throws Exception { + AutoCloseable first = + component.install(DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8)); + Map file = component.asMap(); + first.close(); + Map redirected; + List> failed = new ArrayList<>(); + try (AutoCloseable second = component.install( + DefaultOutputCapabilities.snapshot(Destination.REDIRECTED, StandardCharsets.US_ASCII))) { + redirected = component.asMap(); + first.close(); + assertSame(redirected, component.asMap()); + assertThrows(IllegalStateException.class, () -> { + try (AutoCloseable nested = component.install( + DefaultOutputCapabilities.snapshot(Destination.CONSOLE, StandardCharsets.ISO_8859_1))) { + failed.add(component.asMap()); + throw new IllegalStateException("logging setup failed"); + } + }); + assertSame(redirected, component.asMap()); + } + assertEquals(Collections.singletonMap("destination", "UNKNOWN"), component.asMap()); + assertEquals("FILE", file.get("destination")); + assertEquals("UTF-8", file.get("encoding")); + assertEquals("REDIRECTED", redirected.get("destination")); + assertEquals("US-ASCII", redirected.get("encoding")); + assertEquals("CONSOLE", failed.get(0).get("destination")); + assertEquals("ISO-8859-1", failed.get(0).get("encoding")); + } + + @Test + void parallelReadersRetainTheirOriginalInformation() throws Exception { + Map file; + try (AutoCloseable cleanup = + component.install(DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8))) { + file = component.asMap(); + } + CompletableFuture ready = new CompletableFuture<>(); + List>> readers = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + readers.add(ready.thenApplyAsync(ignored -> new HashMap<>(file))); + } + try (AutoCloseable cleanup = component.install( + DefaultOutputCapabilities.snapshot(Destination.CONSOLE, StandardCharsets.ISO_8859_1))) { + ready.complete(null); + for (CompletableFuture> reader : readers) { + Map copy = reader.get(10, TimeUnit.SECONDS); + assertEquals("FILE", copy.get("destination")); + assertEquals("UTF-8", copy.get("encoding")); + } + assertEquals("CONSOLE", component.asMap().get("destination")); + assertEquals("ISO-8859-1", component.asMap().get("encoding")); + } + } +} diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITOutputCapabilitiesTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITOutputCapabilitiesTest.java new file mode 100644 index 000000000000..ebaa857bbd04 --- /dev/null +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITOutputCapabilitiesTest.java @@ -0,0 +1,61 @@ +/* + * 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.it; + +import java.nio.file.Path; +import java.util.Properties; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class MavenITOutputCapabilitiesTest extends AbstractMavenIntegrationTestCase { + @Test + void injectsCapabilitiesIntoLegacyPluginsInParallelReactor() throws Exception { + Path directory = extractResources("output-capabilities"); + Verifier plugin = newVerifier(directory.resolve("plugin")); + plugin.addCliArgument("install"); + plugin.execute(); + plugin.verifyErrorFreeLog(); + Verifier legacyPlugin = newVerifier(directory.resolve("legacy-plugin")); + legacyPlugin.addCliArgument("install"); + legacyPlugin.execute(); + legacyPlugin.verifyErrorFreeLog(); + + for (String color : new String[] {"never", "always"}) { + Verifier consumer = newVerifier(directory.resolve("consumer")); + consumer.addCliArgument("-T2"); + consumer.addCliArgument("-B"); + consumer.addCliArgument("--color=" + color); + consumer.addCliArgument("validate"); + consumer.execute(); + consumer.verifyErrorFreeLog(); + for (String prefix : new String[] {"", "child/"}) { + Properties properties = consumer.loadProperties(prefix + "target/output-capabilities.properties"); + Properties legacy = consumer.loadProperties(prefix + "target/legacy-output-capabilities.properties"); + assertEquals("true", legacy.getProperty("available")); + assertEquals(properties.getProperty("destination"), legacy.getProperty("destination")); + assertEquals(properties.getProperty("encoding"), legacy.getProperty("encoding")); + // Maven 4's verifier directs logging through -l. + assertEquals("FILE", properties.getProperty("destination")); + assertEquals("UTF-8", properties.getProperty("encoding")); + } + } + } +} diff --git a/its/core-it-suite/src/test/resources/output-capabilities/consumer/child/pom.xml b/its/core-it-suite/src/test/resources/output-capabilities/consumer/child/pom.xml new file mode 100644 index 000000000000..6a927442ef71 --- /dev/null +++ b/its/core-it-suite/src/test/resources/output-capabilities/consumer/child/pom.xml @@ -0,0 +1,25 @@ + + + + 4.0.0 + org.apache.maven.its.outputconsumer1.0 + child + pom + diff --git a/its/core-it-suite/src/test/resources/output-capabilities/consumer/pom.xml b/its/core-it-suite/src/test/resources/output-capabilities/consumer/pom.xml new file mode 100644 index 000000000000..db1b0fce14f0 --- /dev/null +++ b/its/core-it-suite/src/test/resources/output-capabilities/consumer/pom.xml @@ -0,0 +1,56 @@ + + + + 4.0.0 + org.apache.maven.its.output + consumer + 1.0 + pom + child + + + + org.apache.maven.its.output + legacy-output-capabilities-maven-plugin + 1.0 + validatereport + + + + + + typed + true + + + + org.apache.maven.its.output + output-capabilities-maven-plugin + 1.0 + validatereport + + + + + + legacy-only + + + diff --git a/its/core-it-suite/src/test/resources/output-capabilities/legacy-plugin/pom.xml b/its/core-it-suite/src/test/resources/output-capabilities/legacy-plugin/pom.xml new file mode 100644 index 000000000000..36f1cccb73e1 --- /dev/null +++ b/its/core-it-suite/src/test/resources/output-capabilities/legacy-plugin/pom.xml @@ -0,0 +1,63 @@ + + + + 4.0.0 + org.apache.maven.its.output + legacy-output-capabilities-maven-plugin + 1.0 + maven-plugin + + 3.6.3 + 8 + UTF-8 + + + + org.apache.maven + maven-core + ${maven.version} + provided + + + org.apache.maven + maven-plugin-api + ${maven.version} + provided + + + org.apache.maven.plugin-tools + maven-plugin-annotations + 3.15.2 + provided + + + + + + maven-compiler-plugin + 3.14.1 + + + maven-plugin-plugin + 3.15.2 + + + + diff --git a/its/core-it-suite/src/test/resources/output-capabilities/legacy-plugin/src/main/java/org/apache/maven/its/output/LegacyOutputMojo.java b/its/core-it-suite/src/test/resources/output-capabilities/legacy-plugin/src/main/java/org/apache/maven/its/output/LegacyOutputMojo.java new file mode 100644 index 000000000000..e8df6b5479db --- /dev/null +++ b/its/core-it-suite/src/test/resources/output-capabilities/legacy-plugin/src/main/java/org/apache/maven/its/output/LegacyOutputMojo.java @@ -0,0 +1,82 @@ +/* + * 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.its.output; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; + +/** This fixture must compile and run using only Maven 3.6.3 APIs. */ +@Mojo(name = "report", threadSafe = true) +public class LegacyOutputMojo extends AbstractMojo { + @Parameter(defaultValue = "${session}", readonly = true) + private MavenSession session; + + @Parameter(defaultValue = "${project.build.directory}/legacy-output-capabilities.properties", readonly = true) + private File output; + + @Parameter(property = "capabilities.available", defaultValue = "true") + private boolean expectedAvailable; + + @Override + public void execute() throws MojoExecutionException { + Object value = session.getRequest().getData().get("maven.logging.outputCapabilities"); + boolean available = value instanceof Map; + if (expectedAvailable != available) { + throw new MojoExecutionException("Unexpected compatibility service availability: " + available); + } + Properties properties = new Properties(); + properties.setProperty("available", Boolean.toString(available)); + properties.setProperty("destination", "UNKNOWN"); + properties.setProperty("encoding", "unknown"); + properties.setProperty("defaultEncoding", Charset.defaultCharset().name()); + if (available) { + Map snapshot = new HashMap<>((Map) value); + Object destination = snapshot.get("destination"); + if (Arrays.asList("CONSOLE", "FILE", "REDIRECTED", "UNKNOWN").contains(destination)) { + properties.setProperty("destination", (String) destination); + } + Object encoding = snapshot.get("encoding"); + if (encoding instanceof String) { + properties.setProperty("encoding", (String) encoding); + } + } + try { + Files.createDirectories(output.toPath().getParent()); + try (OutputStream stream = Files.newOutputStream(output.toPath())) { + properties.store(stream, "Maven logging compatibility information"); + } + } catch (IOException e) { + throw new MojoExecutionException("Unable to write capabilities", e); + } + getLog().info("legacy-output-capabilities-marker caf\u00e9 \u251c\u2500"); + } +} diff --git a/its/core-it-suite/src/test/resources/output-capabilities/plugin/pom.xml b/its/core-it-suite/src/test/resources/output-capabilities/plugin/pom.xml new file mode 100644 index 000000000000..9bf9a525ddf6 --- /dev/null +++ b/its/core-it-suite/src/test/resources/output-capabilities/plugin/pom.xml @@ -0,0 +1,69 @@ + + + + 4.0.0 + org.apache.maven.its.output + output-capabilities-maven-plugin + 1.0 + maven-plugin + + 4.1.0-SNAPSHOT + 8 + UTF-8 + + + + org.apache.maven + maven-core + ${maven.version} + provided + + + org.apache.maven + maven-plugin-api + ${maven.version} + provided + + + javax.inject + javax.inject + 1 + provided + + + org.apache.maven.plugin-tools + maven-plugin-annotations + 3.15.2 + provided + + + + + + maven-compiler-plugin + 3.14.1 + + + maven-plugin-plugin + 3.15.2 + + + + diff --git a/its/core-it-suite/src/test/resources/output-capabilities/plugin/src/main/java/org/apache/maven/its/output/OutputMojo.java b/its/core-it-suite/src/test/resources/output-capabilities/plugin/src/main/java/org/apache/maven/its/output/OutputMojo.java new file mode 100644 index 000000000000..8fab5aac658a --- /dev/null +++ b/its/core-it-suite/src/test/resources/output-capabilities/plugin/src/main/java/org/apache/maven/its/output/OutputMojo.java @@ -0,0 +1,60 @@ +/* + * 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.its.output; + +import javax.inject.Inject; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.util.Properties; + +import org.apache.maven.logging.OutputCapabilities; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; + +@Mojo(name = "report", threadSafe = true) +public class OutputMojo extends AbstractMojo { + @Inject + private OutputCapabilities capabilities; + + @Parameter(defaultValue = "${project.build.directory}/output-capabilities.properties", readonly = true) + private File output; + + @Override + public void execute() throws MojoExecutionException { + Properties properties = new Properties(); + properties.setProperty("destination", capabilities.getDestination().name()); + properties.setProperty("encoding", capabilities.getEncoding().map(Charset::name).orElse("unknown")); + properties.setProperty("defaultEncoding", Charset.defaultCharset().name()); + try { + Files.createDirectories(output.toPath().getParent()); + try (OutputStream stream = Files.newOutputStream(output.toPath())) { + properties.store(stream, "Maven logging capabilities"); + } + } catch (IOException e) { + throw new MojoExecutionException("Unable to write capabilities", e); + } + getLog().info("output-capabilities-marker caf\u00e9 \u251c\u2500"); + } +}