From 6f8b9288f3e0f192aa5977c282668f7fb66d2e7e Mon Sep 17 00:00:00 2001 From: Stephane Nicoll Date: Thu, 28 Jul 2022 13:45:21 +0200 Subject: [PATCH] Extract AotGenerateMojo to its own structure This commit stops AotGenerateMojo from being an extension of the regular run infrastructure and used the opportunity to extract a number of utility classes to run a Java process. As a result, not all features of running an application is supported and exposed options now are targeted against AOT. See gh-31682 --- .../maven/AbstractDependencyFilterMojo.java | 48 +++++- .../boot/maven/AotGenerateMojo.java | 141 ++++++++++++------ .../boot/maven/CommandLineBuilder.java | 135 +++++++++++++++++ .../boot/maven/JavaProcessExecutor.java | 75 ++++++++++ .../SpringBootApplicationClassFinder.java | 50 +++++++ .../boot/maven/CommandLineBuilderTests.java | 78 ++++++++++ 6 files changed, 479 insertions(+), 48 deletions(-) create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CommandLineBuilder.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/JavaProcessExecutor.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/SpringBootApplicationClassFinder.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/CommandLineBuilderTests.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractDependencyFilterMojo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractDependencyFilterMojo.java index c2074afb6b..cc0929ba64 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractDependencyFilterMojo.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractDependencyFilterMojo.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-2022 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,10 @@ package org.springframework.boot.maven; +import java.io.File; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; @@ -25,6 +29,8 @@ import org.apache.maven.artifact.Artifact; import org.apache.maven.plugin.AbstractMojo; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.project.MavenProject; +import org.apache.maven.shared.artifact.filter.collection.AbstractArtifactFeatureFilter; import org.apache.maven.shared.artifact.filter.collection.ArtifactFilterException; import org.apache.maven.shared.artifact.filter.collection.ArtifactsFilter; import org.apache.maven.shared.artifact.filter.collection.FilterArtifacts; @@ -38,6 +44,13 @@ import org.apache.maven.shared.artifact.filter.collection.FilterArtifacts; */ public abstract class AbstractDependencyFilterMojo extends AbstractMojo { + /** + * The Maven project. + * @since 3.0.0 + */ + @Parameter(defaultValue = "${project}", readonly = true, required = true) + protected MavenProject project; + /** * Collection of artifact definitions to include. The {@link Include} element defines * mandatory {@code groupId} and {@code artifactId} properties and an optional @@ -76,6 +89,26 @@ public abstract class AbstractDependencyFilterMojo extends AbstractMojo { this.excludeGroupIds = excludeGroupIds; } + protected List getDependencyURLs(ArtifactsFilter... additionalFilters) throws MojoExecutionException { + Set artifacts = filterDependencies(this.project.getArtifacts(), getFilters(additionalFilters)); + List urls = new ArrayList<>(); + for (Artifact artifact : artifacts) { + if (artifact.getFile() != null) { + urls.add(toURL(artifact.getFile())); + } + } + return urls; + } + + protected URL toURL(File file) { + try { + return file.toURI().toURL(); + } + catch (MalformedURLException ex) { + throw new IllegalStateException("Invalid URL for " + file, ex); + } + } + protected final Set filterDependencies(Set dependencies, FilterArtifacts filters) throws MojoExecutionException { try { @@ -124,4 +157,17 @@ public abstract class AbstractDependencyFilterMojo extends AbstractMojo { return cleaned.toString(); } + static class TestArtifactFilter extends AbstractArtifactFeatureFilter { + + TestArtifactFilter() { + super("", Artifact.SCOPE_TEST); + } + + @Override + protected String getArtifactFeature(Artifact artifact) { + return artifact.getScope(); + } + + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AotGenerateMojo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AotGenerateMojo.java index 09bf791279..2c6a66dd8c 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AotGenerateMojo.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AotGenerateMojo.java @@ -23,6 +23,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Locale; @@ -36,14 +37,18 @@ import javax.tools.JavaFileObject; import javax.tools.StandardJavaFileManager; import javax.tools.ToolProvider; +import org.apache.maven.execution.MavenSession; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.Component; import org.apache.maven.plugins.annotations.LifecyclePhase; import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; import org.apache.maven.plugins.annotations.ResolutionScope; +import org.apache.maven.toolchain.ToolchainManager; -import org.springframework.boot.loader.tools.RunProcess; +import org.springframework.boot.maven.CommandLineBuilder.ClasspathBuilder; +import org.springframework.util.ObjectUtils; /** * Invoke the AOT engine on the application. @@ -55,10 +60,35 @@ import org.springframework.boot.loader.tools.RunProcess; @Mojo(name = "aot-generate", defaultPhase = LifecyclePhase.PREPARE_PACKAGE, threadSafe = true, requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME, requiresDependencyCollection = ResolutionScope.COMPILE_PLUS_RUNTIME) -public class AotGenerateMojo extends AbstractRunMojo { +public class AotGenerateMojo extends AbstractDependencyFilterMojo { private static final String AOT_PROCESSOR_CLASS_NAME = "org.springframework.boot.AotProcessor"; + /** + * The current Maven session. This is used for toolchain manager API calls. + */ + @Parameter(defaultValue = "${session}", readonly = true) + private MavenSession session; + + /** + * The toolchain manager to use to locate a custom JDK. + */ + @Component + private ToolchainManager toolchainManager; + + /** + * Directory containing the classes and resource files that should be packaged into + * the archive. + */ + @Parameter(defaultValue = "${project.build.outputDirectory}", required = true) + private File classesDirectory; + + /** + * Skip the execution. + */ + @Parameter(property = "spring-boot.aot.skip", defaultValue = "false") + private boolean skip; + /** * Directory containing the generated sources. */ @@ -77,11 +107,40 @@ public class AotGenerateMojo extends AbstractRunMojo { @Parameter(defaultValue = "${project.build.directory}/spring-aot/main/classes", required = true) private File generatedClasses; + /** + * List of JVM system properties to pass to the AOT process. + */ + @Parameter + private Map systemPropertyVariables; + + /** + * JVM arguments that should be associated with the AOT process. On command line, make + * sure to wrap multiple values between quotes. + */ + @Parameter(property = "spring-boot.aot.jvmArguments") + private String jvmArguments; + + /** + * Name of the main class to use as the source for the AOT process. If not specified + * the first compiled class found that contains a 'main' method will be used. + */ + @Parameter(property = "spring-boot.aot.main-class") + private String mainClass; + + /** + * Spring profiles to take into account for AOT processing. + */ + @Parameter + private String[] profiles; + @Override - protected void run(File workingDirectory, String startClassName, Map environmentVariables) - throws MojoExecutionException, MojoFailureException { + public void execute() throws MojoExecutionException, MojoFailureException { + if (this.skip) { + getLog().debug("skipping execution as per configuration."); + return; + } try { - generateAotAssets(workingDirectory, startClassName, environmentVariables); + generateAotAssets(); compileSourceFiles(); copyAll(this.generatedResources.toPath().resolve("META-INF/native-image"), this.classesDirectory.toPath().resolve("META-INF/native-image")); @@ -92,52 +151,39 @@ public class AotGenerateMojo extends AbstractRunMojo { } } - private void generateAotAssets(File workingDirectory, String startClassName, - Map environmentVariables) throws MojoExecutionException { - List args = new ArrayList<>(); - addJvmArgs(args); - addClasspath(args); - args.add(AOT_PROCESSOR_CLASS_NAME); - // Adding arguments that are necessary for generation - args.add(startClassName); - args.add(this.generatedSources.toString()); - args.add(this.generatedResources.toString()); - args.add(this.generatedClasses.toString()); - args.add(this.project.getGroupId()); - args.add(this.project.getArtifactId()); - addArgs(args); + private void generateAotAssets() throws MojoExecutionException { + String applicationClass = (this.mainClass != null) ? this.mainClass + : SpringBootApplicationClassFinder.findSingleClass(this.classesDirectory); + List aotArguments = new ArrayList<>(); + aotArguments.add(applicationClass); + aotArguments.add(this.generatedSources.toString()); + aotArguments.add(this.generatedResources.toString()); + aotArguments.add(this.generatedClasses.toString()); + aotArguments.add(this.project.getGroupId()); + aotArguments.add(this.project.getArtifactId()); + if (!ObjectUtils.isEmpty(this.profiles)) { + aotArguments.add("--spring.profiles.active=" + String.join(",", this.profiles)); + } + // @formatter:off + List args = CommandLineBuilder.forMainClass(AOT_PROCESSOR_CLASS_NAME) + .withSystemProperties(this.systemPropertyVariables) + .withJvmArguments(new RunArguments(this.jvmArguments).asArray()) + .withClasspath(getClassPathUrls()) + .withArguments(aotArguments.toArray(String[]::new)) + .build(); + // @formatter:on if (getLog().isDebugEnabled()) { getLog().debug("Generating AOT assets using command: " + args); } - int exitCode = forkJvm(workingDirectory, args, environmentVariables); - if (!hasTerminatedSuccessfully(exitCode)) { - throw new MojoExecutionException("AOT generation process finished with exit code: " + exitCode); - } + JavaProcessExecutor processExecutor = new JavaProcessExecutor(this.session, this.toolchainManager); + processExecutor.run(this.project.getBasedir(), args, Collections.emptyMap()); } - private int forkJvm(File workingDirectory, List args, Map environmentVariables) - throws MojoExecutionException { - try { - RunProcess runProcess = new RunProcess(workingDirectory, getJavaExecutable()); - return runProcess.run(true, args, environmentVariables); - } - catch (Exception ex) { - throw new MojoExecutionException("Could not exec java", ex); - } - } - - @Override - protected URL[] getClassPathUrls() throws MojoExecutionException { - try { - List urls = new ArrayList<>(); - addUserDefinedDirectories(urls); - addProjectClasses(urls); - addDependencies(urls, getFilters(new TestArtifactFilter())); - return urls.toArray(new URL[0]); - } - catch (IOException ex) { - throw new MojoExecutionException("Unable to build classpath", ex); - } + private URL[] getClassPathUrls() throws MojoExecutionException { + List urls = new ArrayList<>(); + urls.add(toURL(this.classesDirectory)); + urls.addAll(getDependencyURLs(new TestArtifactFilter())); + return urls.toArray(URL[]::new); } private void compileSourceFiles() throws IOException, MojoExecutionException { @@ -148,7 +194,8 @@ public class AotGenerateMojo extends AbstractRunMojo { JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); try (StandardJavaFileManager fm = compiler.getStandardFileManager(null, null, null)) { List options = new ArrayList<>(); - addClasspath(options); + options.add("-cp"); + options.add(ClasspathBuilder.build(Arrays.asList(getClassPathUrls()))); options.add("-d"); options.add(this.classesDirectory.toPath().toAbsolutePath().toString()); Iterable compilationUnits = fm.getJavaFileObjectsFromPaths(sourceFiles); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CommandLineBuilder.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CommandLineBuilder.java new file mode 100644 index 0000000000..0b98e90806 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CommandLineBuilder.java @@ -0,0 +1,135 @@ +/* + * Copyright 2012-2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.maven; + +import java.io.File; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Helper class to build the command-line arguments of a java process. + * + * @author Stephane Nicoll + */ +final class CommandLineBuilder { + + private final List options = new ArrayList<>(); + + private final List classpathElements = new ArrayList<>(); + + private final String mainClass; + + private final List arguments = new ArrayList<>(); + + private CommandLineBuilder(String mainClass) { + this.mainClass = mainClass; + } + + static CommandLineBuilder forMainClass(String mainClass) { + return new CommandLineBuilder(mainClass); + } + + CommandLineBuilder withJvmArguments(String... jvmArguments) { + if (jvmArguments != null) { + this.options.addAll(Arrays.stream(jvmArguments).filter(Objects::nonNull).toList()); + } + return this; + } + + CommandLineBuilder withSystemProperties(Map systemProperties) { + if (systemProperties != null) { + systemProperties.entrySet().stream().map((e) -> SystemPropertyFormatter.format(e.getKey(), e.getValue())) + .forEach(this.options::add); + } + return this; + } + + CommandLineBuilder withClasspath(URL... elements) { + this.classpathElements.addAll(Arrays.asList(elements)); + return this; + } + + CommandLineBuilder withArguments(String... arguments) { + if (arguments != null) { + this.arguments.addAll(Arrays.stream(arguments).filter(Objects::nonNull).toList()); + } + return this; + } + + List build() { + List commandLine = new ArrayList<>(); + if (!this.options.isEmpty()) { + commandLine.addAll(this.options); + } + if (!this.classpathElements.isEmpty()) { + commandLine.add("-cp"); + commandLine.add(ClasspathBuilder.build(this.classpathElements)); + } + commandLine.add(this.mainClass); + if (!this.arguments.isEmpty()) { + commandLine.addAll(this.arguments); + } + return commandLine; + } + + static class ClasspathBuilder { + + static String build(List classpathElements) { + StringBuilder classpath = new StringBuilder(); + for (URL element : classpathElements) { + if (classpath.length() > 0) { + classpath.append(File.pathSeparator); + } + classpath.append(toFile(element)); + } + return classpath.toString(); + } + + private static File toFile(URL element) { + try { + return new File(element.toURI()); + } + catch (URISyntaxException ex) { + throw new IllegalArgumentException(ex); + } + } + + } + + /** + * Format System properties. + */ + private static class SystemPropertyFormatter { + + static String format(String key, String value) { + if (key == null) { + return ""; + } + if (value == null || value.isEmpty()) { + return String.format("-D%s", key); + } + return String.format("-D%s=\"%s\"", key, value); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/JavaProcessExecutor.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/JavaProcessExecutor.java new file mode 100644 index 0000000000..94941b6a1f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/JavaProcessExecutor.java @@ -0,0 +1,75 @@ +/* + * Copyright 2012-2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.maven; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.toolchain.Toolchain; +import org.apache.maven.toolchain.ToolchainManager; + +import org.springframework.boot.loader.tools.JavaExecutable; +import org.springframework.boot.loader.tools.RunProcess; + +/** + * Ease the execution of a Java process using Maven's toolchain support. + * + * @author Stephane Nicoll + */ +class JavaProcessExecutor { + + private static final int EXIT_CODE_SIGINT = 130; + + private final MavenSession mavenSession; + + private final ToolchainManager toolchainManager; + + JavaProcessExecutor(MavenSession mavenSession, ToolchainManager toolchainManager) { + this.mavenSession = mavenSession; + this.toolchainManager = toolchainManager; + } + + int run(File workingDirectory, List args, Map environmentVariables) + throws MojoExecutionException { + RunProcess runProcess = new RunProcess(workingDirectory, getJavaExecutable()); + try { + int exitCode = runProcess.run(true, args, environmentVariables); + if (!hasTerminatedSuccessfully(exitCode)) { + throw new MojoExecutionException("Process terminated with exit code: " + exitCode); + } + return exitCode; + } + catch (IOException ex) { + throw new MojoExecutionException("Process execution failed", ex); + } + } + + private boolean hasTerminatedSuccessfully(int exitCode) { + return (exitCode == 0 || exitCode == EXIT_CODE_SIGINT); + } + + private String getJavaExecutable() { + Toolchain toolchain = this.toolchainManager.getToolchainFromBuildContext("jdk", this.mavenSession); + String javaExecutable = (toolchain != null) ? toolchain.findTool("java") : null; + return (javaExecutable != null) ? javaExecutable : new JavaExecutable().toString(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/SpringBootApplicationClassFinder.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/SpringBootApplicationClassFinder.java new file mode 100644 index 0000000000..efa4757c28 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/SpringBootApplicationClassFinder.java @@ -0,0 +1,50 @@ +/* + * Copyright 2012-2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.maven; + +import java.io.File; +import java.io.IOException; + +import org.apache.maven.plugin.MojoExecutionException; + +import org.springframework.boot.loader.tools.MainClassFinder; + +/** + * Find a single Spring Boot Application class match based on directory. + * + * @author Stephane Nicoll + * @see MainClassFinder + */ +abstract class SpringBootApplicationClassFinder { + + private static final String SPRING_BOOT_APPLICATION_CLASS_NAME = "org.springframework.boot.autoconfigure.SpringBootApplication"; + + static String findSingleClass(File classesDirectory) throws MojoExecutionException { + try { + String mainClass = MainClassFinder.findSingleMainClass(classesDirectory, + SPRING_BOOT_APPLICATION_CLASS_NAME); + if (mainClass != null) { + return mainClass; + } + throw new MojoExecutionException("Unable to find a suitable main class, please add a 'mainClass' property"); + } + catch (IOException ex) { + throw new MojoExecutionException(ex.getMessage(), ex); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/CommandLineBuilderTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/CommandLineBuilderTests.java new file mode 100644 index 0000000000..aa2430f47a --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/CommandLineBuilderTests.java @@ -0,0 +1,78 @@ +/* + * Copyright 2012-2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.maven; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.maven.sample.ClassWithMainMethod; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link CommandLineBuilder}. + * + * @author Stephane Nicoll + */ +class CommandLineBuilderTests { + + public static final String CLASS_NAME = ClassWithMainMethod.class.getName(); + + @Test + void buildWithNullJvmArgumentsIsIgnored() { + assertThat(CommandLineBuilder.forMainClass(CLASS_NAME).withJvmArguments((String[]) null).build()) + .containsExactly(CLASS_NAME); + } + + @Test + void buildWithNullIntermediateJvmArgumentIsIgnored() { + assertThat(CommandLineBuilder.forMainClass(CLASS_NAME).withJvmArguments("-verbose:class", null, "-verbose:gc") + .build()).containsExactly("-verbose:class", "-verbose:gc", CLASS_NAME); + } + + @Test + void buildWithJvmArgument() { + assertThat(CommandLineBuilder.forMainClass(CLASS_NAME).withJvmArguments("-verbose:class").build()) + .containsExactly("-verbose:class", CLASS_NAME); + } + + @Test + void buildWithNullSystemPropertyIsIgnored() { + assertThat(CommandLineBuilder.forMainClass(CLASS_NAME).withSystemProperties(null).build()) + .containsExactly(CLASS_NAME); + } + + @Test + void buildWithSystemProperty() { + assertThat(CommandLineBuilder.forMainClass(CLASS_NAME).withSystemProperties(Map.of("flag", "enabled")).build()) + .containsExactly("-Dflag=\"enabled\"", CLASS_NAME); + } + + @Test + void buildWithNullArgumentsIsIgnored() { + assertThat(CommandLineBuilder.forMainClass(CLASS_NAME).withArguments((String[]) null).build()) + .containsExactly(CLASS_NAME); + } + + @Test + void buildWithNullIntermediateArgumentIsIgnored() { + assertThat(CommandLineBuilder.forMainClass(CLASS_NAME).withArguments("--test", null, "--another").build()) + .containsExactly(CLASS_NAME, "--test", "--another"); + } + +}