File: AddSpringBootAppTests.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.
 
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
 
namespace Aspire.Hosting.Java.Tests;
 
public class AddSpringBootAppTests
{
    [Fact]
    public async Task AddSpringBootApp_MavenProject_LaunchesThroughSpringBootRun()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.Write("pom.xml", "<project/>");
 
        var app = builder.AddSpringBootApp("catalog", tempDir.Path);
        using var application = builder.Build();
        await builder.Eventing.PublishAsync(
            new BeforeResourceStartedEvent(app.Resource, application.Services),
            TestContext.Current.CancellationToken);
 
        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 AddSpringBootApp_GradleProject_LaunchesThroughBootRun()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.Write("build.gradle", "plugins { id 'org.springframework.boot' }");
 
        var app = builder.AddSpringBootApp("orders", tempDir.Path);
        using var application = builder.Build();
        await builder.Eventing.PublishAsync(
            new BeforeResourceStartedEvent(app.Resource, application.Services),
            TestContext.Current.CancellationToken);
 
        Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command);
        Assert.Equal(ExpectedWrapperInvocation.Args(Path.Combine(tempDir.Path, JavaHostingExtensions.s_defaultGradleWrapper), tempDir.Path, "bootRun"), await ArgumentEvaluator.GetArgumentListAsync(app.Resource));
    }
 
    [Fact]
    public async Task AddSpringBootApp_KotlinGradleProject_IsDetectedAsGradle()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.Write("build.gradle.kts", "plugins { id(\"org.springframework.boot\") }");
 
        var app = builder.AddSpringBootApp("orders", tempDir.Path);
        using var application = builder.Build();
        await builder.Eventing.PublishAsync(
            new BeforeResourceStartedEvent(app.Resource, application.Services),
            TestContext.Current.CancellationToken);
 
        Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command);
    }
 
    [Theory]
    [InlineData("build.gradle")]
    [InlineData("build.gradle.kts")]
    [InlineData("settings.gradle")]
    [InlineData("settings.gradle.kts")]
    public async Task BuildToolDetection_GradleMarkersAgreeBetweenRunAndPublish(string marker)
    {
        using var tempDir = new TempJavaAppDirectory();
        tempDir.Write(marker, "");
 
        using var runBuilder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        var runApp = runBuilder.AddSpringBootApp("catalog", tempDir.Path);
        using var application = runBuilder.Build();
        await runBuilder.Eventing.PublishAsync(
            new BeforeResourceStartedEvent(runApp.Resource, application.Services),
            TestContext.Current.CancellationToken);
        var runTool = Assert.Single(runApp.Resource.Annotations.OfType<JavaBuildToolAnnotation>()).Tool;
 
        using var publishBuilder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var publishApp = publishBuilder.AddJavaApp("catalog", tempDir.Path, "build/libs/catalog.jar");
        var (publishTool, _) = JavaDockerfileGenerator.ResolveBuildTool(publishApp.Resource, tempDir.Path);
 
        Assert.Equal(JavaBuildTool.Gradle, runTool);
        Assert.Equal(runTool, publishTool);
    }
 
    [Theory]
    [InlineData("pom.xml")]
    [InlineData("build.gradle")]
    public void AddSpringBootApp_DoesNotCreateASeparateBuildResource(string buildFile)
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.Write(buildFile, "");
 
        var app = builder.AddSpringBootApp("catalog", tempDir.Path);
 
        Assert.Same(app.Resource, Assert.Single(builder.Resources));
    }
 
    [Fact]
    public async Task AddSpringBootApp_ExplicitLaunchGoalOverridesTheDetectedDefault()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.Write("pom.xml", "<project/>");
        tempDir.WriteWrapper(JavaHostingExtensions.s_defaultMavenWrapper);
 
        var app = builder.AddSpringBootApp("catalog", tempDir.Path)
            .WithMavenGoal("spring-boot:test-run");
        using var application = builder.Build();
        await builder.Eventing.PublishAsync(
            new BeforeResourceStartedEvent(app.Resource, application.Services),
            TestContext.Current.CancellationToken);
 
        Assert.Equal(
            ExpectedWrapperInvocation.Args(
                Path.Combine(tempDir.Path, JavaHostingExtensions.s_defaultMavenWrapper),
                tempDir.Path,
                "spring-boot:test-run"),
            await ArgumentEvaluator.GetArgumentListAsync(app.Resource));
    }
 
    [Fact]
    public async Task AddSpringBootApp_ExplicitBuildArgumentsDoNotCreateASeparateBuildResource()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.Write("pom.xml", "<project/>");
        tempDir.WriteWrapper(JavaHostingExtensions.s_defaultMavenWrapper);
 
        var app = builder.AddSpringBootApp("catalog", tempDir.Path)
            .WithMavenBuild("verify");
        using var application = builder.Build();
        await builder.Eventing.PublishAsync(
            new BeforeResourceStartedEvent(app.Resource, application.Services),
            TestContext.Current.CancellationToken);
 
        Assert.Same(app.Resource, Assert.Single(builder.Resources));
        Assert.Equal(["verify"], Assert.Single(app.Resource.Annotations.OfType<JavaBuildStepAnnotation>()).Args);
        Assert.Equal(["spring-boot:run"], Assert.Single(app.Resource.Annotations.OfType<JavaBuildToolAnnotation>()).Args);
    }
 
    [Fact]
    public void AddSpringBootApp_DeclaresHttpEndpointThroughServerPort()
    {
        // Spring Boot reads SERVER_PORT, which is how the port Aspire allocates reaches the application
        // without any code in the application itself.
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.Write("pom.xml", "<project/>");
 
        var app = builder.AddSpringBootApp("catalog", tempDir.Path);
 
        var endpoint = Assert.Single(app.Resource.Annotations.OfType<EndpointAnnotation>());
        Assert.Equal("http", endpoint.Name);
        Assert.Equal("SERVER_PORT", endpoint.TargetPortEnvironmentVariable);
 
        // Pinning a target port would make two Spring Boot services collide on a real port on the machine,
        // because these run as host processes rather than containers.
        Assert.Null(endpoint.TargetPort);
    }
 
    [Fact]
    public void AddSpringBootApp_AddsNoHealthCheck()
    {
        // /actuator/health only exists with spring-boot-starter-actuator. Adding it unconditionally would
        // leave applications without that dependency permanently unhealthy and stall every WaitFor on them.
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.Write("pom.xml", "<project/>");
 
        var app = builder.AddSpringBootApp("catalog", tempDir.Path);
 
        Assert.Empty(app.Resource.Annotations.OfType<HealthCheckAnnotation>());
    }
 
    [Fact]
    public async Task AddSpringBootApp_NoBuildFile_ThrowsWhenTheResourceStarts()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
 
        var app = builder.AddSpringBootApp("catalog", tempDir.Path);
        using var application = builder.Build();
 
        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => builder.Eventing.PublishAsync(
                new BeforeResourceStartedEvent(app.Resource, application.Services),
                TestContext.Current.CancellationToken));
 
        Assert.Equal(
            $"Directory '{tempDir.Path}' contains no pom.xml, build.gradle, build.gradle.kts, settings.gradle, or settings.gradle.kts, " +
            "so the build tool for resource 'catalog' cannot be detected. Check the path, or use AddJavaApp for an application laid out differently.",
            ex.Message);
    }
 
    [Fact]
    public async Task BuildToolDetection_BothBuildToolsAreRejectedInRunAndPublish()
    {
        using var tempDir = new TempJavaAppDirectory();
        tempDir.Write("pom.xml", "<project/>");
        tempDir.Write("build.gradle", "");
 
        using var runBuilder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        var runApp = runBuilder.AddSpringBootApp("catalog", tempDir.Path);
        using var application = runBuilder.Build();
        var runException = await Assert.ThrowsAsync<InvalidOperationException>(
            () => runBuilder.Eventing.PublishAsync(
                new BeforeResourceStartedEvent(runApp.Resource, application.Services),
                TestContext.Current.CancellationToken));
 
        using var publishBuilder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var publishApp = publishBuilder.AddJavaApp("catalog", tempDir.Path, "target/catalog.jar");
        var publishException = Assert.Throws<DistributedApplicationException>(
            () => JavaDockerfileGenerator.ResolveBuildTool(publishApp.Resource, tempDir.Path));
 
        Assert.Equal(
            $"Directory '{tempDir.Path}' contains both Maven and Gradle build files, so the build tool for resource 'catalog' is ambiguous. " +
            "Use AddJavaApp and call WithMavenBuild, WithGradleBuild, WithMavenGoal, or WithGradleTask to choose one explicitly.",
            runException.Message);
        Assert.Equal(runException.Message, publishException.Message);
    }
 
    [Theory]
    [InlineData("pom.xml", "target")]
    [InlineData("build.gradle", "build")]
    public async Task WithOtelAgent_NoPath_ResolvesTheBuildToolsOutputDirectory(string buildFile, string outputDirectory)
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.Write(buildFile, "");
 
        var app = builder.AddSpringBootApp("catalog", tempDir.Path).WithOtelAgent();
        using var application = builder.Build();
        await builder.Eventing.PublishAsync(
            new BeforeResourceStartedEvent(app.Resource, application.Services),
            TestContext.Current.CancellationToken);
 
        TestEndpointAllocator.AllocateEndpoints(app.Resource);
 
        var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        var expected = Path.GetFullPath(Path.Combine(tempDir.Path, outputDirectory, "agent", "opentelemetry-javaagent.jar"));
        Assert.Equal($"-javaagent:{expected}", envVars["JAVA_TOOL_OPTIONS"]);
    }
 
    [Theory]
    [InlineData("pom.xml", "target")]
    [InlineData("build.gradle", "build")]
    public async Task WithOtelAgent_NoPath_ResolvesTheBuildToolConfiguredAfterIt(string buildFile, string outputDirectory)
    {
        // The build tool is deliberately configured after the agent. WithWrapperPath promises order
        // independence, and the agent overload has no reason to be the one method that does not.
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.Write(buildFile, "");
 
        var app = builder.AddJavaApp("catalog", tempDir.Path).WithOtelAgent();
 
        if (buildFile is "pom.xml")
        {
            app.WithMavenGoal("spring-boot:run").WithMavenBuild();
        }
        else
        {
            app.WithGradleTask("bootRun").WithGradleBuild();
        }
 
        TestEndpointAllocator.AllocateEndpoints(app.Resource);
 
        var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        var expected = Path.GetFullPath(Path.Combine(tempDir.Path, outputDirectory, "agent", "opentelemetry-javaagent.jar"));
        Assert.Equal($"-javaagent:{expected}", envVars["JAVA_TOOL_OPTIONS"]);
    }
 
    [Theory]
    [InlineData("pom.xml", "maven")]
    [InlineData("build.gradle", "gradle")]
    public async Task WithOtelAgent_BuildProducedAgent_AddsABuildTheApplicationWaitsFor(string buildFile, string toolName)
    {
        // spring-boot:run and bootRun compile the application themselves, so a Spring Boot resource
        // normally has no separate build resource. A build-produced agent makes one mandatory anyway:
        // JAVA_TOOL_OPTIONS is read by the wrapper's own JVM, so without a build that has already
        // written the agent, that JVM dies at VM initialization before the launch goal ever runs.
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.Write(buildFile, "");
 
        var app = builder.AddSpringBootApp("catalog", tempDir.Path).WithOtelAgent();
 
        var buildResource = Assert.Single(
            builder.Resources.OfType<JavaBuildResource>(),
            resource => resource.Name == $"catalog-{toolName}-build");
 
        Assert.Contains(
            app.Resource.Annotations.OfType<WaitAnnotation>(),
            wait => ReferenceEquals(wait.Resource, buildResource));
 
        // The build resource is the one that produces the agent, so it must not be asked to load it.
        var buildEnv = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            buildResource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.DoesNotContain("JAVA_TOOL_OPTIONS", buildEnv.Keys);
    }
 
    [Fact]
    public async Task WithOtelAgent_AbsoluteAgentPath_AddsNoBuild()
    {
        // An absolute path is supplied by the machine rather than produced by the build, so there is
        // nothing to build first and adding a resource would just slow the application down.
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.Write("pom.xml", "");
 
        var app = builder.AddSpringBootApp("catalog", tempDir.Path)
            .WithOtelAgent(Path.Combine(Path.GetTempPath(), "opentelemetry-javaagent.jar"));
 
        Assert.Empty(builder.Resources.OfType<JavaBuildResource>());
        Assert.Empty(app.Resource.Annotations.OfType<WaitAnnotation>());
    }
 
    [Fact]
    public async Task WithOtelAgent_NoPath_WithoutABuild_Throws()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
 
        // Resolution is deferred so the build tool can be configured afterwards, so the failure for a
        // resource that never configures one surfaces when the environment is evaluated.
        var app = builder.AddJavaApp("api", tempDir.Path).WithOtelAgent();
 
        TestEndpointAllocator.AllocateEndpoints(app.Resource);
 
        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            async () => await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
                app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance));
 
        Assert.Contains("has no Maven or Gradle build configured", ex.Message, StringComparison.Ordinal);
    }
 
    [Fact]
    public async Task AddSpringBootApp_RemainsAJavaAppResource()
    {
        // The helper is AddJavaApp with the Spring Boot defaults applied, so every other With… method has
        // to keep working on the result.
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.Write("pom.xml", "<project/>");
 
        var app = builder.AddSpringBootApp("catalog", tempDir.Path)
            .WithJvmArgs("-Xmx256m")
            .WithExternalHttpEndpoints();
 
        TestEndpointAllocator.AllocateEndpoints(app.Resource);
 
        var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.Equal("-Xmx256m", envVars["JAVA_TOOL_OPTIONS"]);
        Assert.True(Assert.Single(app.Resource.Annotations.OfType<EndpointAnnotation>()).IsExternal);
    }
 
    [Fact]
    public void WithMavenBuildThenWithOtelAgent_CreatesTheBuildResourceTheAgentNeeds()
    {
        // spring-boot:run compiles on its way to running, so the first WithMavenBuild records the build
        // step without adding a resource. A relative OpenTelemetry agent path then makes the build
        // mandatory - nothing else writes target/agent/opentelemetry-javaagent.jar - and the second pass
        // through WithJavaBuildStep is the one that has to add it. Skipping on the annotation alone left
        // the annotation naming a resource that was never created, and the JVM failed at startup with
        // "Error opening zip file or JAR manifest missing".
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true);
        using var tempDir = new TempJavaAppDirectory();
        tempDir.Write("pom.xml", "<project/>");
 
        var app = builder.AddSpringBootApp("catalog", tempDir.Path)
            .WithMavenBuild("-DskipTests", "package");
 
        Assert.DoesNotContain(builder.Resources, r => r.Name == "catalog-maven-build");
 
        app.WithOtelAgent();
 
        var buildResource = Assert.Single(builder.Resources, r => r.Name == "catalog-maven-build");
        Assert.Equal(JavaBuildTool.Maven, Assert.IsType<JavaBuildResource>(buildResource).Tool);
    }
}