From ff0cd05fb59361e98c12582357385f2b793f03b0 Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Mon, 5 Oct 2026 18:59:19 +0000 Subject: [PATCH 1/2] Add maven-executor-verifier, a Verifier on top of the executors Seven IT suites that moved off maven-verifier each wrote about 2,100 lines of the same Verifier helpers. This module holds them once: running Maven with -e --batch-mode, autoclean and the log file, the log, file and artifact checks, filtering, and ResourceExtractor. It is seeded from apache/maven-integration-testing#446 without the members kept there only for source compatibility with maven-verifier, depends on maven-executor only, and computes paths through DefaultExecutorTool. addCliOption() splits an option such as "-pl x -am" into separate arguments. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 + maven-executor-verifier/pom.xml | 121 +++ .../executor/verifier/ResourceExtractor.java | 191 +++++ .../verifier/VerificationException.java | 38 + .../maven/executor/verifier/Verifier.java | 806 ++++++++++++++++++ .../verifier/ResourceExtractorTest.java | 101 +++ .../verifier/VerifierExecuteTest.java | 124 +++ .../maven/executor/verifier/VerifierTest.java | 225 +++++ .../test/resources/projects/failing/pom.xml | 28 + .../test/resources/projects/reactor/a/pom.xml | 33 + .../test/resources/projects/reactor/b/pom.xml | 40 + .../test/resources/projects/reactor/c/pom.xml | 33 + .../test/resources/projects/reactor/pom.xml | 33 + .../test/resources/projects/simple/pom.xml | 29 + pom.xml | 1 + 15 files changed, 1808 insertions(+) create mode 100644 maven-executor-verifier/pom.xml create mode 100644 maven-executor-verifier/src/main/java/org/apache/maven/executor/verifier/ResourceExtractor.java create mode 100644 maven-executor-verifier/src/main/java/org/apache/maven/executor/verifier/VerificationException.java create mode 100644 maven-executor-verifier/src/main/java/org/apache/maven/executor/verifier/Verifier.java create mode 100644 maven-executor-verifier/src/test/java/org/apache/maven/executor/verifier/ResourceExtractorTest.java create mode 100644 maven-executor-verifier/src/test/java/org/apache/maven/executor/verifier/VerifierExecuteTest.java create mode 100644 maven-executor-verifier/src/test/java/org/apache/maven/executor/verifier/VerifierTest.java create mode 100644 maven-executor-verifier/src/test/resources/projects/failing/pom.xml create mode 100644 maven-executor-verifier/src/test/resources/projects/reactor/a/pom.xml create mode 100644 maven-executor-verifier/src/test/resources/projects/reactor/b/pom.xml create mode 100644 maven-executor-verifier/src/test/resources/projects/reactor/c/pom.xml create mode 100644 maven-executor-verifier/src/test/resources/projects/reactor/pom.xml create mode 100644 maven-executor-verifier/src/test/resources/projects/simple/pom.xml diff --git a/README.md b/README.md index 25a24c2..d0bac16 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,11 @@ implementations out of the box: "forked" and "embedded". There are more provider * `docker-exe` uses Docker CLI and runs [Apache Maven Docker Image](https://hub.docker.com/_/maven) * `testcontainers` uses Docker via TestContainers and runs [Apache Maven Docker Image](https://hub.docker.com/_/maven) +For test suites, `maven-executor-verifier` provides a `Verifier` on top of the executors: it runs Maven on a test +project, writes the log, and checks the log, project files and artifacts in the local repository, with a +`ResourceExtractor` that copies test projects from the class path. It is the successor of maven-verifier's +`Verifier`, and depends on `maven-executor` only. + Versions and Branches --------------------- diff --git a/maven-executor-verifier/pom.xml b/maven-executor-verifier/pom.xml new file mode 100644 index 0000000..bda7385 --- /dev/null +++ b/maven-executor-verifier/pom.xml @@ -0,0 +1,121 @@ + + + + 4.0.0 + + + org.apache.maven.executor + executor + 1.0.1-SNAPSHOT + + + maven-executor-verifier + jar + + Maven Executor Verifier + Test support on top of Maven Executor for suites that run Maven and check its output, the successor of + maven-verifier. + + + + org.apache.maven.executor + maven-executor + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-params + test + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + prepare-maven-distros + + unpack + + generate-test-resources + + + + org.apache.maven + apache-maven + ${version.maven3} + bin + zip + + + org.apache.maven + apache-maven + ${version.maven4} + bin + zip + + + + + + + + org.apache.maven.plugins + maven-antrun-plugin + + + create-tmp-dir + + run + + process-test-resources + + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + false + + ${project.build.directory}/dependency/apache-maven-${version.maven3} + ${project.build.directory}/dependency/apache-maven-${version.maven4} + ${settings.localRepository} + + -Xmx256m -Djava.io.tmpdir=${testTmpDir} + true + + + + + diff --git a/maven-executor-verifier/src/main/java/org/apache/maven/executor/verifier/ResourceExtractor.java b/maven-executor-verifier/src/main/java/org/apache/maven/executor/verifier/ResourceExtractor.java new file mode 100644 index 0000000..34e4923 --- /dev/null +++ b/maven-executor-verifier/src/main/java/org/apache/maven/executor/verifier/ResourceExtractor.java @@ -0,0 +1,191 @@ +/* + * 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.executor.verifier; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Enumeration; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** + * Copies test projects from the class path, a directory or a jar, to a directory where Maven can run on them. + */ +public final class ResourceExtractor { + private ResourceExtractor() {} + + /** + * Extracts the resource into a fresh directory of the same name under {@code maven.test.tmpdir}, or else + * {@code java.io.tmpdir}. A resource in a directory on the class path is used where it is, without a copy. + * + * @param cl the class whose class loader and package resolve {@code resourcePath} + * @param resourcePath the resource, for example {@code /it0001} + * @return the directory to run Maven in + */ + public static File simpleExtractResources(Class cl, String resourcePath) throws IOException { + File tempDir = new File(System.getProperty("maven.test.tmpdir", System.getProperty("java.io.tmpdir"))); + File testDir = new File(tempDir, resourcePath); + deleteRecursively(testDir.toPath()); + return extractResourcePath(cl, resourcePath, tempDir, false); + } + + public static File extractResourcePath(String resourcePath, File dest) throws IOException { + return extractResourcePath(ResourceExtractor.class, resourcePath, dest); + } + + public static File extractResourcePath(Class cl, String resourcePath, File dest) throws IOException { + return extractResourcePath(cl, resourcePath, dest, false); + } + + public static File extractResourcePath(Class cl, String resourcePath, File tempDir, boolean alwaysExtract) + throws IOException { + return extractResourceToDestination(cl, resourcePath, new File(tempDir, resourcePath), alwaysExtract); + } + + /** + * @param alwaysExtract whether a resource in a directory on the class path is copied too; one in a jar always is + * @return {@code destination}, or the resource itself when it is in a directory and not copied + */ + public static File extractResourceToDestination( + Class cl, String resourcePath, File destination, boolean alwaysExtract) throws IOException { + URL url = cl.getResource(resourcePath); + if (url == null) { + throw new IllegalArgumentException("Resource not found: " + resourcePath); + } + if ("jar".equalsIgnoreCase(url.getProtocol())) { + extractResourcePathFromJar(cl, jarFile(url), resourcePath, destination); + return destination; + } + File resourceFile; + try { + resourceFile = new File(new URI(url.toExternalForm())); + } catch (URISyntaxException e) { + throw new IOException("Couldn't convert URL to File: " + url, e); + } + if (!alwaysExtract) { + return resourceFile; + } + copy(resourceFile.toPath(), destination.toPath()); + return destination; + } + + private static void extractResourcePathFromJar(Class cl, File jarFile, String resourcePath, File dest) + throws IOException { + String absolutePath = resourcePath.startsWith("/") + ? resourcePath.substring(1) + : cl.getPackage().getName().replace('.', '/') + "/" + resourcePath; + String directory = absolutePath + "/"; + try (ZipFile zip = new ZipFile(jarFile, ZipFile.OPEN_READ)) { + if (zip.getEntry(directory) == null) { + Files.createDirectories(dest.toPath().toAbsolutePath().getParent()); + try (InputStream in = zip.getInputStream(zip.getEntry(absolutePath))) { + Files.copy(in, dest.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + return; + } + for (Enumeration entries = zip.entries(); entries.hasMoreElements(); ) { + ZipEntry entry = entries.nextElement(); + if (!entry.getName().startsWith(directory)) { + continue; + } + Path target = dest.toPath() + .resolve(entry.getName().substring(directory.length())) + .normalize(); + if (!target.startsWith(dest.toPath().normalize())) { + throw new IOException("Entry outside of the target directory: " + entry.getName()); + } + if (entry.isDirectory()) { + Files.createDirectories(target); + } else { + Files.createDirectories(target.getParent()); + try (InputStream in = zip.getInputStream(entry)) { + Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); + } + } + } + } + } + + private static File jarFile(URL url) throws IOException { + String file = url.getFile(); + int index = file.indexOf('!'); + if (index == -1) { + throw new IOException(url.toExternalForm() + " does not have a '!'"); + } + try { + return new File(new URI(file.substring(0, index))); + } catch (URISyntaxException e) { + throw new IOException("Couldn't convert URL to File: " + url, e); + } + } + + private static void copy(Path source, Path target) throws IOException { + if (!Files.isDirectory(source)) { + Files.createDirectories(target.toAbsolutePath().getParent()); + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + return; + } + Files.walkFileTree(source, new SimpleFileVisitor() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + Files.createDirectories(target.resolve(source.relativize(dir).toString())); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.copy( + file, target.resolve(source.relativize(file).toString()), StandardCopyOption.REPLACE_EXISTING); + return FileVisitResult.CONTINUE; + } + }); + } + + static void deleteRecursively(Path path) throws IOException { + if (!Files.exists(path)) { + return; + } + Files.walkFileTree(path, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException e) throws IOException { + if (e != null) { + throw e; + } + Files.delete(dir); + return FileVisitResult.CONTINUE; + } + }); + } +} diff --git a/maven-executor-verifier/src/main/java/org/apache/maven/executor/verifier/VerificationException.java b/maven-executor-verifier/src/main/java/org/apache/maven/executor/verifier/VerificationException.java new file mode 100644 index 0000000..0c7d8cc --- /dev/null +++ b/maven-executor-verifier/src/main/java/org/apache/maven/executor/verifier/VerificationException.java @@ -0,0 +1,38 @@ +/* + * 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.executor.verifier; + +/** + * A check of a {@link Verifier} failed, or Maven could not be run. + */ +public class VerificationException extends Exception { + private static final long serialVersionUID = 1L; + + public VerificationException(String message) { + super(message); + } + + public VerificationException(Throwable cause) { + super(cause); + } + + public VerificationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/maven-executor-verifier/src/main/java/org/apache/maven/executor/verifier/Verifier.java b/maven-executor-verifier/src/main/java/org/apache/maven/executor/verifier/Verifier.java new file mode 100644 index 0000000..591bfe2 --- /dev/null +++ b/maven-executor-verifier/src/main/java/org/apache/maven/executor/verifier/Verifier.java @@ -0,0 +1,806 @@ +/* + * 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.executor.verifier; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URL; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.maven.executor.ExecutorException; +import org.apache.maven.executor.ExecutorHelper; +import org.apache.maven.executor.ExecutorRequest; +import org.apache.maven.executor.ExecutorResult; +import org.apache.maven.executor.ExecutorTool; +import org.apache.maven.executor.embedded.EmbeddedMavenExecutor; +import org.apache.maven.executor.forked.ForkedMavenExecutor; +import org.apache.maven.executor.support.DefaultExecutorTool; + +import static java.util.Objects.requireNonNull; + +/** + * Runs Maven on a test project and checks the result: the log, files in the project and artifacts in the local + * repository. It is the successor of {@code org.apache.maven.shared.verifier.Verifier} from maven-verifier, on top of + * maven-executor. + *

+ * Each {@link #execute()} runs Maven in {@link #getBasedir()} with {@code -e --batch-mode}, the local repository of + * this verifier as {@code -Dmaven.repo.local}, {@code maven-clean-plugin:clean} first unless + * {@link #setAutoclean(boolean) autoclean} is off, the {@link #setSystemProperty(String, String) system properties} + * as {@code -D} arguments and then the {@link #addCliArgument(String) arguments}. Standard output and standard error + * go to the {@link #setLogFileName(String) log file} in the base directory, which the {@code verify*InLog} checks + * read. + *

+ * Without an explicit {@link ExecutorHelper}, Maven is the installation in the {@code maven.home} system property, + * run in the mode of the {@code verifier.forkMode} system property ({@code forked} when unset). The executors for an + * installation are created once and kept for the life of the JVM, so an embedded Maven is not set up again for every + * test. + *

+ * The local repository and artifact paths come from an {@link ExecutorTool}, by default {@link DefaultExecutorTool}, + * which computes them without running Maven or resolving any plugin. + */ +public class Verifier { + private static final String LOG_FILENAME = "log.txt"; + + private static final String CLEAN_CLI_ARGUMENT = "org.apache.maven.plugins:maven-clean-plugin:clean"; + + private static final String ARTIFACT_MARKER = "${artifact:"; + + private static final Map DEFAULT_EXECUTOR_HELPERS = new ConcurrentHashMap<>(); + + /** + * Packaging types that do not name their own file: the extension and classifier the artifact is stored with. + */ + private static final Map TYPES; + + static { + Map types = new HashMap<>(); + types.put("maven-plugin", new String[] {"jar", null}); + types.put("ejb", new String[] {"jar", null}); + types.put("test-jar", new String[] {"jar", "tests"}); + types.put("ejb-client", new String[] {"jar", "client"}); + types.put("java-source", new String[] {"jar", "sources"}); + types.put("javadoc", new String[] {"jar", "javadoc"}); + TYPES = Collections.unmodifiableMap(types); + } + + private final String basedir; + + private final ExecutorHelper executorHelper; + + private ExecutorTool executorTool; + + private String localRepository; + + private final List defaultCliArguments = new ArrayList<>(Arrays.asList("-e", "--batch-mode")); + + private final List cliArguments = new ArrayList<>(); + + private final Map systemProperties = new LinkedHashMap<>(); + + private final Map environmentVariables = new HashMap<>(); + + private boolean autoclean = true; + + private boolean forkJvm; + + private String logFileName = LOG_FILENAME; + + private String executable = ExecutorRequest.MVN; + + /** + * A verifier for the Maven installation in the {@code maven.home} system property. + * + * @param basedir the directory of the test project + */ + public Verifier(String basedir) { + this(basedir, defaultExecutorHelper()); + } + + /** + * @param basedir the directory of the test project + * @param executorHelper runs Maven; this verifier does not close it + */ + public Verifier(String basedir, ExecutorHelper executorHelper) { + this.basedir = requireNonNull(basedir, "basedir"); + this.executorHelper = requireNonNull(executorHelper, "executorHelper"); + this.executorTool = new DefaultExecutorTool(mavenHome()); + } + + /** + * The shared helper for the installation in {@code maven.home}, in the mode of {@code verifier.forkMode}. + */ + private static ExecutorHelper defaultExecutorHelper() { + Path mavenHome = mavenHome(); + if (mavenHome == null) { + throw new IllegalStateException("The maven.home system property must point to the Maven installation to" + + " run, or the verifier must be given an ExecutorHelper"); + } + String forkMode = System.getProperty("verifier.forkMode"); + ExecutorHelper.Mode mode = forkMode == null || forkMode.isEmpty() + ? ExecutorHelper.Mode.FORKED + : ExecutorHelper.Mode.valueOf(forkMode.toUpperCase(Locale.ROOT)); + return DEFAULT_EXECUTOR_HELPERS.computeIfAbsent( + mavenHome.toAbsolutePath().normalize(), + home -> ExecutorHelper.forExecutors( + mode, new EmbeddedMavenExecutor(home), new ForkedMavenExecutor(home))); + } + + private static Path mavenHome() { + String mavenHome = System.getProperty("maven.home"); + return mavenHome == null || mavenHome.isEmpty() ? null : Paths.get(mavenHome); + } + + // ---------------------------------------------------------------------- + // Running Maven + // ---------------------------------------------------------------------- + + /** + * Runs Maven and writes its output to the log file. + * + * @throws VerificationException if Maven could not be run or exited with a non-zero code; the message has the + * command line and the log + */ + public void execute() throws VerificationException { + List args = commandLine(); + File logFile = getLogFile(); + // the executor's pump threads close the streams they are given, so the log file is written afterwards + ByteArrayOutputStream stdOut = new ByteArrayOutputStream(); + ByteArrayOutputStream stdErr = new ByteArrayOutputStream(); + ExecutorResult result; + try { + ExecutorRequest.Builder builder = ExecutorRequest.mavenBuilder() + .command(executable) + .cwd(Paths.get(basedir)) + .arguments(args) + .skipMavenRc(true) + .stdOut(stdOut) + .stdErr(stdErr); + if (!environmentVariables.isEmpty()) { + builder.environmentVariables(environmentVariables); + } + ExecutorHelper.Mode mode = forkJvm ? ExecutorHelper.Mode.FORKED : executorHelper.getDefaultMode(); + result = executorHelper.execute(mode, builder.build()); + } catch (ExecutorException e) { + writeLogFile(logFile, stdOut, stdErr); + throw new VerificationException("Failed to execute Maven", e); + } + writeLogFile(logFile, stdOut, stdErr); + if (!result.success()) { + throw new VerificationException( + "Exit code was non-zero: " + result.exitCode().orElse(-1) + + "; command line and log = \n" + executable + " " + String.join(" ", args) + "\n" + + logContents(logFile)); + } + } + + private List commandLine() { + List args = new ArrayList<>(defaultCliArguments); + args.add("-Dmaven.repo.local=" + getLocalRepository()); + if (autoclean) { + args.add(CLEAN_CLI_ARGUMENT); + } + for (Map.Entry property : systemProperties.entrySet()) { + args.add("-D" + property.getKey() + "=" + property.getValue()); + } + for (String cliArgument : cliArguments) { + args.add(cliArgument.replace("${basedir}", basedir)); + } + return args; + } + + private static void writeLogFile(File logFile, ByteArrayOutputStream stdOut, ByteArrayOutputStream stdErr) + throws VerificationException { + try { + Files.createDirectories(logFile.toPath().toAbsolutePath().getParent()); + try (OutputStream out = Files.newOutputStream(logFile.toPath())) { + stdOut.writeTo(out); + stdErr.writeTo(out); + } + } catch (IOException e) { + throw new VerificationException("Could not write log file: " + logFile, e); + } + } + + private static String logContents(File logFile) { + try { + return new String(Files.readAllBytes(logFile.toPath()), Charset.defaultCharset()); + } catch (IOException e) { + return "(Error reading log contents: " + e.getMessage() + ")"; + } + } + + /** + * @return the version of the Maven this verifier runs + */ + public String getMavenVersion() throws VerificationException { + try { + return executorHelper.mavenVersion(); + } catch (ExecutorException e) { + throw new VerificationException("Failed to determine Maven version", e); + } + } + + // ---------------------------------------------------------------------- + // Configuration + // ---------------------------------------------------------------------- + + /** + * Adds one command line argument as it is, spaces included. {@code ${basedir}} is replaced with + * {@link #getBasedir()} when Maven runs. + */ + public void addCliArgument(String cliArgument) { + cliArguments.add(requireNonNull(cliArgument, "cliArgument")); + } + + /** + * Adds command line arguments, each as it is. {@code ${basedir}} is replaced with {@link #getBasedir()} when + * Maven runs. + */ + public void addCliArguments(String... cliArguments) { + for (String cliArgument : cliArguments) { + addCliArgument(cliArgument); + } + } + + /** + * Adds an option as it would be typed on the command line: {@code "-pl module-a -am"} becomes the three arguments + * {@code -pl}, {@code module-a} and {@code -am}, as maven-verifier 1.x split its options. A value with spaces + * needs {@link #addCliArgument(String)}. + */ + public void addCliOption(String option) { + for (String argument : requireNonNull(option, "option").trim().split("\\s+")) { + if (!argument.isEmpty()) { + addCliArgument(argument); + } + } + } + + /** + * @return the arguments added so far, without the defaults and the system properties + */ + public List getCliArguments() { + return Collections.unmodifiableList(cliArguments); + } + + /** + * Passes a system property to Maven as {@code -Dkey=value}; {@code null} removes it. + */ + public void setSystemProperty(String key, String value) { + if (value != null) { + systemProperties.put(key, value); + } else { + systemProperties.remove(key); + } + } + + public Map getSystemProperties() { + return Collections.unmodifiableMap(systemProperties); + } + + /** + * Sets an environment variable for Maven; {@code null} removes it. + */ + public void setEnvironmentVariable(String key, String value) { + if (value != null) { + environmentVariables.put(key, value); + } else { + environmentVariables.remove(key); + } + } + + public boolean isAutoclean() { + return autoclean; + } + + /** + * Whether {@code maven-clean-plugin:clean} runs first, on by default. + */ + public void setAutoclean(boolean autoclean) { + this.autoclean = autoclean; + } + + /** + * Runs Maven forked even when the default mode is embedded. + */ + public void setForkJvm(boolean forkJvm) { + this.forkJvm = forkJvm; + } + + public String getExecutable() { + return executable; + } + + /** + * The command to run, {@code mvn} by default, for example a wrapper script. + */ + public void setExecutable(String executable) { + this.executable = requireNonNull(executable, "executable"); + } + + public String getBasedir() { + return basedir; + } + + /** + * @return the name of the log file, relative to the base directory + */ + public String getLogFileName() { + return logFileName; + } + + /** + * @param logFileName the name of the log file, relative to the base directory + */ + public void setLogFileName(String logFileName) { + if (logFileName == null || logFileName.isEmpty()) { + throw new IllegalArgumentException("log file name unspecified"); + } + this.logFileName = logFileName; + } + + private File getLogFile() { + return new File(basedir, logFileName); + } + + /** + * @param executorTool computes the local repository and artifact paths, {@link DefaultExecutorTool} by default + */ + public void setExecutorTool(ExecutorTool executorTool) { + this.executorTool = requireNonNull(executorTool, "executorTool"); + } + + /** + * Uses this local repository instead of the one the {@link ExecutorTool} finds. + */ + public void setLocalRepo(String localRepository) { + this.localRepository = localRepository; + } + + /** + * @return the local repository Maven runs with: the one {@link #setLocalRepo(String) set}, else the one the + * {@link ExecutorTool} finds for the arguments, system properties and base directory of this verifier + */ + public String getLocalRepository() { + if (localRepository != null) { + return localRepository; + } + return executorTool.localRepository(toolRequest()); + } + + private ExecutorRequest.Builder toolRequest() { + List args = new ArrayList<>(); + for (Map.Entry property : systemProperties.entrySet()) { + args.add("-D" + property.getKey() + "=" + property.getValue()); + } + args.addAll(cliArguments); + return ExecutorRequest.mavenBuilder().cwd(Paths.get(basedir)).arguments(args); + } + + // ---------------------------------------------------------------------- + // Log + // ---------------------------------------------------------------------- + + /** + * Fails if a line of the log has {@code [ERROR]}, except the noise of the Velocity in old Doxia versions. + */ + public void verifyErrorFreeLog() throws VerificationException { + for (String line : loadFile(getLogFile(), false)) { + if (stripAnsi(line).contains("[ERROR]") && !isVelocityError(line)) { + throw new VerificationException("Error in execution: " + line); + } + } + } + + private static boolean isVelocityError(String line) { + return line.contains("VM_global_library.vm") || line.contains("VM #") && line.contains("macro"); + } + + /** + * Fails unless a line of the log contains the text, ANSI colours ignored. + */ + public void verifyTextInLog(String text) throws VerificationException { + if (!logContains(text)) { + throw new VerificationException("Text not found in log: " + text); + } + } + + /** + * Fails if a line of the log contains the text, ANSI colours ignored. + */ + public void verifyTextNotInLog(String text) throws VerificationException { + if (logContains(text)) { + throw new VerificationException("Text found in log: " + text); + } + } + + private boolean logContains(String text) throws VerificationException { + for (String line : loadFile(getLogFile(), false)) { + if (stripAnsi(line).contains(text)) { + return true; + } + } + return false; + } + + public static String stripAnsi(String msg) { + return msg.replaceAll("\u001B\\[[;\\d]*[ -/]*[@-~]", ""); + } + + // ---------------------------------------------------------------------- + // Files of the test project + // ---------------------------------------------------------------------- + + /** + * @param filename relative to the base directory + */ + public Properties loadProperties(String filename) throws VerificationException { + Properties properties = new Properties(); + try (InputStream in = Files.newInputStream(new File(basedir, filename).toPath())) { + properties.load(in); + } catch (IOException e) { + throw new VerificationException("Error reading properties file", e); + } + return properties; + } + + /** + * @param filename relative to the base directory + * @param encoding {@code null} or empty for the platform encoding + * @return the non-empty lines + */ + public List loadLines(String filename, String encoding) throws IOException { + List lines = new ArrayList<>(); + try (BufferedReader reader = Files.newBufferedReader(new File(basedir, filename).toPath(), charset(encoding))) { + String line; + while ((line = reader.readLine()) != null) { + if (!line.isEmpty()) { + lines.add(line); + } + } + } + return lines; + } + + public List loadLines(String filename) throws IOException { + return loadLines(filename, null); + } + + public List loadFile(String basedir, String filename, boolean hasCommand) throws VerificationException { + return loadFile(new File(basedir, filename), hasCommand); + } + + /** + * Loads the trimmed lines that are neither empty nor {@code #} comments. A {@code ${artifact:g:a:v:ext}} marker + * becomes the artifact path, followed by the {@code maven-metadata*.xml} files next to the artifact and its + * version directory. + * + * @param hasCommand whether each line starts with a command and a space before the path + */ + public List loadFile(File file, boolean hasCommand) throws VerificationException { + List lines = new ArrayList<>(); + if (!file.exists()) { + return lines; + } + try (BufferedReader reader = Files.newBufferedReader(file.toPath(), Charset.defaultCharset())) { + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (!line.startsWith("#") && !line.isEmpty()) { + lines.addAll(replaceArtifacts(line, hasCommand)); + } + } + } catch (IOException e) { + throw new VerificationException(e); + } + return lines; + } + + private List replaceArtifacts(String line, boolean hasCommand) { + int index = line.indexOf(ARTIFACT_MARKER); + if (index < 0) { + return Collections.singletonList(line); + } + String newLine = line.substring(0, index); + int end = line.indexOf('}', index); + if (end < 0) { + throw new IllegalArgumentException("line does not contain ending artifact marker: '" + line + "'"); + } + String[] coordinates = + line.substring(index + ARTIFACT_MARKER.length(), end).split(":"); + if (coordinates.length != 4) { + throw new IllegalArgumentException("Artifact must have 4 tokens: '" + line + "'"); + } + newLine += getArtifactPath(coordinates[0], coordinates[1], coordinates[2], coordinates[3]); + newLine += line.substring(end + 1); + + List lines = new ArrayList<>(); + lines.add(newLine); + String command = null; + String filespec = newLine; + if (hasCommand) { + int space = newLine.indexOf(' '); + command = newLine.substring(0, space); + filespec = newLine.substring(space + 1); + } + File artifactDir = new File(filespec).getParentFile(); + addMetadataToList(artifactDir, command, lines); + addMetadataToList(artifactDir != null ? artifactDir.getParentFile() : null, command, lines); + return lines; + } + + private static void addMetadataToList(File dir, String command, List lines) { + String[] files = + dir != null ? dir.list((d, name) -> name.startsWith("maven-metadata") && name.endsWith(".xml")) : null; + if (files != null) { + for (String file : files) { + String path = new File(dir, file).getPath(); + lines.add(command != null ? command + " " + path : path); + } + } + } + + /** + * Replaces each key of the map with its value in the source file and writes the result. + * + * @param srcPath relative to the base directory + * @param dstPath relative to the base directory, may be {@code srcPath} + * @param fileEncoding {@code null} or empty for the platform encoding + * @return the written file + */ + public File filterFile(String srcPath, String dstPath, String fileEncoding, Map filterMap) + throws IOException { + Charset charset = charset(fileEncoding); + String data = new String(Files.readAllBytes(new File(basedir, srcPath).toPath()), charset); + for (Map.Entry entry : filterMap.entrySet()) { + data = data.replace(entry.getKey(), entry.getValue()); + } + File dstFile = new File(basedir, dstPath); + Files.createDirectories(dstFile.toPath().toAbsolutePath().getParent()); + Files.write(dstFile.toPath(), data.getBytes(charset)); + return dstFile; + } + + /** + * {@link #filterFile(String, String, String, Map)} with {@link #newDefaultFilterMap()}. + */ + public File filterFile(String srcPath, String dstPath, String fileEncoding) throws IOException { + return filterFile(srcPath, dstPath, fileEncoding, newDefaultFilterMap()); + } + + /** + * @return a new modifiable map from {@code @basedir@} to the base directory and from {@code @baseurl@} to its + * {@code file:} URL + */ + public Map newDefaultFilterMap() { + Map filterMap = new HashMap<>(); + Path base = Paths.get(basedir).toAbsolutePath(); + filterMap.put("@basedir@", base.toString()); + filterMap.put("@baseurl@", base.toUri().toASCIIString()); + return filterMap; + } + + /** + * @param path relative to the base directory + */ + public void deleteDirectory(String path) throws IOException { + ResourceExtractor.deleteRecursively(new File(basedir, path).toPath()); + } + + /** + * Fails unless the file exists. A relative path is relative to the base directory; {@code *} in the file name + * matches any characters; {@code a.jar!/entry} checks an entry of a jar in the base directory. + */ + public void verifyFilePresent(String file) throws VerificationException { + verifyFilePresence(file, true); + } + + /** + * Fails if the file exists, with the paths of {@link #verifyFilePresent(String)}. + */ + public void verifyFileNotPresent(String file) throws VerificationException { + verifyFilePresence(file, false); + } + + private void verifyFilePresence(String filePath, boolean wanted) throws VerificationException { + if (filePath.contains("!/")) { + verifyJarEntryPresence(filePath, wanted); + return; + } + File expectedFile = new File(filePath); + // on Windows, a path with a leading (back-)slash is relative to the current drive + if (!expectedFile.isAbsolute() && !expectedFile.getPath().startsWith(File.separator)) { + expectedFile = new File(basedir, filePath); + } + boolean found; + if (expectedFile.getName().indexOf('*') > -1) { + String pattern = expectedFile.getName().replace(".", "\\.").replace("*", ".*"); + String[] candidates = expectedFile.getParentFile().list((dir, name) -> name.matches(pattern)); + found = candidates != null && candidates.length > 0; + if (found != wanted) { + throw new VerificationException((wanted ? "Expected" : "Unwanted") + " file pattern was " + + (wanted ? "not found: " : "found: ") + expectedFile.getPath()); + } + } else { + found = expectedFile.exists(); + if (found != wanted) { + throw new VerificationException((wanted ? "Expected" : "Unwanted") + " file was " + + (wanted ? "not found: " : "found: ") + expectedFile.getPath()); + } + } + } + + private void verifyJarEntryPresence(String filePath, boolean wanted) throws VerificationException { + String base = Paths.get(basedir).toAbsolutePath().toUri().toASCIIString(); + String url = "jar:" + base + (base.endsWith("/") ? "" : "/") + filePath; + boolean found; + try (InputStream in = new URL(url).openStream()) { + found = in != null; + } catch (IOException e) { + found = false; + } + if (found != wanted) { + throw new VerificationException((wanted ? "Expected" : "Unwanted") + " JAR resource was " + + (wanted ? "not found: " : "found: ") + filePath); + } + } + + // ---------------------------------------------------------------------- + // Local repository + // ---------------------------------------------------------------------- + + /** + * @return the absolute path of the artifact in the local repository + */ + public String getArtifactPath(String groupId, String artifactId, String version, String type) { + return getArtifactPath(groupId, artifactId, version, type, null); + } + + /** + * @param type an extension, or a packaging type such as {@code maven-plugin} or {@code test-jar} that is stored + * with another extension and classifier + * @param classifier {@code null} or empty for none; a classifier of the type is used when none is given + * @return the absolute path of the artifact in the local repository + */ + public String getArtifactPath(String groupId, String artifactId, String version, String type, String classifier) { + String[] extensionAndClassifier = extensionAndClassifier(type); + String extension = extensionAndClassifier[0]; + if ((classifier == null || classifier.isEmpty()) && extensionAndClassifier[1] != null) { + classifier = extensionAndClassifier[1]; + } + String gav = groupId + ":" + artifactId + ":" + extension + + (classifier != null && !classifier.isEmpty() ? ":" + classifier : "") + ":" + version; + return new File(getLocalRepository(), executorTool.artifactPath(toolRequest(), gav, null)).getPath(); + } + + /** + * Maps a type that does not name its own file to the extension and classifier it is stored with, as + * {@code {extension, classifier}} with a {@code null} classifier for none. Override it for types of a suite. + */ + protected String[] extensionAndClassifier(String type) { + String[] known = TYPES.get(type); + return known != null ? known.clone() : new String[] {type, null}; + } + + /** + * @return the artifact file and the {@code maven-metadata*.xml} files next to it and in its artifact directory + */ + public List getArtifactFileNameList(String groupId, String artifactId, String version, String type) { + List files = new ArrayList<>(); + String artifactPath = getArtifactPath(groupId, artifactId, version, type); + files.add(artifactPath); + File versionDir = new File(artifactPath).getParentFile(); + addMetadataToList(versionDir, null, files); + addMetadataToList(versionDir.getParentFile(), null, files); + return files; + } + + /** + * @return the path of {@code maven-metadata-local.xml} of the group, artifact or version + */ + public String getArtifactMetadataPath(String groupId, String artifactId, String version) { + return getArtifactMetadataPath(groupId, artifactId, version, "maven-metadata-local.xml"); + } + + public String getArtifactMetadataPath(String groupId, String artifactId) { + return getArtifactMetadataPath(groupId, artifactId, null); + } + + /** + * @param artifactId {@code null} for a group-level file + * @param version {@code null} for an artifact-level file + * @param filename the file name as it is in the local repository + * @return the absolute path of the file in the local repository + */ + public String getArtifactMetadataPath(String groupId, String artifactId, String version, String filename) { + Path path = Paths.get(getLocalRepository()).resolve(groupId.replace('.', '/')); + if (artifactId != null) { + path = path.resolve(artifactId); + if (version != null) { + path = path.resolve(version); + } + } + return path.resolve(filename).toString(); + } + + public void verifyArtifactPresent(String groupId, String artifactId, String version, String type) + throws VerificationException { + verifyFilePresence(getArtifactPath(groupId, artifactId, version, type), true); + } + + public void verifyArtifactNotPresent(String groupId, String artifactId, String version, String type) + throws VerificationException { + verifyFilePresence(getArtifactPath(groupId, artifactId, version, type), false); + } + + /** + * Fails unless the artifact exists with exactly this content, read in the platform encoding. + */ + public void verifyArtifactContent(String groupId, String artifactId, String version, String type, String content) + throws IOException, VerificationException { + String fileName = getArtifactPath(groupId, artifactId, version, type); + if (!content.equals(new String(Files.readAllBytes(Paths.get(fileName)), Charset.defaultCharset()))) { + throw new VerificationException("Content of " + fileName + " does not equal " + content); + } + } + + /** + * Deletes the artifact file and the {@code maven-metadata*.xml} files next to it and in its artifact directory. + */ + public void deleteArtifact(String groupId, String artifactId, String version, String type) throws IOException { + for (String fileName : getArtifactFileNameList(groupId, artifactId, version, type)) { + Files.deleteIfExists(Paths.get(fileName)); + } + } + + /** + * Deletes everything of the group in the local repository. + */ + public void deleteArtifacts(String groupId) throws IOException { + ResourceExtractor.deleteRecursively(Paths.get(getLocalRepository(), groupId.replace('.', '/'))); + } + + /** + * Deletes everything of the version in the local repository. + */ + public void deleteArtifacts(String groupId, String artifactId, String version) throws IOException { + ResourceExtractor.deleteRecursively( + Paths.get(getLocalRepository(), groupId.replace('.', '/'), artifactId, version)); + } + + private static Charset charset(String encoding) { + return encoding == null || encoding.isEmpty() ? Charset.defaultCharset() : Charset.forName(encoding); + } +} diff --git a/maven-executor-verifier/src/test/java/org/apache/maven/executor/verifier/ResourceExtractorTest.java b/maven-executor-verifier/src/test/java/org/apache/maven/executor/verifier/ResourceExtractorTest.java new file mode 100644 index 0000000..13bf948 --- /dev/null +++ b/maven-executor-verifier/src/test/java/org/apache/maven/executor/verifier/ResourceExtractorTest.java @@ -0,0 +1,101 @@ +/* + * 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.executor.verifier; + +import java.io.File; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +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.assertTrue; + +class ResourceExtractorTest { + @TempDir + Path temp; + + /** Loaded from the test jar, so that its resources are looked up there. */ + public static class Marker {} + + @Test + void extractsADirectoryWithSeveralFilesFromAJar() throws Exception { + Path jar = temp.resolve("projects.jar"); + String markerClass = Marker.class.getName().replace('.', '/') + ".class"; + try (OutputStream out = Files.newOutputStream(jar); + ZipOutputStream zip = new ZipOutputStream(out)) { + zip.putNextEntry(new ZipEntry(markerClass)); + try (InputStream in = Marker.class.getClassLoader().getResourceAsStream(markerClass)) { + byte[] buffer = new byte[8192]; + int n; + while ((n = in.read(buffer)) > 0) { + zip.write(buffer, 0, n); + } + } + zip.closeEntry(); + zip.putNextEntry(new ZipEntry("it0001/")); + zip.closeEntry(); + entry(zip, "it0001/pom.xml", ""); + entry(zip, "it0001/src/main/resources/a.txt", "a"); + entry(zip, "it0001/src/main/resources/b.txt", "b"); + entry(zip, "it0002/pom.xml", ""); + } + + try (URLClassLoader loader = new URLClassLoader(new URL[] {jar.toUri().toURL()}, null)) { + Class marker = loader.loadClass(Marker.class.getName()); + File dir = ResourceExtractor.extractResourcePath( + marker, "/it0001", temp.resolve("out").toFile()); + + assertEquals(temp.resolve("out").resolve("it0001").toFile(), dir); + assertEquals("", read(dir.toPath().resolve("pom.xml"))); + assertEquals("a", read(dir.toPath().resolve("src/main/resources/a.txt"))); + assertEquals("b", read(dir.toPath().resolve("src/main/resources/b.txt"))); + assertTrue(!Files.exists(temp.resolve("out").resolve("it0002"))); + } + } + + @Test + void usesOrCopiesADirectoryOnTheClassPath() throws Exception { + File inPlace = ResourceExtractor.extractResourcePath(getClass(), "/projects/simple", temp.toFile()); + assertTrue(new File(inPlace, "pom.xml").isFile()); + assertTrue(!inPlace.toPath().startsWith(temp)); + + File copy = ResourceExtractor.extractResourcePath(getClass(), "/projects/simple", temp.toFile(), true); + assertEquals(temp.resolve("projects/simple").toFile(), copy); + assertTrue(new File(copy, "pom.xml").isFile()); + } + + private static void entry(ZipOutputStream zip, String name, String content) throws Exception { + zip.putNextEntry(new ZipEntry(name)); + zip.write(content.getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + + private static String read(Path file) throws Exception { + return new String(Files.readAllBytes(file), StandardCharsets.UTF_8); + } +} diff --git a/maven-executor-verifier/src/test/java/org/apache/maven/executor/verifier/VerifierExecuteTest.java b/maven-executor-verifier/src/test/java/org/apache/maven/executor/verifier/VerifierExecuteTest.java new file mode 100644 index 0000000..53f97b8 --- /dev/null +++ b/maven-executor-verifier/src/test/java/org/apache/maven/executor/verifier/VerifierExecuteTest.java @@ -0,0 +1,124 @@ +/* + * 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.executor.verifier; + +import java.io.File; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.stream.Stream; + +import org.apache.maven.executor.ExecutorHelper; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link Verifier} running Maven 3 and Maven 4 on the test projects. Embedded Maven 4 is left out: it hangs instead of + * failing on a failed build (E1 in apache/maven-executor#49). + */ +@Timeout(300) +class VerifierExecuteTest { + private static final String GROUP_ID = "org.apache.maven.executor.verifier.its"; + + @TempDir + Path temp; + + static Stream mavens() { + return Stream.of( + Arguments.of("3", ExecutorHelper.Mode.FORKED), + Arguments.of("3", ExecutorHelper.Mode.EMBEDDED), + Arguments.of("4", ExecutorHelper.Mode.FORKED)); + } + + private Verifier verifier(String maven, ExecutorHelper.Mode mode, ExecutorHelper helper, String project) + throws Exception { + File dir = ResourceExtractor.extractResourcePath(getClass(), "/projects/" + project, temp.toFile(), true); + Verifier verifier = new Verifier(dir.getAbsolutePath(), helper); + verifier.setLocalRepo(temp.resolve("repo").toString()); + verifier.addCliArgument("-Daether.remoteRepositoryFilter.prefixes=false"); + if (System.getProperty("localRepository") != null) { + verifier.setSystemProperty("maven.repo.local.tail", System.getProperty("localRepository")); + } + return verifier; + } + + private static ExecutorHelper helper(String maven, ExecutorHelper.Mode mode) { + return ExecutorHelper.forMavenInstallation( + Paths.get(System.getProperty("3".equals(maven) ? "maven3home" : "maven4home")), mode); + } + + @ParameterizedTest + @MethodSource("mavens") + void installsAndChecksTheLocalRepository(String maven, ExecutorHelper.Mode mode) throws Exception { + try (ExecutorHelper helper = helper(maven, mode)) { + Verifier verifier = verifier(maven, mode, helper, "simple"); + assertTrue(verifier.getMavenVersion().startsWith(maven + "."), verifier.getMavenVersion()); + verifier.addCliArgument("install"); + verifier.execute(); + + verifier.verifyErrorFreeLog(); + verifier.verifyTextInLog("BUILD SUCCESS"); + verifier.verifyFilePresent("log.txt"); + verifier.verifyArtifactPresent(GROUP_ID, "simple", "1.0", "pom"); + verifier.verifyArtifactNotPresent(GROUP_ID, "simple", "1.0", "jar"); + + verifier.deleteArtifacts(GROUP_ID); + verifier.verifyArtifactNotPresent(GROUP_ID, "simple", "1.0", "pom"); + } + } + + @ParameterizedTest + @MethodSource("mavens") + void passesAnOptionAsSeparateArguments(String maven, ExecutorHelper.Mode mode) throws Exception { + try (ExecutorHelper helper = helper(maven, mode)) { + Verifier verifier = verifier(maven, mode, helper, "reactor"); + verifier.setAutoclean(false); + verifier.addCliOption("-pl b -am validate"); + verifier.execute(); + + verifier.verifyErrorFreeLog(); + verifier.verifyTextInLog("Building a 1.0"); + verifier.verifyTextInLog("Building b 1.0"); + verifier.verifyTextNotInLog("Building c 1.0"); + } + } + + @ParameterizedTest + @MethodSource("mavens") + void failedBuildThrowsAndKeepsTheLog(String maven, ExecutorHelper.Mode mode) throws Exception { + try (ExecutorHelper helper = helper(maven, mode)) { + Verifier verifier = verifier(maven, mode, helper, "failing"); + verifier.setAutoclean(false); + verifier.setLogFileName("failing-log.txt"); + verifier.addCliArgument("validate"); + + VerificationException failure = assertThrows(VerificationException.class, verifier::execute); + assertTrue(failure.getMessage().contains("Exit code was non-zero"), failure.getMessage()); + + verifier.verifyFilePresent("failing-log.txt"); + verifier.verifyTextInLog("no-such-packaging"); + assertThrows(VerificationException.class, verifier::verifyErrorFreeLog); + } + } +} diff --git a/maven-executor-verifier/src/test/java/org/apache/maven/executor/verifier/VerifierTest.java b/maven-executor-verifier/src/test/java/org/apache/maven/executor/verifier/VerifierTest.java new file mode 100644 index 0000000..ec8a675 --- /dev/null +++ b/maven-executor-verifier/src/test/java/org/apache/maven/executor/verifier/VerifierTest.java @@ -0,0 +1,225 @@ +/* + * 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.executor.verifier; + +import java.io.File; +import java.io.OutputStream; +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.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.apache.maven.executor.ExecutorHelper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The checks and paths of {@link Verifier}, without running Maven. + */ +class VerifierTest { + @TempDir + Path basedir; + + private Path repo; + + private ExecutorHelper helper; + + private Verifier verifier; + + @BeforeEach + void beforeEach() { + repo = basedir.resolve("repo"); + helper = ExecutorHelper.forMavenInstallation( + Paths.get(System.getProperty("maven3home")), ExecutorHelper.Mode.FORKED); + verifier = new Verifier(basedir.toString(), helper); + verifier.setLocalRepo(repo.toString()); + } + + @AfterEach + void afterEach() { + helper.close(); + } + + @Test + void addCliOptionSplitsAndAddCliArgumentDoesNot() { + verifier.addCliOption(" -pl module-app -am "); + verifier.addCliArgument("-Dname=a value"); + assertEquals(Arrays.asList("-pl", "module-app", "-am", "-Dname=a value"), verifier.getCliArguments()); + } + + @Test + void localRepositoryFromArgumentsAndSystemProperties() { + Verifier fresh = new Verifier(basedir.toString(), helper); + Path fromArgument = basedir.resolve("from-argument"); + fresh.addCliArgument("-Dmaven.repo.local=" + fromArgument); + assertEquals(fromArgument.toString(), fresh.getLocalRepository()); + + Verifier withProperty = new Verifier(basedir.toString(), helper); + Path fromProperty = basedir.resolve("from-property"); + withProperty.setSystemProperty("maven.repo.local", fromProperty.toString()); + assertEquals(fromProperty.toString(), withProperty.getLocalRepository()); + + assertEquals(repo.toString(), verifier.getLocalRepository(), "setLocalRepo wins"); + } + + @Test + void artifactPaths() { + assertEquals( + path("org/example/lib/1.0/lib-1.0.jar"), verifier.getArtifactPath("org.example", "lib", "1.0", "jar")); + assertEquals( + path("org/example/plugin/1.0/plugin-1.0.jar"), + verifier.getArtifactPath("org.example", "plugin", "1.0", "maven-plugin")); + assertEquals( + path("org/example/lib/1.0/lib-1.0-tests.jar"), + verifier.getArtifactPath("org.example", "lib", "1.0", "test-jar")); + assertEquals( + path("org/example/lib/1.0-SNAPSHOT/lib-1.0-SNAPSHOT-bin.zip"), + verifier.getArtifactPath("org.example", "lib", "1.0-SNAPSHOT", "zip", "bin")); + assertEquals( + path("org/example/lib/maven-metadata-local.xml"), + verifier.getArtifactMetadataPath("org.example", "lib")); + assertEquals( + path("org/example/maven-metadata-central.xml"), + verifier.getArtifactMetadataPath("org.example", null, null, "maven-metadata-central.xml")); + } + + @Test + void suiteTypesThroughOverride() { + Verifier coreIts = new Verifier(basedir.toString(), helper) { + @Override + protected String[] extensionAndClassifier(String type) { + return "coreit-artifact".equals(type) ? new String[] {"jar", "it"} : super.extensionAndClassifier(type); + } + }; + coreIts.setLocalRepo(repo.toString()); + assertEquals( + path("org/example/lib/1.0/lib-1.0-it.jar"), + coreIts.getArtifactPath("org.example", "lib", "1.0", "coreit-artifact")); + } + + @Test + void artifactChecksAndDeletion() throws Exception { + assertThrows( + VerificationException.class, () -> verifier.verifyArtifactPresent("org.example", "lib", "1.0", "jar")); + write(verifier.getArtifactPath("org.example", "lib", "1.0", "jar"), "content"); + write(verifier.getArtifactMetadataPath("org.example", "lib"), ""); + verifier.verifyArtifactPresent("org.example", "lib", "1.0", "jar"); + verifier.verifyArtifactContent("org.example", "lib", "1.0", "jar", "content"); + assertEquals( + 2, + verifier.getArtifactFileNameList("org.example", "lib", "1.0", "jar") + .size()); + + verifier.deleteArtifact("org.example", "lib", "1.0", "jar"); + verifier.verifyArtifactNotPresent("org.example", "lib", "1.0", "jar"); + assertFalse(Files.exists(Paths.get(verifier.getArtifactMetadataPath("org.example", "lib")))); + + write(verifier.getArtifactPath("org.example", "other", "2.0", "pom"), ""); + verifier.deleteArtifacts("org.example"); + assertFalse(Files.exists(repo.resolve("org").resolve("example"))); + } + + @Test + void logChecks() throws Exception { + write( + basedir.resolve("log.txt").toString(), + "[INFO] \u001B[1mBUILD SUCCESS\u001B[m\n" + + "[ERROR] VM_global_library.vm not found\n" + + "[ERROR] VM #displayTree: error : too few arguments to macro\n"); + verifier.verifyTextInLog("[INFO] BUILD SUCCESS"); + verifier.verifyTextNotInLog("BUILD FAILURE"); + assertThrows(VerificationException.class, () -> verifier.verifyTextNotInLog("BUILD SUCCESS")); + assertThrows(VerificationException.class, () -> verifier.verifyTextInLog("BUILD FAILURE")); + verifier.verifyErrorFreeLog(); + + verifier.setLogFileName("other.txt"); + write(basedir.resolve("other.txt").toString(), "[ERROR] Failed to execute goal"); + assertThrows(VerificationException.class, verifier::verifyErrorFreeLog); + } + + @Test + void fileChecks() throws Exception { + write(basedir.resolve("target/app-1.0.jar").toString(), ""); + verifier.verifyFilePresent("target/app-1.0.jar"); + verifier.verifyFilePresent("target/app-*.jar"); + verifier.verifyFileNotPresent("target/app-*.war"); + verifier.verifyFileNotPresent(basedir.resolve("missing.txt").toString()); + assertThrows(VerificationException.class, () -> verifier.verifyFilePresent("target/missing.jar")); + + try (OutputStream out = Files.newOutputStream(basedir.resolve("target/entries.jar")); + ZipOutputStream zip = new ZipOutputStream(out)) { + zip.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF")); + zip.closeEntry(); + } + verifier.verifyFilePresent("target/entries.jar!/META-INF/MANIFEST.MF"); + verifier.verifyFileNotPresent("target/entries.jar!/missing.txt"); + } + + @Test + void filterFileAndLoad() throws Exception { + write(basedir.resolve("src.txt").toString(), "dir=@basedir@\nurl=@baseurl@\nkey=@key@\n"); + Map filterMap = verifier.newDefaultFilterMap(); + filterMap.put("@key@", "value"); + File filtered = verifier.filterFile("src.txt", "out/filtered.properties", "UTF-8", filterMap); + assertEquals(basedir.resolve("out").resolve("filtered.properties").toFile(), filtered); + assertEquals("value", verifier.loadProperties("out/filtered.properties").getProperty("key")); + assertEquals( + basedir.toAbsolutePath().toString(), + verifier.loadProperties("out/filtered.properties").getProperty("dir")); + assertEquals(3, verifier.loadLines("src.txt", "UTF-8").size()); + } + + @Test + void loadFileReplacesArtifactMarkers() throws Exception { + write(verifier.getArtifactMetadataPath("org.example", "lib", "1.0"), ""); + write( + basedir.resolve("expected.txt").toString(), + "# comment\n\n ${artifact:org.example:lib:1.0:jar}\nplain\n"); + assertTrue(verifier.loadFile(basedir.toString(), "missing.txt", false).isEmpty()); + List lines = verifier.loadFile(basedir.toString(), "expected.txt", false); + assertEquals( + Arrays.asList( + verifier.getArtifactPath("org.example", "lib", "1.0", "jar"), + verifier.getArtifactMetadataPath("org.example", "lib", "1.0"), + "plain"), + lines); + } + + private String path(String relative) { + return repo.resolve(relative.replace('/', File.separatorChar)).toString(); + } + + private static void write(String file, String content) throws Exception { + Path path = Paths.get(file); + Files.createDirectories(path.toAbsolutePath().getParent()); + Files.write(path, content.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/maven-executor-verifier/src/test/resources/projects/failing/pom.xml b/maven-executor-verifier/src/test/resources/projects/failing/pom.xml new file mode 100644 index 0000000..5f61100 --- /dev/null +++ b/maven-executor-verifier/src/test/resources/projects/failing/pom.xml @@ -0,0 +1,28 @@ + + + + 4.0.0 + + org.apache.maven.executor.verifier.its + failing + 1.0 + no-such-packaging + diff --git a/maven-executor-verifier/src/test/resources/projects/reactor/a/pom.xml b/maven-executor-verifier/src/test/resources/projects/reactor/a/pom.xml new file mode 100644 index 0000000..dd34b94 --- /dev/null +++ b/maven-executor-verifier/src/test/resources/projects/reactor/a/pom.xml @@ -0,0 +1,33 @@ + + + + 4.0.0 + + org.apache.maven.executor.verifier.its + reactor + 1.0 + + org.apache.maven.executor.verifier.its + a + 1.0 + pom + + diff --git a/maven-executor-verifier/src/test/resources/projects/reactor/b/pom.xml b/maven-executor-verifier/src/test/resources/projects/reactor/b/pom.xml new file mode 100644 index 0000000..666b8b8 --- /dev/null +++ b/maven-executor-verifier/src/test/resources/projects/reactor/b/pom.xml @@ -0,0 +1,40 @@ + + + + 4.0.0 + + org.apache.maven.executor.verifier.its + reactor + 1.0 + + org.apache.maven.executor.verifier.its + b + 1.0 + pom + + + org.apache.maven.executor.verifier.its + a + 1.0 + pom + + + diff --git a/maven-executor-verifier/src/test/resources/projects/reactor/c/pom.xml b/maven-executor-verifier/src/test/resources/projects/reactor/c/pom.xml new file mode 100644 index 0000000..6b935a4 --- /dev/null +++ b/maven-executor-verifier/src/test/resources/projects/reactor/c/pom.xml @@ -0,0 +1,33 @@ + + + + 4.0.0 + + org.apache.maven.executor.verifier.its + reactor + 1.0 + + org.apache.maven.executor.verifier.its + c + 1.0 + pom + + diff --git a/maven-executor-verifier/src/test/resources/projects/reactor/pom.xml b/maven-executor-verifier/src/test/resources/projects/reactor/pom.xml new file mode 100644 index 0000000..f49a086 --- /dev/null +++ b/maven-executor-verifier/src/test/resources/projects/reactor/pom.xml @@ -0,0 +1,33 @@ + + + + 4.0.0 + + org.apache.maven.executor.verifier.its + reactor + 1.0 + pom + + a + b + c + + diff --git a/maven-executor-verifier/src/test/resources/projects/simple/pom.xml b/maven-executor-verifier/src/test/resources/projects/simple/pom.xml new file mode 100644 index 0000000..2635165 --- /dev/null +++ b/maven-executor-verifier/src/test/resources/projects/simple/pom.xml @@ -0,0 +1,29 @@ + + + + 4.0.0 + + org.apache.maven.executor.verifier.its + simple + 1.0 + pom + + diff --git a/pom.xml b/pom.xml index 380733d..213ac09 100644 --- a/pom.xml +++ b/pom.xml @@ -40,6 +40,7 @@ test-utilities maven-executor + maven-executor-verifier providers From a59ed17c11a38c99d2271ae15a2ac7162e0a5106 Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Mon, 5 Oct 2026 20:18:42 +0000 Subject: [PATCH 2/2] Run the verifier tests on embedded Maven 4 too The rc-6 deadlock behind E1 is fixed in 4.0.0-rc-7, which the build uses; all three scenarios pass embedded. Co-Authored-By: Claude Opus 5.5 --- .../apache/maven/executor/verifier/VerifierExecuteTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/maven-executor-verifier/src/test/java/org/apache/maven/executor/verifier/VerifierExecuteTest.java b/maven-executor-verifier/src/test/java/org/apache/maven/executor/verifier/VerifierExecuteTest.java index 53f97b8..dd2ecca 100644 --- a/maven-executor-verifier/src/test/java/org/apache/maven/executor/verifier/VerifierExecuteTest.java +++ b/maven-executor-verifier/src/test/java/org/apache/maven/executor/verifier/VerifierExecuteTest.java @@ -34,8 +34,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** - * {@link Verifier} running Maven 3 and Maven 4 on the test projects. Embedded Maven 4 is left out: it hangs instead of - * failing on a failed build (E1 in apache/maven-executor#49). + * {@link Verifier} running Maven 3 and Maven 4, forked and embedded, on the test projects. */ @Timeout(300) class VerifierExecuteTest { @@ -48,7 +47,8 @@ static Stream mavens() { return Stream.of( Arguments.of("3", ExecutorHelper.Mode.FORKED), Arguments.of("3", ExecutorHelper.Mode.EMBEDDED), - Arguments.of("4", ExecutorHelper.Mode.FORKED)); + Arguments.of("4", ExecutorHelper.Mode.FORKED), + Arguments.of("4", ExecutorHelper.Mode.EMBEDDED)); } private Verifier verifier(String maven, ExecutorHelper.Mode mode, ExecutorHelper helper, String project)