File: Templating\TemplateEngineFactoryGitTests.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 Aspire.Cli.Commands;
using Aspire.Cli.Git;
using Aspire.Cli.Resources;
using Aspire.Cli.Templating;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Time.Testing;
 
namespace Aspire.Cli.Tests.Templating;
 
public class TemplateEngineFactoryGitTests(ITestOutputHelper outputHelper)
{
    private const string CommitSha = TestGitCommandRunner.DefaultCommitSha;
 
    [Theory]
    [InlineData(40)]
    [InlineData(64)]
    public async Task RepositoryTemplate_FetchesExactCommitAndChecksOutDetached(int shaLength)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var commit = new string('a', shaLength);
        var commands = TestGitCommandRunner.CreateForTemplateCheckout(CreateTemplateCheckoutAsync);
        using var provider = CreateServices(workspace, commands).BuildServiceProvider();
 
        var templates = await GetFactory(provider).GetTemplatesAsync(
            new TemplateSource(".", "-repository with spaces", commit), TestContext.Current.CancellationToken);
 
        Assert.Equal("git-template", Assert.Single(templates).Name);
        var init = commands.Commands.First();
        var destination = init.Arguments[^1];
        Assert.Collection(commands.Commands,
            command =>
            {
                Assert.Equal(["init", "--quiet", $"--object-format={(shaLength == 64 ? "sha256" : "sha1")}", "--", destination], command.Arguments);
                Assert.Equal(workspace.Path, command.WorkingDirectory.FullName);
            },
            command =>
            {
                Assert.Equal(["fetch", "--depth", "1", "--no-tags", "--", "-repository with spaces", commit], command.Arguments);
                Assert.Equal(destination, command.WorkingDirectory.FullName);
            },
            command => Assert.Equal(["checkout", "--detach", "--force", "FETCH_HEAD"], command.Arguments),
            command => Assert.Equal(["rev-parse", "--verify", "HEAD"], command.Arguments));
        Assert.All(commands.Commands, command => Assert.Equal(GitCommandMode.Captured, command.Mode));
    }
 
    [Fact]
    public async Task RepositoryTemplate_RejectsCheckoutOfDifferentCommit()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var commands = new TestGitCommandRunner { Output = new string('a', 40) };
        using var provider = CreateServices(workspace, commands).BuildServiceProvider();
 
        var exception = await Assert.ThrowsAsync<TemplateDiscoveryException>(() =>
            GetFactory(provider).GetTemplatesAsync(new TemplateSource(".", "repository", CommitSha), TestContext.Current.CancellationToken));
 
        Assert.Equal(string.Format(NewCommandStrings.TemplateRepositoryRefNotFound, CommitSha), exception.Message);
        Assert.Equal(4, commands.Commands.Count);
    }
 
    [Theory]
    [InlineData(1)]
    [InlineData(2)]
    [InlineData(3)]
    [InlineData(4)]
    public async Task RepositoryTemplate_CommandFailureStopsFurtherCommands(int failingCommand)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var commands = new TestGitCommandRunner();
        commands.RunCallback = (_, _) => commands.Commands.Count == failingCommand
            ? throw new GitCommandException(GitFailure.CommandFailed, 128)
            : Task.FromResult(CommitSha);
        using var provider = CreateServices(workspace, commands).BuildServiceProvider();
 
        var exception = await Assert.ThrowsAsync<TemplateDiscoveryException>(() =>
            GetFactory(provider).GetTemplatesAsync(new TemplateSource(".", "repository", CommitSha), TestContext.Current.CancellationToken));
 
        Assert.Equal(CliExitCodes.FailedToInstallTemplates, exception.ExitCode);
        Assert.Equal(string.Format(NewCommandStrings.TemplateRepositoryGitExited, 128, ErrorStrings.GitCommandFailed), exception.Message);
        Assert.Equal(failingCommand, commands.Commands.Count);
    }
 
    [Fact]
    public async Task RepositoryTemplate_AlreadyCanceledDoesNotRunCommands()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var commands = new TestGitCommandRunner();
        using var provider = CreateServices(workspace, commands).BuildServiceProvider();
 
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
            GetFactory(provider).GetTemplatesAsync(new TemplateSource(".", "repository", CommitSha), new CancellationToken(canceled: true)));
 
        Assert.Empty(commands.Commands);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task RepositoryTemplate_CallerCancellationIsNotReportedAsTimeout(bool resolving)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
        var commands = new TestGitCommandRunner
        {
            RunCallback = (_, _) =>
            {
                cancellation.Cancel();
                return Task.FromResult(CommitSha);
            }
        };
        using var provider = CreateServices(workspace, commands).BuildServiceProvider();
 
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
            GetFactory(provider).GetTemplatesAsync(new TemplateSource(".", "repository", resolving ? null : CommitSha), cancellation.Token));
 
        Assert.Single(commands.Commands);
    }
 
    [Fact]
    public async Task RepositoryTemplate_CheckoutTimeoutBudgetCoversAllCommands()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var time = new FakeTimeProvider();
        var commands = new TestGitCommandRunner
        {
            RunCallback = (_, token) =>
            {
                time.Advance(TimeSpan.FromSeconds(40));
                token.ThrowIfCancellationRequested();
                return Task.FromResult(CommitSha);
            }
        };
        var services = CreateServices(workspace, commands);
        services.AddSingleton<TimeProvider>(time);
        using var provider = services.BuildServiceProvider();
 
        var exception = await Assert.ThrowsAsync<TemplateDiscoveryException>(() =>
            GetFactory(provider).GetTemplatesAsync(new TemplateSource(".", "repository", CommitSha), TestContext.Current.CancellationToken));
 
        Assert.Equal(ErrorStrings.GitTimedOut, exception.Message);
        Assert.Equal(3, commands.Commands.Count);
    }
 
    [Fact]
    public async Task RepositoryTemplate_ResolutionTimeoutCancelsPendingCommand()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var time = new FakeTimeProvider();
        var commands = new TestGitCommandRunner
        {
            RunCallback = async (_, token) =>
            {
                time.Advance(TimeSpan.FromMinutes(2));
                await Task.Delay(Timeout.InfiniteTimeSpan, token);
                return string.Empty;
            }
        };
        var services = CreateServices(workspace, commands);
        services.AddSingleton<TimeProvider>(time);
        using var provider = services.BuildServiceProvider();
 
        var exception = await Assert.ThrowsAsync<TemplateDiscoveryException>(() =>
            GetFactory(provider).GetTemplatesAsync(new TemplateSource(".", "repository", null), TestContext.Current.CancellationToken));
 
        Assert.Equal(ErrorStrings.GitTimedOut, exception.Message);
        Assert.Single(commands.Commands);
    }
 
    [Fact]
    public async Task RepositoryTemplate_ResolutionAndCheckoutHaveSeparateTimeoutBudgets()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var time = new FakeTimeProvider();
        var commands = TestGitCommandRunner.CreateForTemplateCheckout(CreateTemplateCheckoutAsync);
        var run = Assert.IsType<Func<GitCommandInvocation, CancellationToken, Task<string>>>(commands.RunCallback);
        commands.RunCallback = (command, token) =>
        {
            time.Advance(command.Arguments[0] switch
            {
                "ls-remote" => TimeSpan.FromSeconds(119),
                "rev-parse" => TimeSpan.FromSeconds(29),
                _ => TimeSpan.FromSeconds(30)
            });
            return run(command, token);
        };
        var services = CreateServices(workspace, commands);
        services.AddSingleton<TimeProvider>(time);
        using var provider = services.BuildServiceProvider();
 
        var templates = await GetFactory(provider).GetTemplatesAsync(new TemplateSource(".", "repository", null), TestContext.Current.CancellationToken);
 
        Assert.Equal("git-template", Assert.Single(templates).Name);
        Assert.Equal(5, commands.Commands.Count);
    }
 
    private IServiceCollection CreateServices(TemporaryWorkspace workspace, TestGitCommandRunner commands)
    {
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        services.AddSingleton<IGitClient, GitClient>();
        services.AddSingleton<IGitCommandRunner>(commands);
        return services;
    }
 
    private static TemplateEngineFactory GetFactory(IServiceProvider provider) =>
        Assert.Single(provider.GetServices<ITemplateFactory>().OfType<TemplateEngineFactory>());
 
    private static async Task CreateTemplateCheckoutAsync(string repository, DirectoryInfo destination, string commit, CancellationToken token)
    {
        destination.CreateSubdirectory(".git");
        var configuration = destination.CreateSubdirectory(".template.config");
        await File.WriteAllTextAsync(Path.Combine(configuration.FullName, "template.json"),
            """{ "identity": "Aspire.GitTemplate", "name": "Git template", "shortName": "git-template" }""", token);
        await File.WriteAllTextAsync(Path.Combine(destination.FullName, "content.txt"), "template content", token);
    }
}