File: AddJavaAppTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Java.Tests\Aspire.Hosting.Java.Tests.csproj (Aspire.Hosting.Java.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIREEXTENSION001
 
using System.Diagnostics;
using System.IO.Compression;
using System.Text.Json;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
 
namespace Aspire.Hosting.Java.Tests;
 
public class AddJavaAppTests
{
    // An absolute path on the current platform. Run mode resolves the agent through
    // Path.GetFullPath, which rewrites a POSIX-style literal to C:\opt\... on Windows, so the
    // expected value has to be platform-specific.
    private static readonly string s_absoluteAgentPath =
        OperatingSystem.IsWindows() ? @"C:\opt\otel\agent.jar" : "/opt/otel/agent.jar";
 
    private static string AbsoluteAgentPath => s_absoluteAgentPath;
 
    // Publish mode emits the authored path verbatim, and it is read inside the Linux image the
    // application is published to, so the absolute form is POSIX on every platform. Using the
    // platform-specific literal above instead made this scenario unreachable on Windows: publishing
    // rejects a Windows-rooted agent outright, which
    // VerifyPublish_AWindowsAbsoluteOtelAgentIsRejectedOnEveryPlatform covers.
    private const string ContainerProvidedAgentPath = "/opt/otel/agent.jar";
 
    // ---- Launch mode -------------------------------------------------------
 
    [Fact]
    public async Task AddJavaApp_MavenGoal_LaunchesThroughTheWrapper()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.WriteWrapper(JavaHostingExtensions.s_defaultMavenWrapper);
 
        var app = builder.AddJavaApp("api", tempDir.Path).WithMavenGoal("spring-boot:run");
 
        // The command has to become the wrapper. Leaving it as "java" while the goal was still contributed
        // as an argument produced the uninvokable command line "java spring-boot:run".
        Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command);
        Assert.Equal(ExpectedWrapperInvocation.Args(Path.Combine(tempDir.Path, JavaHostingExtensions.s_defaultMavenWrapper), tempDir.Path, "spring-boot:run"), await ArgumentEvaluator.GetArgumentListAsync(app.Resource));
    }
 
    [Fact]
    public async Task AddJavaApp_GradleTask_LaunchesThroughTheWrapper()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.WriteWrapper(JavaHostingExtensions.s_defaultGradleWrapper);
 
        var app = builder.AddJavaApp("api", tempDir.Path).WithGradleTask("bootRun", "--no-daemon");
 
        Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command);
        Assert.Equal(ExpectedWrapperInvocation.Args(Path.Combine(tempDir.Path, JavaHostingExtensions.s_defaultGradleWrapper), tempDir.Path, "bootRun", "--no-daemon"), await ArgumentEvaluator.GetArgumentListAsync(app.Resource));
    }
 
    [Fact]
    public async Task VerifyManifest_AddJavaAppWithJar()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory, "app.jar");
 
        var manifest = await ManifestUtils.GetManifest(app.Resource);
 
        var expected = """
            {
              "type": "executable.v0",
              "workingDirectory": ".",
              "command": "java",
              "args": [
                "-jar",
                "app.jar"
              ]
            }
            """;
        Assert.Equal(expected, manifest.ToString());
    }
 
    [Fact]
    public async Task VerifyManifest_AddJavaAppWithJarAndArgs()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory, "app.jar", ["--server.port=8080"]);
 
        var manifest = await ManifestUtils.GetManifest(app.Resource);
 
        var expected = """
            {
              "type": "executable.v0",
              "workingDirectory": ".",
              "command": "java",
              "args": [
                "-jar",
                "app.jar",
                "--server.port=8080"
              ]
            }
            """;
        Assert.Equal(expected, manifest.ToString());
    }
 
    // ---- Resource properties ------------------------------------------------
 
    [Fact]
    public void AddJavaApp_SetsResourceName()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        var app = builder.AddJavaApp("myapi", AppContext.BaseDirectory);
 
        Assert.Equal("myapi", app.Resource.Name);
    }
 
    [Fact]
    public void AddJavaApp_UsesJavaAsCommand()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory);
 
        Assert.Equal("java", app.Resource.Command);
    }
 
    [Fact]
    public void AddJavaApp_ResolvesWorkingDirectoryFullPath()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
 
        var app = builder.AddJavaApp("api", tempDir.Path);
 
        var expectedPath = Path.GetFullPath(tempDir.Path, builder.AppHostDirectory);
        Assert.Equal(expectedPath, app.Resource.WorkingDirectory);
    }
 
    [Fact]
    public void AddJavaApp_ImplementsIResourceWithServiceDiscovery()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory);
 
        Assert.IsAssignableFrom<IResourceWithServiceDiscovery>(app.Resource);
    }
 
    [Fact]
    public void AddJavaApp_ImplementsIContainerFilesDestinationResource()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory);
 
        Assert.IsAssignableFrom<IContainerFilesDestinationResource>(app.Resource);
    }
 
    [Fact]
    public async Task AddJavaApp_WithoutLaunchMode_ThrowsWhenArgumentsAreGathered()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        // A bare "java" with no arguments prints the JVM usage text and exits, so the failure is raised
        // where it can name the resource and the fix.
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory);
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            async () => await ArgumentEvaluator.GetArgumentListAsync(app.Resource));
 
        Assert.Equal(
            "Java application 'api' has no launch mode configured. Call WithMavenGoal or WithGradleTask to run it through a build tool, or use the AddJavaApp overload that takes a jarPath to run a prebuilt JAR.",
            exception.Message);
    }
 
    [Fact]
    public async Task AddJavaAppWithJar_ArgsAreJarAndUserArgs()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory, "app.jar", ["--port=9090"]);
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        Assert.Equal(["-jar", "app.jar", "--port=9090"], args);
    }
 
    [Fact]
    public async Task AddJavaAppWithJar_NoUserArgs_OnlyJarArgs()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory, "app.jar");
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        Assert.Equal(["-jar", "app.jar"], args);
    }
 
    // ---- WithMavenGoal ------------------------------------------------------
 
    [Fact]
    public void WithMavenGoalShouldThrowWhenBuilderIsNull()
    {
        IResourceBuilder<JavaAppResource> builder = null!;
 
        var action = () => builder.WithMavenGoal("spring-boot:run");
 
        var exception = Assert.Throws<ArgumentNullException>(action);
        Assert.Equal(nameof(builder), exception.ParamName);
    }
 
    [Fact]
    public void WithMavenGoalShouldThrowWhenGoalIsNullOrEmpty()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
        var app = builder.AddJavaApp("api", tempDir.Path);
 
        var nullAction = () => app.WithMavenGoal(null!);
        var emptyAction = () => app.WithMavenGoal(string.Empty);
 
        var nullEx = Assert.Throws<ArgumentNullException>(nullAction);
        Assert.Equal("goal", nullEx.ParamName);
 
        var emptyEx = Assert.Throws<ArgumentException>(emptyAction);
        Assert.Equal("goal", emptyEx.ParamName);
    }
 
    [Fact]
    public async Task WithMavenGoal_PassesGoalAsArgument()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
 
        var app = builder.AddJavaApp("api", tempDir.Path)
            .WithMavenGoal("spring-boot:run");
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        Assert.Contains("spring-boot:run", args);
    }
 
    [Fact]
    public async Task WithMavenGoal_WithArgs_IncludesGoalAndArgs()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        using var tempDir = new TempJavaAppDirectory();
        var app = builder.AddJavaApp("api", tempDir.Path)
            .WithMavenGoal("spring-boot:run", "-DskipTests");
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        Assert.Contains("spring-boot:run", args);
        Assert.Contains("-DskipTests", args);
    }
 
    // ---- WithGradleTask -----------------------------------------------------
 
    [Fact]
    public void WithGradleTaskShouldThrowWhenBuilderIsNull()
    {
        IResourceBuilder<JavaAppResource> builder = null!;
 
        var action = () => builder.WithGradleTask("bootRun");
 
        var exception = Assert.Throws<ArgumentNullException>(action);
        Assert.Equal(nameof(builder), exception.ParamName);
    }
 
    [Fact]
    public void WithGradleTaskShouldThrowWhenTaskIsNullOrEmpty()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
        var app = builder.AddJavaApp("api", tempDir.Path);
 
        var nullAction = () => app.WithGradleTask(null!);
        var emptyAction = () => app.WithGradleTask(string.Empty);
 
        var nullEx = Assert.Throws<ArgumentNullException>(nullAction);
        Assert.Equal("task", nullEx.ParamName);
 
        var emptyEx = Assert.Throws<ArgumentException>(emptyAction);
        Assert.Equal("task", emptyEx.ParamName);
    }
 
    [Fact]
    public async Task WithGradleTask_PassesTaskAsArgument()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
 
        var app = builder.AddJavaApp("api", tempDir.Path)
            .WithGradleTask("bootRun");
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        Assert.Contains("bootRun", args);
    }
 
    [Fact]
    public async Task WithGradleTask_WrapperPathIsResolved()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.WriteWrapper(JavaHostingExtensions.s_defaultGradleWrapper);
 
        var app = builder.AddJavaApp("api", tempDir.Path)
            .WithGradleTask("bootRun");
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        // WithCommand sets the wrapper as the command, args contain only the task
        var expectedWrapper = Path.GetFullPath(Path.Combine(tempDir.Path, JavaHostingExtensions.s_defaultGradleWrapper));
        Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command);
        Assert.Equal(ExpectedWrapperInvocation.Args(expectedWrapper, tempDir.Path, "bootRun"), args);
    }
 
    [Fact]
    public async Task WithMavenGoal_WrapperPathIsResolved()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.WriteWrapper(JavaHostingExtensions.s_defaultMavenWrapper);
 
        var app = builder.AddJavaApp("api", tempDir.Path)
            .WithMavenGoal("spring-boot:run");
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        // WithCommand sets the wrapper as the command, args contain only the goal
        var expectedWrapper = Path.GetFullPath(Path.Combine(tempDir.Path, JavaHostingExtensions.s_defaultMavenWrapper));
        Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command);
        Assert.Equal(ExpectedWrapperInvocation.Args(expectedWrapper, tempDir.Path, "spring-boot:run"), args);
    }
 
    [Fact]
    public async Task WithGradleTask_FindsTheWrapperAtTheRootOfAMultiProjectBuild()
    {
        // A Gradle multi-project build has exactly one gradlew, at the root next to settings.gradle.
        // Subprojects never carry their own, so a resource pointed at services/api has to walk up to it.
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var repo = new TempJavaAppDirectory();
        repo.WriteWrapper(JavaHostingExtensions.s_defaultGradleWrapper);
        File.WriteAllText(Path.Combine(repo.Path, "settings.gradle"), "include 'services:api'");
        var module = Directory.CreateDirectory(Path.Combine(repo.Path, "services", "api")).FullName;
        File.WriteAllText(Path.Combine(module, "build.gradle"), "");
 
        var app = builder.AddJavaApp("api", module).WithGradleTask("bootRun");
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        var expectedWrapper = Path.GetFullPath(Path.Combine(repo.Path, JavaHostingExtensions.s_defaultGradleWrapper));
        Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command);
        Assert.Equal(ExpectedWrapperInvocation.Args(expectedWrapper, module, "bootRun"), args);
    }
 
    [Fact]
    public async Task WithMavenGoal_FindsTheWrapperAtTheRootOfAMultiModuleBuild()
    {
        // Maven multi-module repositories keep mvnw next to the aggregator pom, not in each module.
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var repo = new TempJavaAppDirectory();
        repo.WriteWrapper(JavaHostingExtensions.s_defaultMavenWrapper);
        File.WriteAllText(Path.Combine(repo.Path, "pom.xml"), "<project />");
        var module = Directory.CreateDirectory(Path.Combine(repo.Path, "services", "api")).FullName;
        File.WriteAllText(Path.Combine(module, "pom.xml"), "<project />");
 
        var app = builder.AddJavaApp("api", module).WithMavenGoal("spring-boot:run");
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        var expectedWrapper = Path.GetFullPath(Path.Combine(repo.Path, JavaHostingExtensions.s_defaultMavenWrapper));
        Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command);
        Assert.Equal(ExpectedWrapperInvocation.Args(expectedWrapper, module, "spring-boot:run"), args);
    }
 
    [Fact]
    public async Task WithGradleTask_PrefersTheWrapperInTheApplicationDirectory()
    {
        // A module that carries its own wrapper keeps using it, even when an ancestor has one too.
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var repo = new TempJavaAppDirectory();
        repo.WriteWrapper(JavaHostingExtensions.s_defaultGradleWrapper);
        var module = Directory.CreateDirectory(Path.Combine(repo.Path, "services", "api")).FullName;
        File.WriteAllText(Path.Combine(module, JavaHostingExtensions.s_defaultGradleWrapper), "#!/bin/sh\n");
 
        var app = builder.AddJavaApp("api", module).WithGradleTask("bootRun");
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        var expectedWrapper = Path.GetFullPath(Path.Combine(module, JavaHostingExtensions.s_defaultGradleWrapper));
        Assert.Equal(ExpectedWrapperInvocation.Args(expectedWrapper, module, "bootRun"), args);
    }
 
    [Theory]
    [InlineData("mvnw.cmd")]
    [InlineData("gradlew.bat")]
    public void WrapperInvocationForWindowsRunsTheWrapperThroughCall(string wrapperName)
    {
        // cmd strips the first and last quote on the line when the first token is quoted, so a wrapper
        // path containing a space would be mangled if it led. "call" keeps a quote off the front.
        var workingDirectory = Path.Combine(Path.GetTempPath(), "repo", "services", "api");
        var wrapperPath = Path.Combine(Path.GetTempPath(), "repo", "build tools", wrapperName);
 
        var (command, leadingArgs) = JavaHostingExtensions.WrapperInvocationFor(wrapperPath, workingDirectory, isWindows: true);
 
        Assert.Equal(Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe", command);
        Assert.Equal(["/c", "call", Path.Combine("..", "..", "build tools", wrapperName)], leadingArgs);
    }
 
    [Fact]
    public void WrapperInvocationForUnixRunsTheWrapperThroughShWithItsFullPath()
    {
        // A wrapper checked out on Windows arrives without its executable bit, so it is run by sh
        // rather than executed. The path stays absolute because no shell resolves it.
        var wrapperPath = Path.Combine(Path.GetTempPath(), "repo", "mvnw");
 
        var (command, leadingArgs) = JavaHostingExtensions.WrapperInvocationFor(
            wrapperPath, Path.Combine(Path.GetTempPath(), "repo", "services", "api"), isWindows: false);
 
        Assert.Equal("sh", command);
        Assert.Equal([wrapperPath], leadingArgs);
    }
 
    [Fact]
    public async Task WithGradleTask_WithArgs_IncludesTaskAndArgs()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        using var tempDir = new TempJavaAppDirectory();
        var app = builder.AddJavaApp("api", tempDir.Path)
            .WithGradleTask("bootRun", "--no-daemon");
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        Assert.Contains("bootRun", args);
        Assert.Contains("--no-daemon", args);
    }
 
    [Fact]
    public void WithGradleTask_ThrowsWhenJarPathIsSet()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        using var tempDir = new TempJavaAppDirectory();
        var app = builder.AddJavaApp("api", tempDir.Path, "app.jar");
 
        var action = () => app.WithGradleTask("bootRun");
 
        var exception = Assert.Throws<InvalidOperationException>(action);
        Assert.Equal(
            "WithGradleTask cannot be used when a JAR path has been specified. Use either the AddJavaApp overload that takes a jarPath, or WithGradleTask, not both.",
            exception.Message);
    }
 
    [Fact]
    public void WithMavenGoal_ThrowsWhenJarPathIsSet()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        using var tempDir = new TempJavaAppDirectory();
        var app = builder.AddJavaApp("api", tempDir.Path, "app.jar");
 
        var action = () => app.WithMavenGoal("spring-boot:run");
 
        var exception = Assert.Throws<InvalidOperationException>(action);
        Assert.Equal(
            "WithMavenGoal cannot be used when a JAR path has been specified. Use either the AddJavaApp overload that takes a jarPath, or WithMavenGoal, not both.",
            exception.Message);
    }
 
    [Fact]
    public void WithGradleTask_ThrowsWhenMavenGoalIsAlreadyConfigured()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        using var tempDir = new TempJavaAppDirectory();
        var app = builder.AddJavaApp("api", tempDir.Path).WithMavenGoal("spring-boot:run");
 
        var action = () => app.WithGradleTask("bootRun");
 
        var exception = Assert.Throws<InvalidOperationException>(action);
        Assert.Equal(
            "WithGradleTask cannot be used when the application is already configured to launch with Maven. A Java application is launched by a single build tool.",
            exception.Message);
    }
 
    [Fact]
    public void WithMavenGoal_ThrowsWhenGradleTaskIsAlreadyConfigured()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        using var tempDir = new TempJavaAppDirectory();
        var app = builder.AddJavaApp("api", tempDir.Path).WithGradleTask("bootRun");
 
        var action = () => app.WithMavenGoal("spring-boot:run");
 
        var exception = Assert.Throws<InvalidOperationException>(action);
        Assert.Equal(
            "WithMavenGoal cannot be used when the application is already configured to launch with Gradle. A Java application is launched by a single build tool.",
            exception.Message);
    }
 
    // ---- WithWrapperPath ----------------------------------------------------
 
    [Fact]
    public void WithWrapperPathShouldThrowWhenBuilderIsNull()
    {
        IResourceBuilder<JavaAppResource> builder = null!;
 
        var action = () => builder.WithWrapperPath("custom-mvnw");
 
        var exception = Assert.Throws<ArgumentNullException>(action);
        Assert.Equal(nameof(builder), exception.ParamName);
    }
 
    [Fact]
    public void WithWrapperPathShouldThrowWhenWrapperPathIsNullOrEmpty()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory);
 
        var nullAction = () => app.WithWrapperPath(null!);
        var emptyAction = () => app.WithWrapperPath(string.Empty);
 
        var nullEx = Assert.Throws<ArgumentNullException>(nullAction);
        Assert.Equal("wrapperPath", nullEx.ParamName);
 
        var emptyEx = Assert.Throws<ArgumentException>(emptyAction);
        Assert.Equal("wrapperPath", emptyEx.ParamName);
    }
 
    [Fact]
    public async Task WithWrapperPath_OverridesMavenDefaultWrapper()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
 
        var app = builder.AddJavaApp("api", tempDir.Path)
            .WithWrapperPath("scripts/custom-mvnw")
            .WithMavenGoal("spring-boot:run");
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        // WithCommand sets the custom wrapper as the command
        var expectedWrapper = Path.GetFullPath(Path.Combine(tempDir.Path, "scripts/custom-mvnw"));
        Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command);
        Assert.Equal(ExpectedWrapperInvocation.Args(expectedWrapper, tempDir.Path, "spring-boot:run"), args);
    }
 
    [Fact]
    public async Task WithWrapperPath_OverridesGradleDefaultWrapper()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
 
        var app = builder.AddJavaApp("api", tempDir.Path)
            .WithWrapperPath("scripts/custom-gradlew")
            .WithGradleTask("bootRun");
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        // WithCommand sets the custom wrapper as the command
        var expectedWrapper = Path.GetFullPath(Path.Combine(tempDir.Path, "scripts/custom-gradlew"));
        Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command);
        Assert.Equal(ExpectedWrapperInvocation.Args(expectedWrapper, tempDir.Path, "bootRun"), args);
    }
 
    // ---- WithJvmArgs --------------------------------------------------------
 
    [Fact]
    public void WithJvmArgsShouldThrowWhenBuilderIsNull()
    {
        IResourceBuilder<JavaAppResource> builder = null!;
 
        var action = () => builder.WithJvmArgs(["-Xmx512m"]);
 
        var exception = Assert.Throws<ArgumentNullException>(action);
        Assert.Equal(nameof(builder), exception.ParamName);
    }
 
    [Fact]
    public void WithJvmArgsShouldThrowWhenArgsIsNull()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory);
 
        var action = () => app.WithJvmArgs(null!);
 
        var exception = Assert.Throws<ArgumentNullException>(action);
        Assert.Equal("args", exception.ParamName);
    }
 
    [Fact]
    public async Task WithJvmArgs_SetsJavaToolOptions()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory)
            .WithJvmArgs(["-Xmx512m", "-Xms256m"]);
 
        var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.Equal("-Xmx512m -Xms256m", envVars["JAVA_TOOL_OPTIONS"]);
    }
 
    [Fact]
    public async Task WithJvmArgs_EmptyArgs_DoesNotSetJavaToolOptions()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory)
            .WithJvmArgs([]);
 
        var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.False(envVars.ContainsKey("JAVA_TOOL_OPTIONS"));
    }
 
    [Fact]
    public async Task WithJvmArgs_MultipleCalls_MergeValues()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory)
            .WithJvmArgs(["-Xmx512m"])
            .WithJvmArgs(["-Xms256m"]);
 
        var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.Equal("-Xmx512m -Xms256m", envVars["JAVA_TOOL_OPTIONS"]);
    }
 
    // ---- WithOtelAgent ------------------------------------------------------
 
    [Fact]
    public void WithOtelAgentShouldThrowWhenBuilderIsNull()
    {
        IResourceBuilder<JavaAppResource> builder = null!;
 
        var action = () => builder.WithOtelAgent("/opt/otel/agent.jar");
 
        var exception = Assert.Throws<ArgumentNullException>(action);
        Assert.Equal(nameof(builder), exception.ParamName);
    }
 
    [Fact]
    public void WithOtelAgentShouldThrowWhenAgentPathIsNullOrWhiteSpace()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory);
 
        Assert.Throws<ArgumentException>(() => app.WithOtelAgent("  "));
    }
 
    [Fact]
    public async Task AddJavaApp_ConfiguresOtlpExporterWithoutAnAgent()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        // The OTLP exporter is wired by AddJavaApp itself, so a Java application reports telemetry
        // through Micrometer/OTel SDK instrumentation even when the Java agent is not used.
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory);
 
        var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.False(string.IsNullOrEmpty(envVars["OTEL_EXPORTER_OTLP_ENDPOINT"]));
 
        // The other half of what this test claims: no agent was requested, so nothing should have put
        // -javaagent on the JVM. Without this the test passes just as happily when an agent is wired.
        Assert.False(envVars.ContainsKey("JAVA_TOOL_OPTIONS"));
    }
 
    [Fact]
    public async Task WithOtelAgent_WithAgentPath_SetsJavaAgentInToolOptions()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory)
            .WithOtelAgent(AbsoluteAgentPath);
 
        var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.Equal($"-javaagent:{AbsoluteAgentPath}", envVars["JAVA_TOOL_OPTIONS"]);
    }
 
    [Fact]
    public async Task WithOtelAgent_CalledTwice_UsesOnlyTheLastAgent()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        // The annotation replaces, so the second call has to win outright. Two -javaagent: entries
        // would start the JVM with both agents attached, which double-instruments the application.
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory)
            .WithOtelAgent("/opt/otel/first.jar")
            .WithOtelAgent(AbsoluteAgentPath);
 
        var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.Equal($"-javaagent:{AbsoluteAgentPath}", envVars["JAVA_TOOL_OPTIONS"]);
    }
 
    [Fact]
    public async Task WithOtelAgent_WithAgentPath_CombinedWithJvmArgs()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory)
            .WithJvmArgs(["-Xmx512m"])
            .WithOtelAgent(AbsoluteAgentPath);
 
        var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.Equal($"-Xmx512m -javaagent:{AbsoluteAgentPath}", envVars["JAVA_TOOL_OPTIONS"]);
    }
 
    [Fact]
    public async Task WithMavenGoal_WithoutAWrapperOnDisk_IsRejectedWhenTheResourceStarts()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory(withWrappers: false);
 
        // A globally installed Maven is deliberately not used as a fallback: the wrapper pins the tool
        // version in the repository, so the AppHost, CI, and the published image all build with the same
        // one. Failing here names the fix instead of silently building with whatever is on the machine.
        var app = builder.AddJavaApp("api", tempDir.Path).WithMavenGoal("spring-boot:run");
 
        using var built = builder.Build();
 
        var ex = await Assert.ThrowsAsync<DistributedApplicationException>(
            () => builder.Eventing.PublishAsync(
                new BeforeResourceStartedEvent(app.Resource, built.Services),
                CancellationToken.None));
 
        Assert.Contains("has no mvnw", ex.Message);
        Assert.Contains("mvn -N wrapper:wrapper", ex.Message);
    }
 
    [Fact]
    public async Task WithGradleTask_WithoutAWrapperOnDisk_IsRejectedWhenTheResourceStarts()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory(withWrappers: false);
 
        var app = builder.AddJavaApp("api", tempDir.Path).WithGradleTask("bootRun");
 
        using var built = builder.Build();
 
        var ex = await Assert.ThrowsAsync<DistributedApplicationException>(
            () => builder.Eventing.PublishAsync(
                new BeforeResourceStartedEvent(app.Resource, built.Services),
                CancellationToken.None));
 
        Assert.Contains("has no gradlew", ex.Message);
        Assert.Contains("gradle wrapper", ex.Message);
    }
 
    [Fact]
    public async Task WithWrapperPath_AfterTheBuildTool_WorksWhenTheProjectHasNoDefaultWrapper()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory(withWrappers: false);
 
        var customWrapper = Path.Combine(tempDir.Path, "tools", "mvnw");
        Directory.CreateDirectory(Path.GetDirectoryName(customWrapper)!);
        File.WriteAllText(customWrapper, "#!/bin/sh\n");
 
        // WithWrapperPath is documented as order-independent. Resolving the wrapper eagerly inside
        // WithMavenGoal made that untrue for the one project shape where the override actually matters:
        // a project whose only wrapper is the custom one threw before the override could be applied.
        var app = builder.AddJavaApp("api", tempDir.Path)
            .WithMavenGoal("spring-boot:run")
            .WithWrapperPath(Path.Combine("tools", "mvnw"));
 
        using var built = builder.Build();
 
        await builder.Eventing.PublishAsync(
            new BeforeResourceStartedEvent(app.Resource, built.Services),
            CancellationToken.None);
 
        // Path.GetFullPath matches the normalization the resource applies, and resolves the symlinked
        // temp directory the same way on both sides so the comparison is about the wrapper, not the path.
        // On Unix the wrapper moves into the first argument, so asserting only the command would stop
        // checking that the override was applied at all.
        Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command);
        Assert.Equal(
            ExpectedWrapperInvocation.Args(Path.GetFullPath(customWrapper), Path.GetFullPath(tempDir.Path), "spring-boot:run"),
            await ArgumentEvaluator.GetArgumentListAsync(app.Resource));
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Windows has no executable bit, and its wrappers are batch files sh cannot run")]
    public async Task WithMavenGoal_WrapperWithoutTheExecutableBit_StillProducesALaunchableCommandLine()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory(withWrappers: false);
 
        // Git records no executable bit on Windows, so a wrapper committed there checks out like this.
        // Executing it directly fails with "permission denied"; the launch has to survive that.
        var wrapper = Path.Combine(tempDir.Path, JavaHostingExtensions.s_defaultMavenWrapper);
        await File.WriteAllTextAsync(wrapper, "#!/bin/sh\nexit 0\n");
        // CA1416 does not understand SkipOnPlatform, which already keeps this off Windows.
#pragma warning disable CA1416
        File.SetUnixFileMode(wrapper, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead);
#pragma warning restore CA1416
 
        var app = builder.AddJavaApp("api", tempDir.Path).WithMavenGoal("spring-boot:run");
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        using var process = Process.Start(new ProcessStartInfo(app.Resource.Command)
        {
            // The goal is dropped: the point is that the wrapper runs at all, not what it does.
            ArgumentList = { args[0] },
            WorkingDirectory = tempDir.Path,
            RedirectStandardError = true
        })!;
 
        await process.WaitForExitAsync(CancellationToken.None);
 
        Assert.Equal(string.Empty, await process.StandardError.ReadToEndAsync());
        Assert.Equal(0, process.ExitCode);
    }
 
    [Fact]
    public async Task WithWrapperPath_PointingAtAMissingFile_IsRejectedWhenTheResourceStarts()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory(withWrappers: false);
 
        var app = builder.AddJavaApp("api", tempDir.Path)
            .WithMavenGoal("spring-boot:run")
            .WithWrapperPath("tools/mvnw");
 
        using var built = builder.Build();
 
        var ex = await Assert.ThrowsAsync<DistributedApplicationException>(
            () => builder.Eventing.PublishAsync(
                new BeforeResourceStartedEvent(app.Resource, built.Services),
                CancellationToken.None));
 
        // The override is what is wrong here, so the message points at WithWrapperPath rather than
        // telling the user to generate a wrapper they did not ask for.
        Assert.Contains(nameof(JavaHostingExtensions.WithWrapperPath), ex.Message);
        Assert.DoesNotContain("mvn -N wrapper:wrapper", ex.Message);
    }
 
    [Fact]
    public async Task WithWrapperPath_IsHonouredEvenWhenNoWrapperExistsOnDisk()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory(withWrappers: false);
 
        var app = builder.AddJavaApp("api", tempDir.Path)
            .WithWrapperPath("/opt/maven/bin/mvn")
            .WithMavenGoal("spring-boot:run");
 
        // An explicit override is a deliberate choice and must win over the default wrapper probe, which
        // would otherwise reject this project for shipping no mvnw.
        Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command);
        Assert.Equal(
            ExpectedWrapperInvocation.Args("/opt/maven/bin/mvn", tempDir.Path, "spring-boot:run"),
            await ArgumentEvaluator.GetArgumentListAsync(app.Resource));
    }
 
    [Fact]
    public void AddJavaApp_RequestsSystemCertificateTrustScope()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory);
 
        // -Djavax.net.ssl.trustStore replaces the JVM's trust anchors rather than adding to them, so the
        // generated bundle has to contain the system roots as well. Under the default Append scope the
        // bundle would hold only Aspire's own certificates and the JVM would stop trusting every public
        // CA -- which also breaks Maven Central and Gradle distribution downloads, because
        // JAVA_TOOL_OPTIONS is inherited by the build tool's JVM.
        Assert.True(app.Resource.TryGetLastAnnotation<CertificateAuthorityCollectionAnnotation>(out var certAnnotation));
        Assert.Equal(CertificateTrustScope.System, certAnnotation.Scope);
    }
 
    [Fact]
    public async Task AddJavaApp_WithAppendCertificateTrustScope_DoesNotOverrideTheJvmTrustStore()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        var javaApp = builder.AddJavaApp("api", AppContext.BaseDirectory);
 
        using var app = builder.Build();
 
        Assert.True(javaApp.Resource.TryGetLastAnnotation<CertificateTrustConfigurationCallbackAnnotation>(out var annotation));
 
        var envVars = new Dictionary<string, object>();
        await annotation.Callback(new CertificateTrustConfigurationCallbackAnnotationContext
        {
            ExecutionContext = new DistributedApplicationExecutionContext(
                new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run)
                {
                    Services = app.Services
                }),
            Resource = javaApp.Resource,
            Arguments = [],
            EnvironmentVariables = envVars,
            CertificateBundlePath = ReferenceExpression.Create($"/etc/ssl/aspire/bundle.p12"),
            CertificateDirectoriesPath = ReferenceExpression.Create($"/etc/ssl/aspire/certs"),
            Scope = CertificateTrustScope.Append,
            CancellationToken = default
        });
 
        // Under Append the bundle holds only Aspire's own certificates. Pointing the JVM at it would
        // drop every public certificate authority, so the override is skipped entirely rather than
        // applied against an incomplete bundle.
        Assert.Empty(envVars);
    }
 
    [Fact]
    public async Task WithOtelAgent_AgentPathContainingSpaces_IsQuotedForTheJvm()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        var agentPath = OperatingSystem.IsWindows()
            ? @"C:\opt\java agents\opentelemetry-javaagent.jar"
            : "/opt/java agents/opentelemetry-javaagent.jar";
 
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory)
            .WithOtelAgent(agentPath);
 
        var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        // The JVM splits JAVA_TOOL_OPTIONS on whitespace but honours double quotes, so an unquoted path
        // containing a space aborts startup with "Unrecognized option: agents/opentelemetry-javaagent.jar"
        // before any application code runs. -javaagent: has no '=' so the whole option is quoted.
        Assert.Equal($"\"-javaagent:{agentPath}\"", envVars["JAVA_TOOL_OPTIONS"]);
    }
 
    [Fact]
    public async Task WithJvmArgs_ValueContainingSpaces_IsQuotedAfterTheAssignment()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory)
            .WithJvmArgs(["-Dapp.data.dir=/var/lib/my app"]);
 
        var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        // For an option with an '=' only the value is quoted. Quoting the whole option would make the JVM
        // treat "-Dapp.data.dir" as part of the property name and the property would silently not be set.
        Assert.Equal("-Dapp.data.dir=\"/var/lib/my app\"", envVars["JAVA_TOOL_OPTIONS"]);
    }
 
    [Fact]
    public async Task WithOtelAgent_RelativeAgentPath_IsMadeAbsoluteInRunMode()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory)
            .WithOtelAgent(Path.Combine("target", "agent", "opentelemetry-javaagent.jar"));
 
        var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        // JAVA_TOOL_OPTIONS is inherited by every JVM started beneath the resource, and build tools start
        // JVMs from directories other than the application directory. The Gradle daemon in particular
        // starts from its own distribution directory, so a relative -javaagent: path fails to resolve and
        // the daemon dies during VM initialization rather than reporting a normal build failure.
        var expected = Path.GetFullPath(
            Path.Combine(app.Resource.WorkingDirectory, "target", "agent", "opentelemetry-javaagent.jar"));
 
        Assert.Equal($"-javaagent:{expected}", envVars["JAVA_TOOL_OPTIONS"]);
        Assert.True(Path.IsPathFullyQualified(expected));
    }
 
    [Fact]
    public async Task WithOtelAgent_RelativeAgentPath_PointsAtContainerPathInPublishMode()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory)
            .WithOtelAgent("target/agent/opentelemetry-javaagent.jar");
 
        var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            app.Resource, DistributedApplicationOperation.Publish, TestServiceProvider.Instance);
 
        // The path has to be interpreted inside the container, so a build-machine path would be wrong.
        // The generated Dockerfile copies the build-produced agent to a fixed location, and this has to
        // agree with it or the container starts a JVM pointing at a JAR that is not in the image.
        Assert.Equal("-javaagent:/app/agent.jar", envVars["JAVA_TOOL_OPTIONS"]);
    }
 
    [Fact]
    public async Task WithOtelAgent_AbsoluteAgentPath_IsLeftUnchangedInPublishMode()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory)
            .WithOtelAgent(ContainerProvidedAgentPath);
 
        var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            app.Resource, DistributedApplicationOperation.Publish, TestServiceProvider.Instance);
 
        // An absolute path cannot have come out of the build context, so it is the base image's or a
        // mount's responsibility and rewriting it would break that arrangement.
        Assert.Equal($"-javaagent:{ContainerProvidedAgentPath}", envVars["JAVA_TOOL_OPTIONS"]);
    }
 
    // ---- WithMavenBuild / WithGradleBuild -----------------------------------
 
    [Fact]
    public void WithMavenBuild_CreatesMavenBuildResourceInRunMode()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
 
        builder.AddJavaApp("api", tempDir.Path)
            .WithMavenBuild();
 
        Assert.Contains(builder.Resources, r => r.Name == "api-maven-build");
        Assert.Equal(JavaBuildTool.Maven, Assert.IsType<JavaBuildResource>(builder.Resources.First(r => r.Name == "api-maven-build")).Tool);
    }
 
    [Fact]
    public void WithMavenBuild_CustomArgs_CreatesBuildResource()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
 
        builder.AddJavaApp("api", tempDir.Path)
            .WithMavenBuild("clean", "install", "-DskipTests");
 
        var buildResource = builder.Resources.First(r => r.Name == "api-maven-build");
        Assert.Equal(JavaBuildTool.Maven, Assert.IsType<JavaBuildResource>(buildResource).Tool);
    }
 
    [Fact]
    public void WithGradleBuild_CreatesGradleBuildResourceInRunMode()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
 
        builder.AddJavaApp("api", tempDir.Path)
            .WithGradleBuild();
 
        Assert.Contains(builder.Resources, r => r.Name == "api-gradle-build");
        Assert.Equal(JavaBuildTool.Gradle, Assert.IsType<JavaBuildResource>(builder.Resources.First(r => r.Name == "api-gradle-build")).Tool);
    }
 
    [Fact]
    public void WithGradleBuild_CustomArgs_CreatesBuildResource()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
 
        builder.AddJavaApp("api", tempDir.Path)
            .WithGradleBuild("clean", "assemble", "--info");
 
        var buildResource = builder.Resources.First(r => r.Name == "api-gradle-build");
        Assert.Equal(JavaBuildTool.Gradle, Assert.IsType<JavaBuildResource>(buildResource).Tool);
    }
 
    [Fact]
    public void WithMavenBuild_DoesNotCreateBuildResourceInPublishMode()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish).WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
 
        builder.AddJavaApp("api", tempDir.Path)
            .WithMavenBuild();
 
        Assert.DoesNotContain(builder.Resources, r => r.Name == "api-maven-build");
    }
 
    [Fact]
    public void WithGradleBuild_DoesNotCreateBuildResourceInPublishMode()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish).WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
 
        builder.AddJavaApp("api", tempDir.Path)
            .WithGradleBuild();
 
        Assert.DoesNotContain(builder.Resources, r => r.Name == "api-gradle-build");
    }
 
    [Theory]
    [InlineData(nameof(JavaBuildTool.Maven), true)]
    [InlineData(nameof(JavaBuildTool.Maven), false)]
    [InlineData(nameof(JavaBuildTool.Gradle), true)]
    [InlineData(nameof(JavaBuildTool.Gradle), false)]
    public void WithBuildAndLaunch_DoesNotCreateASeparateBuildResource(string toolName, bool buildFirst)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
        var app = builder.AddJavaApp("api", tempDir.Path);
 
        if (toolName is nameof(JavaBuildTool.Maven))
        {
            if (buildFirst)
            {
                app.WithMavenBuild();
                app.WithMavenGoal("spring-boot:run");
            }
            else
            {
                app.WithMavenGoal("spring-boot:run");
                app.WithMavenBuild();
            }
        }
        else
        {
            if (buildFirst)
            {
                app.WithGradleBuild();
                app.WithGradleTask("bootRun");
            }
            else
            {
                app.WithGradleTask("bootRun");
                app.WithGradleBuild();
            }
        }
 
        Assert.Same(app.Resource, Assert.Single(builder.Resources));
        Assert.Null(Assert.Single(app.Resource.Annotations.OfType<JavaBuildStepAnnotation>()).ResourceName);
    }
 
    [Fact]
    public void WithMavenBuild_BuildResourceHasParentRelationship()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
 
        var app = builder.AddJavaApp("api", tempDir.Path)
            .WithMavenBuild();
 
        var buildResource = builder.Resources.First(r => r.Name == "api-maven-build");
        Assert.True(buildResource.TryGetAnnotationsOfType<ResourceRelationshipAnnotation>(out var relationships));
        Assert.Contains(relationships, r => r.Type == "Parent" && r.Resource == app.Resource);
    }
 
    [Fact]
    public void WithGradleBuild_BuildResourceHasParentRelationship()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
 
        var app = builder.AddJavaApp("api", tempDir.Path)
            .WithGradleBuild();
 
        var buildResource = builder.Resources.First(r => r.Name == "api-gradle-build");
        Assert.True(buildResource.TryGetAnnotationsOfType<ResourceRelationshipAnnotation>(out var relationships));
        Assert.Contains(relationships, r => r.Type == "Parent" && r.Resource == app.Resource);
    }
 
    // ---- JAR path -----------------------------------------------------------
 
    [Theory]
    [InlineData("target/app.jar")]
    [InlineData(@"target\app.jar")]
    public async Task AddJavaApp_WithJarPath_LaunchesTheJar(string jarPath)
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory, jarPath);
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        Assert.Equal("java", app.Resource.Command);
        Assert.Equal(["-jar", "target/app.jar"], args);
    }
 
    // ---- VS Code debugging --------------------------------------------------
 
    [Fact]
    public void AddJavaApp_InRunMode_SupportsDebugging()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true);
 
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory);
 
        var annotation = app.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().SingleOrDefault();
        Assert.NotNull(annotation);
        Assert.Equal("java", annotation!.LaunchConfigurationType);
    }
 
    [Fact]
    public void AddJavaApp_InPublishMode_DoesNotAddDebuggingAnnotation()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish).WithResourceCleanUp(true);
 
        var app = builder.AddJavaApp("api", AppContext.BaseDirectory);
 
        var annotation = app.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().SingleOrDefault();
        Assert.Null(annotation);
    }
 
    [Fact]
    public async Task WithJvmArgs_IdeDebugLaunchUsesJavaToolOptionsOnly()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.Write("pom.xml", "<project><artifactId>api</artifactId></project>");
 
        var app = builder.AddJavaApp("api", tempDir.Path)
            .WithMavenGoal("spring-boot:run")
            .WithJvmArgs("-javaagent:/opt/coverage-agent.jar");
 
        var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
        var launchConfiguration = await GetLaunchConfigurationAsync(app);
        var serializedLaunchConfiguration = JsonSerializer.SerializeToElement(launchConfiguration);
 
        // The IDE-launched JVM inherits the resource environment. Repeating this value as vm_args would
        // attach single-instance options such as -javaagent twice and either fail or double-instrument.
        Assert.Equal("-javaagent:/opt/coverage-agent.jar", envVars["JAVA_TOOL_OPTIONS"]);
        Assert.False(serializedLaunchConfiguration.TryGetProperty("vm_args", out _));
    }
 
    // ---- Chaining multiple methods ------------------------------------------
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public void WithMavenBuild_AndWithMavenGoal_ProduceTheSameGraphInEitherOrder(bool goalFirst)
    {
        // The launch goal compiles the application, so the build resource is redundant either way.
        // One order never creates it; the other creates it and then unwinds it by hand. Those two
        // paths have to converge, or which methods a user happens to chain first decides whether an
        // extra Maven build runs before every launch.
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.Write("pom.xml", "");
 
        var app = builder.AddJavaApp("api", tempDir.Path);
        if (goalFirst)
        {
            app.WithMavenGoal("spring-boot:run").WithMavenBuild("clean", "package");
        }
        else
        {
            app.WithMavenBuild("clean", "package").WithMavenGoal("spring-boot:run");
        }
 
        Assert.Equal(["api"], builder.Resources.Select(resource => resource.Name).Order());
        Assert.Empty(app.Resource.Annotations.OfType<WaitAnnotation>());
        Assert.Null(Assert.Single(app.Resource.Annotations.OfType<JavaBuildStepAnnotation>()).ResourceName);
    }
 
    [Fact]
    public async Task WithMavenGoal_ThenWithJvmArgs_SetsBothConfigurations()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        using var tempDir = new TempJavaAppDirectory();
        var app = builder.AddJavaApp("api", tempDir.Path)
            .WithMavenGoal("spring-boot:run")
            .WithJvmArgs(["-Xmx1g"]);
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
        Assert.Contains("spring-boot:run", args);
 
        var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
        Assert.Equal("-Xmx1g", envVars["JAVA_TOOL_OPTIONS"]);
    }
 
    [Fact]
    public async Task WithGradleTask_ThenWithOtelAgent_SetsBothConfigurations()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        using var tempDir = new TempJavaAppDirectory();
        var app = builder.AddJavaApp("api", tempDir.Path)
            .WithGradleTask("bootRun")
            .WithOtelAgent(AbsoluteAgentPath);
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
        Assert.Contains("bootRun", args);
 
        var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
        Assert.Equal($"-javaagent:{AbsoluteAgentPath}", envVars["JAVA_TOOL_OPTIONS"]);
    }
 
    [Fact]
    public async Task WithWrapperPath_ThenWithMavenGoal_UsesCustomWrapper()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
 
        var app = builder.AddJavaApp("api", tempDir.Path)
            .WithWrapperPath("tools/mvn")
            .WithMavenGoal("spring-boot:run");
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        // WithCommand sets the custom wrapper as the command
        var expectedWrapper = Path.GetFullPath(Path.Combine(tempDir.Path, "tools/mvn"));
        Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command);
        Assert.Equal(ExpectedWrapperInvocation.Args(expectedWrapper, tempDir.Path, "spring-boot:run"), args);
    }
 
    // ---- Manifest with Maven/Gradle goals -----------------------------------
 
    [Fact]
    public async Task VerifyManifest_WithMavenGoal()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
 
        var app = builder.AddJavaApp("api", tempDir.Path)
            .WithMavenGoal("spring-boot:run");
 
        var manifest = await ManifestUtils.GetManifest(app.Resource);
 
        // The manifest should show the maven wrapper as the command with the goal as args.
        var args = manifest?["args"]?.AsArray();
        Assert.NotNull(args);
        Assert.Contains("spring-boot:run", args!.Select(a => a?.ToString()));
    }
 
    [Fact]
    public async Task VerifyManifest_WithGradleTask()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
 
        var app = builder.AddJavaApp("api", tempDir.Path)
            .WithGradleTask("bootRun");
 
        var manifest = await ManifestUtils.GetManifest(app.Resource);
 
        var args = manifest?["args"]?.AsArray();
        Assert.NotNull(args);
        Assert.Contains("bootRun", args!.Select(a => a?.ToString()));
    }
 
    private static async Task<JavaLaunchConfiguration> GetLaunchConfigurationAsync(IResourceBuilder<JavaAppResource> app)
    {
        var annotation = Assert.Single(app.Resource.Annotations.OfType<SupportsDebuggingAnnotation>());
        Assert.Equal("java", annotation.LaunchConfigurationType);
 
        var context = new LaunchConfigurationCallbackContext(
            ExecutableLaunchMode.Debug,
            app.Resource,
            new Dictionary<string, string>(),
            CancellationToken.None);
 
        return Assert.IsType<JavaLaunchConfiguration>(await annotation.LaunchConfigurationProducer(context));
    }
 
    [Theory]
    [InlineData("pom.xml", "maven")]
    [InlineData("settings.gradle", "gradle")]
    public async Task AddSpringBootApp_DebugConfigurationUsesTheDetectedBuildTool(string marker, string expectedBuildTool)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.Write(marker, "");
 
        var app = builder.AddSpringBootApp("catalog", tempDir.Path);
 
        var launchConfiguration = await GetLaunchConfigurationAsync(app);
 
        Assert.Equal(expectedBuildTool, launchConfiguration.BuildTool);
    }
 
    [Theory]
    [InlineData("pom.xml", "target")]
    [InlineData("build.gradle", "build")]
    public async Task AddQuarkusApp_DebugConfigurationStartsTheFastJarTheBuildProduced(string buildFile, string outputDirectory)
    {
        // Quarkus' entry point lives in the fast JAR's boot classpath rather than in the project, so the
        // language server's classpath cannot start it. The archive goes on the classpath and its manifest
        // names io.quarkus.bootstrap.runner.QuarkusEntryPoint, exactly as `java -jar` would resolve it.
        // Breakpoints still bind, because the debugger maps loaded classes back to sources by name.
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.Write(buildFile, "");
        var runJar = WriteJarWithManifest(
            tempDir.Path,
            Path.Combine(outputDirectory, "quarkus-app", "quarkus-run.jar"),
            "io.quarkus.bootstrap.runner.QuarkusEntryPoint");
 
        var app = builder.AddQuarkusApp("inventory", tempDir.Path);
 
        var launchConfiguration = await GetLaunchConfigurationAsync(app);
 
        Assert.Equal("io.quarkus.bootstrap.runner.QuarkusEntryPoint", launchConfiguration.MainClass);
        Assert.Equal([runJar], launchConfiguration.ClassPaths!);
    }
 
    /// <summary>
    /// Writes a JAR whose manifest declares <paramref name="mainClass"/>. A JAR is a ZIP archive, so
    /// the entry only has to exist at META-INF/MANIFEST.MF with the documented Name: value shape.
    /// </summary>
    private static string WriteJarWithManifest(string directory, string fileName, string? mainClass, bool wrapLongValue = false, string? startClass = null)
    {
        var jarPath = Path.Combine(directory, fileName);
        Directory.CreateDirectory(Path.GetDirectoryName(jarPath)!);
        using var archive = ZipFile.Open(jarPath, ZipArchiveMode.Create);
        using var writer = new StreamWriter(archive.CreateEntry("META-INF/MANIFEST.MF").Open());
 
        writer.Write("Manifest-Version: 1.0\r\n");
        if (mainClass is not null)
        {
            if (wrapLongValue)
            {
                // The manifest format limits a line to 72 bytes and continues longer values on the
                // next line with a single leading space, which is not part of the value.
                var split = mainClass.Length / 2;
                writer.Write($"Main-Class: {mainClass[..split]}\r\n {mainClass[split..]}\r\n");
            }
            else
            {
                writer.Write($"Main-Class: {mainClass}\r\n");
            }
        }
 
        if (startClass is not null)
        {
            writer.Write($"Start-Class: {startClass}\r\n");
        }
 
        writer.Write("\r\n");
 
        // Returned fully resolved because that is the shape the resource reports: the launch
        // configuration runs the authored path through Path.GetFullPath. Path.Combine does not
        // normalize separators, so a fileName such as "target/api.jar" would otherwise produce
        // "C:\...\target/api.jar" on Windows and never compare equal to it.
        return Path.GetFullPath(jarPath);
    }
 
    [Fact]
    public async Task AddJavaApp_WithMavenBuild_SendsNoEntryPointWhenNothingOnDiskNamesOne()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.WriteWrapper(JavaHostingExtensions.s_defaultMavenWrapper);
 
        var app = builder.AddJavaApp("api", tempDir.Path).WithMavenGoal("spring-boot:run");
 
        var launchConfiguration = await GetLaunchConfigurationAsync(app);
 
        Assert.Null(launchConfiguration.MainClass);
        Assert.Null(launchConfiguration.ClassPaths);
        Assert.Equal("maven", launchConfiguration.BuildTool);
        Assert.Equal(tempDir.Path, launchConfiguration.WorkingDirectory);
    }
 
    [Fact]
    public async Task AddJavaApp_WithJar_PutsTheArchiveOnTheClasspathAndLaunchesItsManifestMainClass()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var tempDir = new TempJavaAppDirectory();
        var jarPath = WriteJarWithManifest(tempDir.Path, "api.jar", "com.example.catalog.CatalogApplication");
 
        var app = builder.AddJavaApp("api", tempDir.Path, "api.jar");
 
        var launchConfiguration = await GetLaunchConfigurationAsync(app);
 
        // The archive itself is never the main class: the debug adapter documents that attribute as a
        // fully qualified class name or a .java path, so a JAR path leaves it unable to resolve an
        // entry point. The archive belongs on the classpath instead.
        Assert.Equal("com.example.catalog.CatalogApplication", launchConfiguration.MainClass);
        Assert.Equal(jarPath, Assert.Single(launchConfiguration.ClassPaths!));
        Assert.Null(launchConfiguration.BuildTool);
    }
 
    [Fact]
    public async Task AddJavaApp_WithWindowsStyleJarPath_DebugsTheArchiveOnEveryPlatform()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var tempDir = new TempJavaAppDirectory();
        var jarPath = WriteJarWithManifest(tempDir.Path, "target/api.jar", "com.example.catalog.CatalogApplication");
 
        var app = builder.AddJavaApp("api", tempDir.Path, @"target\api.jar");
 
        var launchConfiguration = await GetLaunchConfigurationAsync(app);
 
        Assert.Equal("com.example.catalog.CatalogApplication", launchConfiguration.MainClass);
        Assert.Equal(jarPath, Assert.Single(launchConfiguration.ClassPaths!));
    }
 
    [Fact]
    public async Task AddJavaApp_WithJar_ReadsAMainClassThatTheManifestWrappedAcrossLines()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var tempDir = new TempJavaAppDirectory();
        const string MainClass = "com.example.catalog.averylongpackagename.that.forces.wrapping.CatalogApplication";
        WriteJarWithManifest(tempDir.Path, "api.jar", MainClass, wrapLongValue: true);
 
        var app = builder.AddJavaApp("api", tempDir.Path, "api.jar");
 
        var launchConfiguration = await GetLaunchConfigurationAsync(app);
 
        Assert.Equal(MainClass, launchConfiguration.MainClass);
    }
 
    [Fact]
    public async Task AddJavaApp_WithJar_WithMainClass_PrefersTheExplicitMainClassOverTheManifest()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var tempDir = new TempJavaAppDirectory();
        var jarPath = WriteJarWithManifest(tempDir.Path, "api.jar", "org.springframework.boot.loader.JarLauncher");
 
        var app = builder.AddJavaApp("api", tempDir.Path, "api.jar")
            .WithMainClass("com.example.catalog.CatalogApplication");
 
        var launchConfiguration = await GetLaunchConfigurationAsync(app);
 
        Assert.Equal("com.example.catalog.CatalogApplication", launchConfiguration.MainClass);
        Assert.Equal(jarPath, Assert.Single(launchConfiguration.ClassPaths!));
    }
 
    [Fact]
    public async Task AddJavaApp_WithJar_ThatIsMissingOrHasNoMainClass_StillSendsTheClasspath()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var tempDir = new TempJavaAppDirectory();
        WriteJarWithManifest(tempDir.Path, "no-main.jar", mainClass: null);
 
        var noMainClass = builder.AddJavaApp("no-main", tempDir.Path, "no-main.jar");
        var missing = builder.AddJavaApp("missing", tempDir.Path, "does-not-exist.jar");
 
        // Neither case is fatal: the IDE can still resolve an entry point from the project, and
        // failing the launch over an unreadable archive would be worse than letting it try.
        var noMainClassConfiguration = await GetLaunchConfigurationAsync(noMainClass);
        Assert.Null(noMainClassConfiguration.MainClass);
        Assert.Equal(Path.Combine(tempDir.Path, "no-main.jar"), Assert.Single(noMainClassConfiguration.ClassPaths!));
 
        var missingConfiguration = await GetLaunchConfigurationAsync(missing);
        Assert.Null(missingConfiguration.MainClass);
        Assert.Equal(Path.Combine(tempDir.Path, "does-not-exist.jar"), Assert.Single(missingConfiguration.ClassPaths!));
    }
 
    [Theory]
    [InlineData("maven", "target")]
    [InlineData("gradle", "build/libs")]
    public async Task AddJavaApp_WithoutAJar_LaunchesTheStartClassOfTheRepackagedSpringBootArchive(string tool, string outputDirectory)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var tempDir = new TempJavaAppDirectory(directoryName: "catalog");
        tempDir.WriteWrapper(tool == "maven" ? JavaHostingExtensions.s_defaultMavenWrapper : JavaHostingExtensions.s_defaultGradleWrapper);
 
        // Repackaging points Main-Class at the launcher and records the application's own entry point
        // in Start-Class, so a debugger that started Main-Class would step through Spring's loader.
        WriteJarWithManifest(
            tempDir.Path,
            Path.Combine(outputDirectory, "catalog-0.0.1-SNAPSHOT.jar"),
            mainClass: "org.springframework.boot.loader.launch.JarLauncher",
            startClass: "com.example.catalog.CatalogApplication");
 
        var app = builder.AddJavaApp("catalog", tempDir.Path);
        _ = tool == "maven" ? app.WithMavenGoal("spring-boot:run") : app.WithGradleTask("bootRun");
 
        // The build file is what the project name is read from, and the directory is named to match it,
        // which is the condition under which the IDE imports the project under that same name.
        File.WriteAllText(
            Path.Combine(tempDir.Path, tool == "maven" ? "pom.xml" : "settings.gradle"),
            tool == "maven"
                ? """
                    <?xml version="1.0" encoding="UTF-8"?>
                    <project xmlns="http://maven.apache.org/POM/4.0.0">
                        <modelVersion>4.0.0</modelVersion>
                        <groupId>com.example</groupId>
                        <artifactId>catalog</artifactId>
                        <version>0.0.1-SNAPSHOT</version>
                    </project>
                    """
                : "rootProject.name = 'catalog'\n");
 
        var launchConfiguration = await GetLaunchConfigurationAsync(app);
 
        Assert.Equal("com.example.catalog.CatalogApplication", launchConfiguration.MainClass);
        // The classpath stays with the language server so breakpoints bind to the source being edited
        // rather than to the classes inside the archive.
        Assert.Null(launchConfiguration.ClassPaths);
        // Sent alongside the main class, not instead of it. Given a class and no project the adapter
        // searches every project in the workspace and refuses to launch with "Main class ... isn't
        // unique in the workspace" whenever the class is visible through more than one of them.
        Assert.Equal("catalog", launchConfiguration.ProjectName);
    }
 
    [Fact]
    public async Task AddJavaApp_WithoutAJar_IgnoresTheArchivesABuildLeavesAlongsideTheApplication()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.WriteWrapper(JavaHostingExtensions.s_defaultGradleWrapper);
 
        WriteJarWithManifest(tempDir.Path, "build/libs/orders.jar", mainClass: null, startClass: "com.example.orders.OrdersApplication");
        // The Gradle Spring Boot plugin writes the unrepackaged classes next to the real artifact, and
        // Maven publishes these two whenever the corresponding plugins are bound to the build.
        WriteJarWithManifest(tempDir.Path, "build/libs/orders-plain.jar", mainClass: null);
        WriteJarWithManifest(tempDir.Path, "build/libs/orders-sources.jar", mainClass: null);
        WriteJarWithManifest(tempDir.Path, "build/libs/orders-javadoc.jar", mainClass: null);
 
        var app = builder.AddJavaApp("orders", tempDir.Path).WithGradleTask("bootRun");
 
        var launchConfiguration = await GetLaunchConfigurationAsync(app);
 
        Assert.Equal("com.example.orders.OrdersApplication", launchConfiguration.MainClass);
    }
 
    [Fact]
    public async Task AddJavaApp_WithoutAJar_SendsNoMainClassWhenTheBuildOutputIsAmbiguous()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.WriteWrapper(JavaHostingExtensions.s_defaultMavenWrapper);
 
        WriteJarWithManifest(tempDir.Path, "target/catalog-0.0.1-SNAPSHOT.jar", mainClass: null, startClass: "com.example.catalog.CatalogApplication");
        WriteJarWithManifest(tempDir.Path, "target/catalog-0.0.1-SNAPSHOT-shaded.jar", mainClass: null, startClass: "com.example.catalog.Other");
 
        var app = builder.AddJavaApp("catalog", tempDir.Path).WithMavenGoal("spring-boot:run");
 
        var launchConfiguration = await GetLaunchConfigurationAsync(app);
 
        // Picking one of two application archives would silently debug the wrong program.
        Assert.Null(launchConfiguration.MainClass);
    }
 
    [Fact]
    public async Task AddJavaApp_WithoutAJar_SendsNoMainClassWhenTheArchiveOnlyNamesASpringBootLauncher()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.WriteWrapper(JavaHostingExtensions.s_defaultMavenWrapper);
 
        WriteJarWithManifest(tempDir.Path, "target/catalog.jar", mainClass: "org.springframework.boot.loader.launch.JarLauncher");
 
        var app = builder.AddJavaApp("catalog", tempDir.Path).WithMavenGoal("spring-boot:run");
 
        var launchConfiguration = await GetLaunchConfigurationAsync(app);
 
        // The launcher can only start with the archive on the classpath, which this launch mode does
        // not send, so reporting it would produce a JVM that fails immediately.
        Assert.Null(launchConfiguration.MainClass);
    }
 
    [Fact]
    public async Task AddJavaApp_WithoutAJar_PrefersAnExplicitMainClassOverTheBuildOutput()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.WriteWrapper(JavaHostingExtensions.s_defaultMavenWrapper);
 
        WriteJarWithManifest(tempDir.Path, "target/catalog.jar", mainClass: null, startClass: "com.example.catalog.CatalogApplication");
 
        var app = builder.AddJavaApp("catalog", tempDir.Path)
            .WithMavenGoal("spring-boot:run")
            .WithMainClass("com.example.catalog.DebugEntryPoint");
 
        var launchConfiguration = await GetLaunchConfigurationAsync(app);
 
        Assert.Equal("com.example.catalog.DebugEntryPoint", launchConfiguration.MainClass);
    }
 
    [Fact]
    public async Task AddJavaApp_WithNoResolvableEntryPoint_NamesTheMavenProjectSoTheIdeDoesNotPrompt()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var tempDir = new TempJavaAppDirectory(directoryName: "catalog");
        tempDir.WriteWrapper(JavaHostingExtensions.s_defaultMavenWrapper);
 
        // <parent> declares an artifactId too, and it is the one a descendant search finds first.
        File.WriteAllText(Path.Combine(tempDir.Path, "pom.xml"), """
            <?xml version="1.0" encoding="UTF-8"?>
            <project xmlns="http://maven.apache.org/POM/4.0.0">
                <modelVersion>4.0.0</modelVersion>
                <parent>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-parent</artifactId>
                    <version>3.5.0</version>
                </parent>
                <groupId>com.example</groupId>
                <artifactId>catalog</artifactId>
                <version>0.0.1-SNAPSHOT</version>
            </project>
            """);
 
        var app = builder.AddJavaApp("catalog", tempDir.Path).WithMavenGoal("spring-boot:run");
 
        var launchConfiguration = await GetLaunchConfigurationAsync(app);
 
        Assert.Null(launchConfiguration.MainClass);
        Assert.Equal("catalog", launchConfiguration.ProjectName);
    }
 
    [Fact]
    public async Task AddJavaApp_WithNoResolvableEntryPoint_NamesTheGradleProjectFromItsSettingsFile()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var tempDir = new TempJavaAppDirectory(directoryName: "orders");
        tempDir.WriteWrapper(JavaHostingExtensions.s_defaultGradleWrapper);
 
        File.WriteAllText(Path.Combine(tempDir.Path, "settings.gradle"), "rootProject.name = 'orders'\n");
 
        var app = builder.AddJavaApp("orders", tempDir.Path).WithGradleTask("bootRun");
 
        var launchConfiguration = await GetLaunchConfigurationAsync(app);
 
        Assert.Null(launchConfiguration.MainClass);
        Assert.Equal("orders", launchConfiguration.ProjectName);
    }
 
    [Fact]
    public async Task AddJavaApp_WithNoResolvableEntryPoint_FallsBackToTheDirectoryNameGradleWouldUse()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.WriteWrapper(JavaHostingExtensions.s_defaultGradleWrapper);
 
        var app = builder.AddJavaApp("orders", tempDir.Path).WithGradleTask("bootRun");
 
        var launchConfiguration = await GetLaunchConfigurationAsync(app);
 
        Assert.Equal(
            Path.GetFileName(tempDir.Path.TrimEnd(Path.DirectorySeparatorChar)),
            launchConfiguration.ProjectName);
    }
 
    [Fact]
    public async Task AddJavaApp_WhenTheProjectDirectoryIsNamedDifferently_DoesNotGuessTheIdeProjectName()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var tempDir = new TempJavaAppDirectory(directoryName: "JavaSpringBoot.AppHost.Java");
        tempDir.WriteWrapper(JavaHostingExtensions.s_defaultGradleWrapper);
 
        File.WriteAllText(
            Path.Combine(tempDir.Path, "settings.gradle"),
            "rootProject.name = 'javaspringboot-apphost'\n");
 
        var app = builder.AddJavaApp("apphost", tempDir.Path).WithGradleTask("bootRun");
 
        var launchConfiguration = await GetLaunchConfigurationAsync(app);
 
        // The language server appends the directory when it disagrees with the declared name, importing
        // this project as "javaspringboot-apphost-JavaSpringBoot.AppHost.Java". Sending the declared name
        // would name a project that does not exist and fail every launch, so nothing is sent and the
        // adapter resolves the entry point across the workspace as it did before.
        Assert.Null(launchConfiguration.ProjectName);
    }
 
    [Fact]
    public async Task AddJavaApp_WithAnExplicitJar_DoesNotScopeResolutionToAProject()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var tempDir = new TempJavaAppDirectory(directoryName: "worker");
        tempDir.WriteWrapper(JavaHostingExtensions.s_defaultMavenWrapper);
 
        File.WriteAllText(Path.Combine(tempDir.Path, "pom.xml"), """
            <?xml version="1.0" encoding="UTF-8"?>
            <project xmlns="http://maven.apache.org/POM/4.0.0">
                <modelVersion>4.0.0</modelVersion>
                <groupId>com.example</groupId>
                <artifactId>worker</artifactId>
                <version>0.0.1-SNAPSHOT</version>
            </project>
            """);
 
        WriteJarWithManifest(
            tempDir.Path,
            Path.Combine("target", "worker-0.0.1-SNAPSHOT.jar"),
            mainClass: "com.example.worker.Worker");
 
        var app = builder
            .AddJavaApp("worker", tempDir.Path, Path.Combine("target", "worker-0.0.1-SNAPSHOT.jar"))
            .WithMavenBuild("package");
 
        var launchConfiguration = await GetLaunchConfigurationAsync(app);
 
        // The archive is on the classpath, so the adapter launches from it rather than from a project
        // the language server compiled. Naming a project as well would ask it to resolve the class
        // somewhere it does not have to exist.
        Assert.Equal("com.example.worker.Worker", launchConfiguration.MainClass);
        Assert.NotNull(launchConfiguration.ClassPaths);
        Assert.Null(launchConfiguration.ProjectName);
    }
}