File: CustomTemplateTests.cs
Web Access
Project: src\tests\Aspire.Cli.EndToEnd.Tests\Aspire.Cli.EndToEnd.Tests.csproj (Aspire.Cli.EndToEnd.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.Nodes;
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests;
 
public sealed class CustomTemplateTests(ITestOutputHelper output)
{
    [Fact]
    [CaptureWorkspaceOnFailure]
    public Task DirectoryTemplatePromptsWithoutDotNetSdk() => CreateFromTemplateAsync(repository: false, nonInteractive: false);
 
    [Fact]
    [CaptureWorkspaceOnFailure]
    public Task RepositoryTemplatePromptsWithoutDotNetSdk() => CreateFromTemplateAsync(repository: true, nonInteractive: false);
 
    [Fact]
    [CaptureWorkspaceOnFailure]
    public Task RepositoryTemplateUsesDefaultsNonInteractivelyWithoutDotNetSdk() => CreateFromTemplateAsync(repository: true, nonInteractive: true);
 
    private async Task CreateFromTemplateAsync(bool repository, bool nonInteractive)
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var testDirectory = Path.Combine(repoRoot, "tests", "Aspire.Cli.EndToEnd.Tests");
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        var workspace = TemporaryWorkspace.Create(output);
        TestDirectoryHelpers.CopyDirectory(
            Path.Combine(testDirectory, "Fixtures", "CustomTemplate"),
            Path.Combine(workspace.WorkspaceRoot.FullName, "template"));
 
        if (nonInteractive)
        {
            // The interactive fixture requires an explicit greeting; unattended generation uses a default instead.
            var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, "template", ".template.config", "template.json");
            var config = JsonNode.Parse(await File.ReadAllTextAsync(configPath))!;
            var greeting = config["symbols"]!["Greeting"]!;
            greeting["isRequired"] = false;
            greeting["defaultValue"] = "Hello from template defaults";
            await File.WriteAllTextAsync(configPath, config.ToJsonString());
        }
 
        // The Git server runs in the test process; host networking makes its loopback address reachable.
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(
            repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.Polyglot,
            workspace: workspace, network: repository ? "host" : null);
        var counter = new SequenceCounter();
        var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
        await using var terminalRun = CliE2ETestHelpers.StartRun(
            terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken);
 
        await auto.PrepareDockerEnvironmentAsync(counter, workspace);
        await auto.InstallAspireCliAsync(strategy, counter);
        await auto.RunCommandAsync("! command -v dotnet", counter);
 
        if (repository)
        {
            // The bind-mounted template belongs to the host UID. Trust only this fixture for Git setup.
            await auto.RunCommandAsync(
                "git -C template init && git -c safe.directory=\"$PWD/template\" -C template add . && " +
                "git -c safe.directory=\"$PWD/template\" -C template -c user.name='Aspire tests' " +
                "-c user.email=tests@example.com -c commit.gpgsign=false commit -m 'Add template' && " +
                "git -c safe.directory=\"$PWD/template\" clone --bare template template.git",
                counter);
        }
 
        await using var server = repository
            ? await GitHttpServer.StartAsync(workspace.Path, TestContext.Current.CancellationToken)
            : null;
        var source = server is not null ? $"--template-repository \"{server.RepositoryUrl}\"" : "--template-path template";
        var mode = nonInteractive ? " --non-interactive" : "";
        await auto.TypeAsync($"aspire new {source} --name PromptDemo --output out --suppress-agent-init --nologo{mode}");
        await auto.EnterAsync();
 
        if (!nonInteractive)
        {
            await auto.WaitUntilTextAsync("Greeting text", timeout: TimeSpan.FromSeconds(60));
            await auto.TypeAsync("Hello from the terminal");
            await auto.EnterAsync();
 
            await auto.WaitUntilTextAsync("Include readme", timeout: TimeSpan.FromSeconds(30));
            if (!repository)
            {
                // Confirmation accepts y/n immediately; a trailing Enter would answer the next prompt.
                await auto.TypeAsync("n");
            }
            else
            {
                await auto.EnterAsync();
            }
 
            await auto.WaitUntilTextAsync("> HTTP API", timeout: TimeSpan.FromSeconds(30));
            if (repository)
            {
                await auto.DownAsync();
            }
            await auto.EnterAsync();
 
            await auto.WaitUntilTextAsync("Included features", timeout: TimeSpan.FromSeconds(30));
            await auto.WaitUntilTextAsync("> [X] Web support", timeout: TimeSpan.FromSeconds(30));
            await auto.DownAsync();
            await auto.TypeAsync(" ");
            await auto.EnterAsync();
        }
        await auto.WaitForSuccessPromptAsync(counter, timeout: nonInteractive ? TimeSpan.FromSeconds(60) : null);
        await auto.RunCommandAsync("! command -v dotnet", counter);
 
        var outputDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "out");
        var files = Directory.GetFiles(outputDirectory, "*", SearchOption.AllDirectories)
            .Order(StringComparer.Ordinal)
            .ToDictionary(path => Path.GetRelativePath(outputDirectory, path).Replace('\\', '/'), File.ReadAllText);
        await VerifyXunit.Verifier.Verify(files).UseDirectory(Path.Combine(testDirectory, "Snapshots"));
    }
}