// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
using Aspire.Cli.Projects;
using Aspire.TestUtilities;
using Aspire.TypeSystem;
namespace Aspire.Cli.Tests.Projects;
public class JavaAppHostToolchainResolverTests(ITestOutputHelper outputHelper)
{
private static string WriteWrapper(string directory, string wrapperName)
{
var path = Path.Combine(directory, wrapperName);
File.WriteAllText(path, "");
return path;
}
/// <summary>
/// Asserts the wrapper invocation, accounting for Windows running the batch wrapper through the
/// command interpreter rather than launching it directly.
/// </summary>
private static void AssertWrapperInvocation(string wrapperPath, string appHostDirectory, string[] toolArgs, CommandSpec actual)
{
if (OperatingSystem.IsWindows())
{
Assert.Equal(Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe", actual.Command);
// "call" precedes the wrapper so the first token on the command line is never a quote,
// which is what stops cmd.exe from stripping quotes around a path containing a space.
Assert.Equal(
["/c", "call", Path.GetRelativePath(appHostDirectory, wrapperPath), .. toolArgs],
actual.Args);
return;
}
Assert.Equal("sh", actual.Command);
Assert.Equal([wrapperPath, .. toolArgs], actual.Args);
}
private static RuntimeSpec CreateJavacRuntimeSpec()
{
return new RuntimeSpec
{
Language = "java",
DisplayName = "Java",
CodeGenLanguage = "Java",
DetectionPatterns = ["AppHost.java", "src/main/java/AppHost.java"],
InstallDependencies = null,
ExtensionLaunchCapability = "java",
// Mirrors JavaLanguageSupport.GetRuntimeSpec: the build-tool compile command is derived
// from this one, so it has to have the same shape for the derivation to be meaningful.
PreExecute =
[
new CommandSpec
{
Command = "javac",
Args = ["--release", "25", "-d", ".java-build", "@.aspire/modules/sources.txt", "{appHostFile}"]
}
],
Execute = new CommandSpec
{
Command = "java",
Args = ["-cp", ".java-build", "AppHost"]
}
};
}
[Fact]
public void Resolve_WithNoBuildFile_UsesJavacSoAJdkIsTheOnlyRequirement()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
Assert.Equal(JavaAppHostToolchain.Javac, JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot).Toolchain);
}
[Fact]
public void Resolve_WithPomXml_UsesMaven()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.Path, "pom.xml"), "<project />");
Assert.Equal(JavaAppHostToolchain.Maven, JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot).Toolchain);
}
[Theory]
[InlineData("build.gradle")]
[InlineData("build.gradle.kts")]
public void Resolve_WithGradleBuildFile_UsesGradle(string buildFileName)
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.Path, buildFileName), "");
Assert.Equal(JavaAppHostToolchain.Gradle, JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot).Toolchain);
}
[Fact]
public void Resolve_WithBothBuildFiles_PrefersMaven()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.Path, "pom.xml"), "<project />");
File.WriteAllText(Path.Combine(workspace.Path, "build.gradle"), "");
Assert.Equal(JavaAppHostToolchain.Maven, JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot).Toolchain);
}
[Fact]
public void Resolve_IgnoresABuildFileInTheParentDirectory()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.Path, "pom.xml"), "<project />");
var appHostDirectory = workspace.CreateDirectory("apphost");
// A pom.xml above the AppHost usually belongs to an unrelated project that merely contains the
// AppHost folder, so inheriting it would build the wrong thing.
Assert.Equal(JavaAppHostToolchain.Javac, JavaAppHostToolchainResolver.Resolve(appHostDirectory).Toolchain);
}
[Fact]
public void ApplyToRuntimeSpec_ForJavac_LeavesTheSpecUntouched()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var baseSpec = CreateJavacRuntimeSpec();
// Existing single-file AppHosts must keep working byte for byte; adopting a build tool is opt-in.
Assert.Same(baseSpec, JavaAppHostToolchainResolver.ApplyToRuntimeSpec(baseSpec, JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot), workspace.WorkspaceRoot));
}
[Fact]
public void ApplyToRuntimeSpec_ForMaven_RestoresCompilesThenLaunchesAPlainJvm()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.Path, "pom.xml"), "<project />");
var wrapper = WriteWrapper(workspace.Path, OperatingSystem.IsWindows() ? "mvnw.cmd" : "mvnw");
var spec = JavaAppHostToolchainResolver.ApplyToRuntimeSpec(CreateJavacRuntimeSpec(), JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot), workspace.WorkspaceRoot);
Assert.Equal("Java (Maven)", spec.DisplayName);
AssertWrapperInvocation(
wrapper,
workspace.Path,
["-B", "-q", "dependency:copy-dependencies", $"-DoutputDirectory={Path.Combine("target", "aspire-deps")}", "-DincludeScope=runtime"],
spec.InstallDependencies!);
var compile = Assert.Single(spec.PreExecute!);
// Compilation stays with javac even under Maven: the build tool cannot be told about the
// generated SDK under .aspire/modules from the command line. The javac options and source
// arguments are inherited from the base spec so the two toolchains cannot drift.
Assert.Equal("javac", compile.Command);
Assert.Equal(
[
"--release", "25",
"-classpath", Path.Combine("target", "aspire-deps", "*"),
"-sourcepath", $".{Path.PathSeparator}{Path.Combine("src", "main", "java")}",
"-d", Path.Combine("target", "classes"),
"@.aspire/modules/sources.txt",
"{appHostFile}"
],
compile.Args);
// The AppHost is launched directly rather than through mvn exec:java so console signals reach
// it, and without a {args} placeholder so the CLI appends real argv entries.
Assert.Equal("java", spec.Execute.Command);
Assert.Equal(
["-cp", $"{Path.Combine("target", "classes")}{Path.PathSeparator}{Path.Combine("target", "aspire-deps", "*")}", "AppHost"],
spec.Execute.Args);
Assert.DoesNotContain(spec.Execute.Args, arg => arg.Contains("{args}", StringComparison.Ordinal));
}
[Theory]
[InlineData("pom.xml", "target", new[] { "pom.xml" })]
[InlineData("build.gradle", "build", new[] { "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts", "gradle/libs.versions.toml" })]
public void ApplyToRuntimeSpec_DeclaresTheBuildDescriptorsAndStagedDependenciesAsCompileInputs(
string buildFileName,
string outputDirectoryName,
string[] expectedDescriptors)
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.Path, buildFileName), "");
WriteWrapper(workspace.Path, buildFileName == "pom.xml"
? OperatingSystem.IsWindows() ? "mvnw.cmd" : "mvnw"
: OperatingSystem.IsWindows() ? "gradlew.bat" : "gradlew");
var baseSpec = CreateJavacRuntimeSpec();
var baseCompile = baseSpec.PreExecute![0];
var specWithCheck = new RuntimeSpec
{
Language = baseSpec.Language,
DisplayName = baseSpec.DisplayName,
CodeGenLanguage = baseSpec.CodeGenLanguage,
DetectionPatterns = baseSpec.DetectionPatterns,
InstallDependencies = baseSpec.InstallDependencies,
ExtensionLaunchCapability = baseSpec.ExtensionLaunchCapability,
PreExecute =
[
new CommandSpec
{
Command = baseCompile.Command,
Args = baseCompile.Args,
UpToDateCheck = new CommandUpToDateCheck
{
Inputs = ["{appHostFile}", ".", ".aspire/modules/**", "src/main/java/**"],
Outputs = [Path.Combine(".java-build", "AppHost.class")],
FileExtensions = [".java"],
StampFile = Path.Combine(".java-build", ".aspire-compile-stamp")
}
}
],
Execute = baseSpec.Execute
};
var spec = JavaAppHostToolchainResolver.ApplyToRuntimeSpec(specWithCheck, JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot), workspace.WorkspaceRoot);
// A dependency bump touches no Java source: the descriptor changes and a differently-named JAR
// is staged. Neither reaches a check whose inputs are only source roots, so the AppHost keeps
// running bytecode compiled against the version that is no longer on the classpath.
var check = Assert.Single(spec.PreExecute!).UpToDateCheck!;
var inputs = check.Inputs;
foreach (var descriptor in expectedDescriptors)
{
Assert.Contains(descriptor.Replace('/', Path.DirectorySeparatorChar), inputs);
}
Assert.Contains(Path.Combine(outputDirectoryName, "aspire-deps"), inputs);
var expectedClassesDirectory = buildFileName == "pom.xml"
? Path.Combine("target", "classes")
: Path.Combine("build", "classes", "java", "main");
Assert.Equal([Path.Combine(expectedClassesDirectory, "AppHost.class")], check.Outputs!);
}
[Fact]
public void ApplyToRuntimeSpec_ForGradle_UsesTheGradleOutputLayout()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.Path, "build.gradle"), "");
var wrapper = WriteWrapper(workspace.Path, OperatingSystem.IsWindows() ? "gradlew.bat" : "gradlew");
var spec = JavaAppHostToolchainResolver.ApplyToRuntimeSpec(CreateJavacRuntimeSpec(), JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot), workspace.WorkspaceRoot);
Assert.Equal("Java (Gradle)", spec.DisplayName);
AssertWrapperInvocation(
wrapper,
workspace.Path,
// CombineProjectPath returns the path with this platform's separators, so the expected value
// has to be converted too or the comparison fails on Windows.
["-q", "--init-script", JavaAppHostToolchainResolver.GradleInitScriptRelativePath.Replace('/', Path.DirectorySeparatorChar), "aspireCopyDependencies"],
spec.InstallDependencies!);
var compile = Assert.Single(spec.PreExecute!);
Assert.Equal("javac", compile.Command);
Assert.Equal(
[
"--release", "25",
"-classpath", Path.Combine("build", "aspire-deps", "*"),
"-sourcepath", $".{Path.PathSeparator}{Path.Combine("src", "main", "java")}",
"-d", Path.Combine("build", "classes", "java", "main"),
"@.aspire/modules/sources.txt",
"{appHostFile}"
],
compile.Args);
Assert.Equal("java", spec.Execute.Command);
Assert.Equal(
["-cp", $"{Path.Combine("build", "classes", "java", "main")}{Path.PathSeparator}{Path.Combine("build", "aspire-deps", "*")}", "AppHost"],
spec.Execute.Args);
}
[Fact]
public void ApplyToRuntimeSpec_PreservesTheExtensionLaunchCapabilitySoTheAppHostStaysDebuggable()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.Path, "pom.xml"), "<project />");
WriteWrapper(workspace.Path, OperatingSystem.IsWindows() ? "mvnw.cmd" : "mvnw");
var spec = JavaAppHostToolchainResolver.ApplyToRuntimeSpec(CreateJavacRuntimeSpec(), JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot), workspace.WorkspaceRoot);
Assert.Equal("java", spec.ExtensionLaunchCapability);
}
[Theory]
[InlineData(true, "mvnw", "mvn -N wrapper:wrapper")]
[InlineData(false, "gradlew", "gradle wrapper")]
public void GetToolInvocation_WithoutAWrapper_IsRejected(bool useMaven, string wrapperName, string generateCommand)
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var toolchain = useMaven ? JavaAppHostToolchain.Maven : JavaAppHostToolchain.Gradle;
// A globally installed tool is deliberately not used: the wrapper pins the version the repository
// builds with, and falling back silently would make the AppHost build machine-dependent.
var ex = Assert.Throws<InvalidOperationException>(
() => JavaAppHostToolchainResolver.GetToolInvocation(workspace.WorkspaceRoot, workspace.WorkspaceRoot, toolchain));
Assert.Contains(wrapperName, ex.Message);
Assert.Contains(generateCommand, ex.Message);
}
[Theory]
[InlineData(true, "mvnw", "mvnw.cmd")]
[InlineData(false, "gradlew", "gradlew.bat")]
public void GetToolInvocation_WithAWrapper_UsesIt(bool useMaven, string wrapperName, string windowsWrapperName)
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var toolchain = useMaven ? JavaAppHostToolchain.Maven : JavaAppHostToolchain.Gradle;
var expectedWrapper = OperatingSystem.IsWindows() ? windowsWrapperName : wrapperName;
var wrapperPath = Path.Combine(workspace.Path, expectedWrapper);
File.WriteAllText(wrapperPath, "");
var invocation = JavaAppHostToolchainResolver.GetToolInvocation(workspace.WorkspaceRoot, workspace.WorkspaceRoot, toolchain);
if (OperatingSystem.IsWindows())
{
// The wrappers are batch files, which produce no output when launched directly with
// redirected stdout, so the command interpreter runs them instead. "call" leads so the
// wrapper is never the first token: cmd strips the first and last quote on the line when
// the first token is quoted, which would mangle a path containing a space.
Assert.Equal(Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe", invocation.Command);
Assert.Equal(["/c", "call", expectedWrapper], invocation.PrefixArgs);
}
else
{
// Run through "sh" rather than executed directly so a wrapper checked out without its
// executable bit still works. The absolute path is kept because the process starts without
// a shell, so a bare "mvnw" would be looked up on PATH and never found.
Assert.Equal("sh", invocation.Command);
Assert.Equal([wrapperPath], invocation.PrefixArgs);
}
}
[Theory]
[InlineData(true, "mvnw", "mvnw.cmd")]
[InlineData(false, "gradlew", "gradlew.bat")]
public void GetToolInvocation_InAMultiModuleBuild_UsesTheWrapperAtTheBuildRoot(bool useMaven, string wrapperName, string windowsWrapperName)
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var toolchain = useMaven ? JavaAppHostToolchain.Maven : JavaAppHostToolchain.Gradle;
// The standard multi-module layout: one wrapper beside the aggregator POM or settings file at
// the repository root, and the AppHost in a module that carries only its own build file.
// Requiring a wrapper beside the module would reject every such repository.
WriteBuildRootMarker(workspace.Path, useMaven);
var expectedWrapper = OperatingSystem.IsWindows() ? windowsWrapperName : wrapperName;
var rootWrapperPath = Path.Combine(workspace.Path, expectedWrapper);
File.WriteAllText(rootWrapperPath, "");
var moduleDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "apphost"));
File.WriteAllText(Path.Combine(moduleDirectory.FullName, useMaven ? "pom.xml" : "build.gradle"), "");
var invocation = JavaAppHostToolchainResolver.GetToolInvocation(moduleDirectory, moduleDirectory, toolchain);
if (OperatingSystem.IsWindows())
{
Assert.Equal(["/c", "call", Path.Combine("..", expectedWrapper)], invocation.PrefixArgs);
}
else
{
Assert.Equal([rootWrapperPath], invocation.PrefixArgs);
}
}
[Fact]
[OuterloopTest("Downloads Maven plugins and dependencies into an empty local repository")]
public async Task ApplyToRuntimeSpec_ForMavenReactor_StagesAnUninstalledSiblingFromAnEmptyLocalRepository()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var appHostDirectory = CreateMavenReactor(workspace.Path);
var localRepository = Directory.CreateDirectory(Path.Combine(workspace.Path, "empty-maven-repository"));
var resolution = JavaAppHostToolchainResolver.Resolve(appHostDirectory);
JavaAppHostToolchainResolver.ApplyToRuntimeSpec(
CreateJavacRuntimeSpec(),
resolution,
appHostDirectory,
out var installDependencies);
AssertWrapperInvocation(
Path.Combine(workspace.Path, OperatingSystem.IsWindows() ? "mvnw.cmd" : "mvnw"),
appHostDirectory.FullName,
[
"-B", "-q",
"-f", Path.Combine("..", "..", "..", "..", "pom.xml"),
"-pl", "apphost,!apphost,.",
"-am",
"install",
"-Dmaven.test.skip=true"
],
installDependencies![0]);
var installResult = await RunCommandAsync(
installDependencies[0],
appHostDirectory.FullName,
new Dictionary<string, string>
{
["MAVEN_OPTS"] = $"-Dmaven.repo.local={localRepository.FullName}"
});
Assert.True(installResult.ExitCode == 0, installResult.Output);
var copyDependencies = installDependencies[1];
AssertWrapperInvocation(
Path.Combine(workspace.Path, OperatingSystem.IsWindows() ? "mvnw.cmd" : "mvnw"),
appHostDirectory.FullName,
[
"-B", "-q",
"-f", Path.Combine("..", "..", "..", "..", "pom.xml"),
"-pl", "apphost",
"dependency:copy-dependencies",
$"-DoutputDirectory={Path.Combine("target", "aspire-deps")}",
"-DincludeScope=runtime"
],
copyDependencies);
var copyResult = await RunCommandAsync(
copyDependencies,
appHostDirectory.FullName,
new Dictionary<string, string>
{
["MAVEN_OPTS"] = $"-Dmaven.repo.local={localRepository.FullName}"
});
Assert.True(copyResult.ExitCode == 0, copyResult.Output);
Assert.True(
File.Exists(Path.Combine(resolution.ProjectDirectory.FullName, "target", "aspire-deps", "library-1.0-SNAPSHOT.jar")),
copyResult.Output);
}
[Fact]
[OuterloopTest("Downloads Maven plugins and dependencies into an empty local repository")]
public async Task ApplyToRuntimeSpec_ForIndependentMavenReactorModule_AnchorsTheUpstreamInstallAtTheAggregator()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var appHostDirectory = CreateIndependentMavenReactor(workspace.Path);
var localRepository = Directory.CreateDirectory(Path.Combine(workspace.Path, "empty-maven-repository"));
var resolution = JavaAppHostToolchainResolver.Resolve(appHostDirectory);
JavaAppHostToolchainResolver.ApplyToRuntimeSpec(
CreateJavacRuntimeSpec(),
resolution,
appHostDirectory,
out var installDependencies);
AssertWrapperInvocation(
Path.Combine(workspace.Path, OperatingSystem.IsWindows() ? "mvnw.cmd" : "mvnw"),
appHostDirectory.FullName,
[
"-B", "-q",
"-f", Path.Combine("..", "..", "..", "..", "pom.xml"),
"-pl", "apphost,!apphost,.",
"-am",
"install",
"-Dmaven.test.skip=true"
],
installDependencies![0]);
var installResult = await RunCommandAsync(
installDependencies[0],
appHostDirectory.FullName,
new Dictionary<string, string>
{
["MAVEN_OPTS"] = $"-Dmaven.repo.local={localRepository.FullName}"
});
Assert.True(installResult.ExitCode == 0, installResult.Output);
}
[Fact]
public void ApplyToRuntimeSpec_WithUnrelatedAncestorMavenWrapper_TargetsStandaloneProject()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(
Path.Combine(workspace.Path, "pom.xml"),
"<project xmlns=\"http://maven.apache.org/POM/4.0.0\"><modelVersion>4.0.0</modelVersion></project>");
var wrapperPath = WriteWrapper(workspace.Path, OperatingSystem.IsWindows() ? "mvnw.cmd" : "mvnw");
var appHostDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "standalone"));
File.WriteAllText(Path.Combine(appHostDirectory.FullName, "pom.xml"), "<project />");
var resolution = JavaAppHostToolchainResolver.Resolve(appHostDirectory);
var spec = JavaAppHostToolchainResolver.ApplyToRuntimeSpec(CreateJavacRuntimeSpec(), resolution, appHostDirectory);
AssertWrapperInvocation(
wrapperPath,
appHostDirectory.FullName,
[
"-B", "-q",
"dependency:copy-dependencies",
$"-DoutputDirectory={Path.Combine("target", "aspire-deps")}",
"-DincludeScope=runtime"
],
spec.InstallDependencies!);
}
private static DirectoryInfo CreateMavenReactor(string root)
{
File.WriteAllText(Path.Combine(root, "pom.xml"), """
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>reactor</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<properties>
<maven.compiler.release>21</maven.compiler.release>
</properties>
<modules>
<module>library</module>
<module>apphost</module>
</modules>
</project>
""");
var libraryDirectory = Directory.CreateDirectory(Path.Combine(root, "library"));
File.WriteAllText(Path.Combine(libraryDirectory.FullName, "pom.xml"), """
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.example</groupId>
<artifactId>reactor</artifactId>
<version>1.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>library</artifactId>
</project>
""");
var librarySources = Directory.CreateDirectory(Path.Combine(libraryDirectory.FullName, "src", "main", "java", "com", "example"));
File.WriteAllText(
Path.Combine(librarySources.FullName, "Library.java"),
"package com.example; public final class Library { }");
var appHostProjectDirectory = Directory.CreateDirectory(Path.Combine(root, "apphost"));
File.WriteAllText(Path.Combine(appHostProjectDirectory.FullName, "pom.xml"), """
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.example</groupId>
<artifactId>reactor</artifactId>
<version>1.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>apphost</artifactId>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>library</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>
""");
var appHostDirectory = Directory.CreateDirectory(
Path.Combine(appHostProjectDirectory.FullName, "src", "main", "java"));
File.WriteAllText(
Path.Combine(appHostDirectory.FullName, "AppHost.java"),
"import aspire.*; public final class AppHost { }");
var wrapperSource = Path.Combine(
GetRepoRoot(),
"playground",
"JavaAppHost",
"forecast");
File.Copy(
Path.Combine(wrapperSource, OperatingSystem.IsWindows() ? "mvnw.cmd" : "mvnw"),
Path.Combine(root, OperatingSystem.IsWindows() ? "mvnw.cmd" : "mvnw"));
var wrapperDirectory = Directory.CreateDirectory(Path.Combine(root, ".mvn", "wrapper"));
File.Copy(
Path.Combine(wrapperSource, ".mvn", "wrapper", "maven-wrapper.properties"),
Path.Combine(wrapperDirectory.FullName, "maven-wrapper.properties"));
return appHostDirectory;
}
private static DirectoryInfo CreateIndependentMavenReactor(string root)
{
File.WriteAllText(Path.Combine(root, "pom.xml"), """
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example.aggregator</groupId>
<artifactId>reactor</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>apphost</module>
</modules>
</project>
""");
var appHostProjectDirectory = Directory.CreateDirectory(Path.Combine(root, "apphost"));
File.WriteAllText(Path.Combine(appHostProjectDirectory.FullName, "pom.xml"), """
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example.apphost</groupId>
<artifactId>apphost</artifactId>
<version>1.0-SNAPSHOT</version>
</project>
""");
var appHostDirectory = Directory.CreateDirectory(
Path.Combine(appHostProjectDirectory.FullName, "src", "main", "java"));
File.WriteAllText(
Path.Combine(appHostDirectory.FullName, "AppHost.java"),
"import aspire.*; public final class AppHost { }");
var wrapperSource = Path.Combine(
GetRepoRoot(),
"playground",
"JavaAppHost",
"forecast");
File.Copy(
Path.Combine(wrapperSource, OperatingSystem.IsWindows() ? "mvnw.cmd" : "mvnw"),
Path.Combine(root, OperatingSystem.IsWindows() ? "mvnw.cmd" : "mvnw"));
var wrapperDirectory = Directory.CreateDirectory(Path.Combine(root, ".mvn", "wrapper"));
File.Copy(
Path.Combine(wrapperSource, ".mvn", "wrapper", "maven-wrapper.properties"),
Path.Combine(wrapperDirectory.FullName, "maven-wrapper.properties"));
return appHostDirectory;
}
private static async Task<(int ExitCode, string Output)> RunCommandAsync(
CommandSpec command,
string workingDirectory,
IReadOnlyDictionary<string, string> environmentVariables)
{
var startInfo = new ProcessStartInfo(command.Command)
{
WorkingDirectory = workingDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
foreach (var argument in command.Args)
{
startInfo.ArgumentList.Add(argument);
}
foreach (var (name, value) in environmentVariables)
{
startInfo.Environment[name] = value;
}
using var process = Process.Start(startInfo) ?? throw new InvalidOperationException($"Failed to start '{command.Command}'.");
var standardOutput = process.StandardOutput.ReadToEndAsync();
var standardError = process.StandardError.ReadToEndAsync();
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
timeout.CancelAfter(TimeSpan.FromMinutes(10));
try
{
await process.WaitForExitAsync(timeout.Token);
}
catch (OperationCanceledException)
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
await process.WaitForExitAsync();
}
if (TestContext.Current.CancellationToken.IsCancellationRequested)
{
throw;
}
return (-1, $"Maven timed out after 10 minutes.{Environment.NewLine}{await standardOutput}{Environment.NewLine}{await standardError}");
}
return (process.ExitCode, $"{await standardOutput}{Environment.NewLine}{await standardError}");
}
private static string GetRepoRoot()
=> Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
[Theory]
[InlineData(true, "mvnw", "mvnw.cmd")]
[InlineData(false, "gradlew", "gradlew.bat")]
public void GetToolInvocation_DoesNotCrossACheckoutBoundaryLookingForAWrapper(bool useMaven, string wrapperName, string windowsWrapperName)
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var toolchain = useMaven ? JavaAppHostToolchain.Maven : JavaAppHostToolchain.Gradle;
// A wrapper in an outer repository must not be adopted by an inner checkout: a submodule or a
// nested clone pins its own build tool version, and the outer one is a different project.
WriteBuildRootMarker(workspace.Path, useMaven);
File.WriteAllText(Path.Combine(workspace.Path, OperatingSystem.IsWindows() ? windowsWrapperName : wrapperName), "");
var innerCheckout = Directory.CreateDirectory(Path.Combine(workspace.Path, "inner"));
Directory.CreateDirectory(Path.Combine(innerCheckout.FullName, ".git"));
File.WriteAllText(Path.Combine(innerCheckout.FullName, useMaven ? "pom.xml" : "build.gradle"), "");
var ex = Assert.Throws<InvalidOperationException>(
() => JavaAppHostToolchainResolver.GetToolInvocation(innerCheckout, innerCheckout, toolchain));
Assert.Contains(innerCheckout.FullName, ex.Message);
}
private static void WriteBuildRootMarker(string directory, bool useMaven)
{
if (useMaven)
{
File.WriteAllText(Path.Combine(directory, "pom.xml"), "");
}
else
{
File.WriteAllText(Path.Combine(directory, "settings.gradle"), "");
}
}
[Fact]
public async Task EnsureToolchainFilesExistAsync_ForGradle_WritesTheInitScriptAndOverwritesAStaleOne()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.Path, "build.gradle"), "");
WriteWrapper(workspace.Path, OperatingSystem.IsWindows() ? "gradlew.bat" : "gradlew");
var scriptPath = Path.Combine(workspace.Path, ".aspire", "aspire-gradle-init.gradle");
// The .aspire directory does not exist yet, which is why this is not a RuntimeSpec migration file.
await JavaAppHostToolchainResolver.EnsureToolchainFilesExistAsync(JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot), CancellationToken.None);
Assert.Contains("aspireCopyDependencies", await File.ReadAllTextAsync(scriptPath));
await File.WriteAllTextAsync(scriptPath, "// stale");
await JavaAppHostToolchainResolver.EnsureToolchainFilesExistAsync(JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot), CancellationToken.None);
Assert.Contains("aspireCopyDependencies", await File.ReadAllTextAsync(scriptPath));
}
[Fact]
public async Task EnsureToolchainFilesExistAsync_ForGradle_StagesWithSyncSoUpgradedDependenciesDoNotAccumulate()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.Path, "build.gradle"), "");
WriteWrapper(workspace.Path, OperatingSystem.IsWindows() ? "gradlew.bat" : "gradlew");
await JavaAppHostToolchainResolver.EnsureToolchainFilesExistAsync(JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot), CancellationToken.None);
var script = await File.ReadAllTextAsync(Path.Combine(workspace.Path, ".aspire", "aspire-gradle-init.gradle"));
// The whole directory is the classpath, so a Copy would leave the previous version of an
// upgraded dependency behind and load it alongside the new one.
Assert.Contains("tasks.register(\"aspireCopyDependencies\", Sync)", script);
}
[Fact]
public async Task ClearStagedDependencies_ForMaven_RemovesPreviouslyStagedDependencies()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.Path, "pom.xml"), "<project />");
var dependencyDirectory = Path.Combine(workspace.Path, "target", "aspire-deps");
Directory.CreateDirectory(dependencyDirectory);
await File.WriteAllTextAsync(Path.Combine(dependencyDirectory, "library-1.0.jar"), "");
JavaAppHostToolchainResolver.ClearStagedDependencies(JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot));
// dependency:copy-dependencies only ever adds, so library-1.0.jar would survive an upgrade to
// library-2.0.jar and both would be on the AppHost's dir/* classpath.
Assert.False(Directory.Exists(dependencyDirectory));
}
[Fact]
public void ClearStagedDependencies_ForMaven_WithNothingStagedYet_DoesNotThrow()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.Path, "pom.xml"), "<project />");
JavaAppHostToolchainResolver.ClearStagedDependencies(JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot));
Assert.False(Directory.Exists(Path.Combine(workspace.Path, "target")));
}
[Fact]
public async Task ClearStagedDependencies_ForGradle_LeavesTheDirectoryToTheSyncTask()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.Path, "build.gradle"), "");
WriteWrapper(workspace.Path, OperatingSystem.IsWindows() ? "gradlew.bat" : "gradlew");
var dependencyDirectory = Path.Combine(workspace.Path, "build", "aspire-deps");
Directory.CreateDirectory(dependencyDirectory);
await File.WriteAllTextAsync(Path.Combine(dependencyDirectory, "library-1.0.jar"), "");
JavaAppHostToolchainResolver.ClearStagedDependencies(JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot));
// Sync prunes the destination itself and stays incremental, so deleting it here would only
// force Gradle to recopy everything.
Assert.True(Directory.Exists(dependencyDirectory));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task EnsureToolchainFilesExistAsync_ForANonGradleToolchain_WritesNothing(bool useJavac)
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
if (!useJavac)
{
File.WriteAllText(Path.Combine(workspace.Path, "pom.xml"), "<project />");
}
await JavaAppHostToolchainResolver.EnsureToolchainFilesExistAsync(
JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot),
CancellationToken.None);
Assert.Empty(workspace.WorkspaceRoot.GetDirectories());
}
[Theory]
[InlineData("pom.xml", true)]
[InlineData("build.gradle", false)]
public void Resolve_WithTheConventionalSourceLayout_FindsTheBuildFileAtTheProjectRoot(string buildFileName, bool expectMaven)
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var expected = expectMaven ? JavaAppHostToolchain.Maven : JavaAppHostToolchain.Gradle;
File.WriteAllText(Path.Combine(workspace.Path, buildFileName), "");
var appHostDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "src", "main", "java"));
// src/main/java is a build tool's source root by convention, so the build file above it is
// this project's, not an unrelated one that happens to contain the AppHost.
var resolution = JavaAppHostToolchainResolver.Resolve(appHostDirectory);
Assert.Equal(expected, resolution.Toolchain);
Assert.Equal(workspace.Path, resolution.ProjectDirectory.FullName);
}
[Fact]
public void Resolve_WithABuildFileThreeLevelsUpThatIsNotASourceRoot_UsesJavac()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.Path, "pom.xml"), "<project />");
var appHostDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "a", "b", "c"));
Assert.Equal(JavaAppHostToolchain.Javac, JavaAppHostToolchainResolver.Resolve(appHostDirectory).Toolchain);
}
[Fact]
public void ApplyToRuntimeSpec_WithTheConventionalSourceLayout_PointsTheToolAtTheProjectRoot()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.Path, "pom.xml"), "<project />");
WriteWrapper(workspace.Path, OperatingSystem.IsWindows() ? "mvnw.cmd" : "mvnw");
var appHostDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "src", "main", "java"));
var resolution = JavaAppHostToolchainResolver.Resolve(appHostDirectory);
var spec = JavaAppHostToolchainResolver.ApplyToRuntimeSpec(CreateJavacRuntimeSpec(), resolution, appHostDirectory);
// Commands run from the AppHost directory, so Maven has to be pointed back at the project root
// and the classpath has to climb back out of src/main/java.
var toProjectRoot = Path.Combine("..", "..", "..");
Assert.Contains("-f", spec.InstallDependencies!.Args);
Assert.Contains(Path.Combine(toProjectRoot, "pom.xml"), spec.InstallDependencies.Args);
// -DoutputDirectory is deliberately *not* rewritten: Maven resolves a relative outputDirectory
// against the project's base directory, not the working directory, so climbing out of
// src/main/java here would stage the jars three levels above the project.
Assert.Contains($"-DoutputDirectory={Path.Combine("target", "aspire-deps")}", spec.InstallDependencies.Args);
// javac and java both resolve their paths against the working directory, so these do climb out.
Assert.Equal(
[
"--release", "25",
"-classpath", Path.Combine(toProjectRoot, "target", "aspire-deps", "*"),
"-sourcepath", $".{Path.PathSeparator}{Path.Combine("src", "main", "java")}",
"-d", Path.Combine(toProjectRoot, "target", "classes"),
"@.aspire/modules/sources.txt",
"{appHostFile}"
],
Assert.Single(spec.PreExecute!).Args);
Assert.Equal(
[
"-cp",
$"{Path.Combine(toProjectRoot, "target", "classes")}{Path.PathSeparator}{Path.Combine(toProjectRoot, "target", "aspire-deps", "*")}",
"AppHost"
],
spec.Execute.Args);
}
[Fact]
public async Task EnsureToolchainFilesExistAsync_WithTheConventionalSourceLayout_WritesTheScriptNextToTheBuildFile()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.Path, "build.gradle"), "");
WriteWrapper(workspace.Path, OperatingSystem.IsWindows() ? "gradlew.bat" : "gradlew");
var appHostDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "src", "main", "java"));
var resolution = JavaAppHostToolchainResolver.Resolve(appHostDirectory);
await JavaAppHostToolchainResolver.EnsureToolchainFilesExistAsync(resolution, CancellationToken.None);
// The --init-script argument is resolved relative to the AppHost directory, so the script has to
// be where that argument points: alongside the build file it augments.
var scriptPath = Path.Combine(workspace.Path, ".aspire", "aspire-gradle-init.gradle");
Assert.Contains("aspireCopyDependencies", await File.ReadAllTextAsync(scriptPath));
var spec = JavaAppHostToolchainResolver.ApplyToRuntimeSpec(CreateJavacRuntimeSpec(), resolution, appHostDirectory);
var initScriptArgument = spec.InstallDependencies!.Args[Array.IndexOf(spec.InstallDependencies.Args, "--init-script") + 1];
Assert.Equal(scriptPath, Path.GetFullPath(Path.Combine(appHostDirectory.FullName, initScriptArgument)));
}
}