File: Projects\JavaAppHostToolchainResolverTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// 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)));
    }
}