File: Templating\JavaStarterScaffoldTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text.Json;
using Aspire.Cli.Commands;
using Aspire.Cli.Projects;
using Aspire.Cli.Templating;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
 
namespace Aspire.Cli.Tests.Templating;
 
/// <summary>
/// Drives <c>aspire new aspire-java-starter</c> through the real <c>CliTemplateFactory</c> writer so
/// the assertions are about what actually lands on disk, with only
/// <see cref="IAppHostServerProjectFactory"/> faked so the run stops before it reaches the network,
/// the dotnet CLI, npm, or the Java toolchain.
/// </summary>
public class JavaStarterScaffoldTests(ITestOutputHelper outputHelper)
{
    /// <summary>
    /// Embedded resources carry no file mode, so every scaffolded file used to land at the default
    /// 0644. The Java starter ships a Gradle wrapper the README and the Java tooling both invoke as
    /// <c>./gradlew</c>, which fails with "Permission denied" against a non-executable file. The hosting
    /// resolver launches the wrapper through <c>sh</c> and so survives either way, which is exactly
    /// why this needs its own test: the AppHost keeps working while everything a developer types by
    /// hand does not.
    /// </summary>
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "File modes are a Unix concept; the wrapper is invoked as gradlew.bat on Windows.")]
    public async Task JavaStarter_ScaffoldsAnExecutableGradleWrapper()
    {
        using var scaffold = await ScaffoldJavaStarterAsync();
        var outputDirectory = scaffold.OutputDirectory;
 
        var wrapperPath = Path.Combine(outputDirectory, "api", "gradlew");
        Assert.True(File.Exists(wrapperPath), $"Expected the scaffolded Gradle wrapper at {wrapperPath}");
 
        var mode = GetUnixFileMode(wrapperPath);
        Assert.True(mode.HasFlag(UnixFileMode.UserExecute), $"Expected the scaffolded Gradle wrapper to be executable, but its mode was {mode}.");
    }
 
#pragma warning disable CA1416 // Only reached from a test skipped on Windows, which the analyzer cannot see through.
    private static UnixFileMode GetUnixFileMode(string path) => File.GetUnixFileMode(path);
#pragma warning restore CA1416
 
    /// <summary>
    /// The Java service, its wrapper, and the frontend all have to survive the scaffold together.
    /// Asserting the whole relative-path set rather than a handful of probes means a template file
    /// silently dropped from the embedded resource glob fails here instead of at a user's first
    /// <c>aspire run</c>.
    /// </summary>
    [Fact]
    public async Task JavaStarter_ScaffoldsTheJavaServiceAlongsideTheFrontend()
    {
        using var scaffold = await ScaffoldJavaStarterAsync();
        var outputDirectory = scaffold.OutputDirectory;
 
        var apiFiles = Directory
            .EnumerateFiles(Path.Combine(outputDirectory, "api"), "*", SearchOption.AllDirectories)
            .Select(path => Path.GetRelativePath(outputDirectory, path).Replace(Path.DirectorySeparatorChar, '/'))
            .Order(StringComparer.Ordinal)
            .ToArray();
 
        Assert.Equal(
            [
                "api/build.gradle",
                "api/gradle/wrapper/gradle-wrapper.jar",
                "api/gradle/wrapper/gradle-wrapper.properties",
                "api/gradlew",
                "api/gradlew.bat",
                "api/settings.gradle",
                "api/src/main/java/com/example/api/ApiApplication.java",
                "api/src/main/java/com/example/api/WeatherForecastController.java",
                "api/src/main/resources/application.properties",
            ],
            apiFiles);
 
        var templateWrapperJarPath = Path.Combine(
            GetRepoRoot(),
            "src",
            "Aspire.Cli",
            "Templating",
            "Templates",
            "java-starter",
            "api",
            "gradle",
            "wrapper",
            "gradle-wrapper.jar");
        var scaffoldedWrapperJarPath = Path.Combine(outputDirectory, "api", "gradle", "wrapper", "gradle-wrapper.jar");
        Assert.Equal(File.ReadAllBytes(templateWrapperJarPath), File.ReadAllBytes(scaffoldedWrapperJarPath));
 
        Assert.True(File.Exists(Path.Combine(outputDirectory, "frontend", "vite.config.ts")));
        Assert.True(File.Exists(Path.Combine(outputDirectory, "AppHost.java")));
    }
 
    [Fact]
    public async Task JavaStarter_ScaffoldsRootJavaProjectWithoutChangingCliOwnership()
    {
        using var scaffold = await ScaffoldJavaStarterAsync();
        var outputDirectory = scaffold.OutputDirectory;
 
        Assert.True(File.Exists(Path.Combine(outputDirectory, ".project")));
        Assert.True(File.Exists(Path.Combine(outputDirectory, ".classpath")));
        Assert.True(File.Exists(Path.Combine(outputDirectory, ".settings", "org.eclipse.jdt.core.prefs")));
        Assert.Contains("<name>JavaStarterOut</name>", File.ReadAllText(Path.Combine(outputDirectory, ".project")));
 
        using var config = JsonDocument.Parse(File.ReadAllText(Path.Combine(outputDirectory, "aspire.config.json")));
        var appHost = config.RootElement.GetProperty("appHost");
        Assert.Equal("AppHost.java", appHost.GetProperty("path").GetString());
        Assert.Equal("java", appHost.GetProperty("language").GetString());
 
        var toolchain = JavaAppHostToolchainResolver.Resolve(new DirectoryInfo(outputDirectory));
        Assert.Equal(JavaAppHostToolchain.Javac, toolchain.Toolchain);
        Assert.Equal(outputDirectory, toolchain.ProjectDirectory.FullName);
    }
 
    private async Task<ScaffoldedTemplate> ScaffoldJavaStarterAsync()
    {
        // The workspace is handed back to the caller rather than disposed here: disposal deletes the
        // directory, so every assertion about the scaffolded files has to run before it happens.
        var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.EnabledFeatures = [KnownFeatures.ExperimentalPolyglotJava];
        });
 
        // PrepareAsync returning failure stops the run right after the template tree is written,
        // which is the only side-effect these tests inspect.
        services.AddSingleton<IAppHostServerProjectFactory>(_ => new TestAppHostServerProjectFactory
        {
            CreateAsyncCallback = (path, _) =>
                Task.FromResult<IAppHostServerProject>(new FakeFailingAppHostServerProject(path))
        });
 
        using var serviceProvider = services.BuildServiceProvider();
        var newCommand = serviceProvider.GetRequiredService<NewCommand>();
 
        const string outputDirectoryName = "JavaStarterOut";
 
        // An explicit --version short-circuits template version resolution, which would otherwise
        // reach for the network. These tests are about the files the writer puts on disk, and the
        // version only reaches aspire.config.json.
        var parseResult = newCommand.Parse(
            $"new {KnownTemplateId.JavaStarter} --name {outputDirectoryName} --output ./{outputDirectoryName} --version 13.0.0 --localhost-tld false");
        _ = await parseResult.InvokeAsync().DefaultTimeout();
 
        return new ScaffoldedTemplate(workspace, Path.Combine(workspace.WorkspaceRoot.FullName, outputDirectoryName));
    }
 
    private static string GetRepoRoot()
        => Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
 
    private sealed class ScaffoldedTemplate(TemporaryWorkspace workspace, string outputDirectory) : IDisposable
    {
        public string OutputDirectory { get; } = outputDirectory;
 
        public void Dispose() => workspace.Dispose();
    }
}