File: Templating\DotNetTemplateFactoryTests.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.Backchannel;
using Aspire.Cli.Certificates;
using Aspire.Cli.Commands;
using Aspire.Cli.Configuration;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.NuGet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Templating;
using Aspire.Cli.Tests.Utils;
using Aspire.Shared;
using Spectre.Console;
 
namespace Aspire.Cli.Tests.Templating;
 
public class DotNetTemplateFactoryTests
{
    private readonly ITestOutputHelper _outputHelper;
 
    public DotNetTemplateFactoryTests(ITestOutputHelper outputHelper)
    {
        _outputHelper = outputHelper;
    }
 
    private sealed class FakeNuGetPackageCache : INuGetPackageCache
    {
        public Task<IEnumerable<Aspire.Shared.NuGetPackageCli>> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken)
        {
            _ = workingDirectory; _ = prerelease; _ = nugetConfigFile; _ = cancellationToken; return Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>([]);
        }
        public Task<IEnumerable<Aspire.Shared.NuGetPackageCli>> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken)
        {
            _ = workingDirectory; _ = prerelease; _ = nugetConfigFile; _ = cancellationToken; return Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>([]);
        }
        public Task<IEnumerable<Aspire.Shared.NuGetPackageCli>> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken)
        {
            _ = workingDirectory; _ = prerelease; _ = nugetConfigFile; _ = cancellationToken; return Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>([]);
        }
        public Task<IEnumerable<Aspire.Shared.NuGetPackageCli>> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func<string, bool>? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken)
        {
            _ = workingDirectory; _ = packageId; _ = filter; _ = prerelease; _ = nugetConfigFile; _ = useCache; _ = cancellationToken; return Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>([]);
        }
    }
 
    private static PackageChannel CreateExplicitChannel(PackageMapping[] mappings) =>
        PackageChannel.CreateExplicitChannel("test", PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache());
 
    private static async Task WriteNuGetConfigAsync(DirectoryInfo dir, string content)
    {
        var path = Path.Combine(dir.FullName, "NuGet.config");
        await File.WriteAllTextAsync(path, content);
    }
 
    /// <summary>
    /// Test that simulates the path comparison logic by testing NuGetConfigMerger behavior
    /// directly, which is what PromptToCreateOrUpdateNuGetConfigAsync will ultimately call.
    /// </summary>
    [Fact]
    public async Task NuGetConfigMerger_InPlaceCreation_WithoutExistingConfig_CreatesInWorkingDirectory()
    {
        // Arrange
        using var workspace = TemporaryWorkspace.Create(_outputHelper);
        var workingDir = workspace.WorkspaceRoot;
 
        var mappings = new[]
        {
            new PackageMapping("Aspire.*", "https://test.feed.example.com")
        };
        var channel = CreateExplicitChannel(mappings);
 
        // Act - Simulate in-place creation: output directory same as working directory
        await NuGetConfigMerger.CreateOrUpdateAsync(workingDir, channel);
 
        // Assert
        var nugetConfigPath = Path.Combine(workingDir.FullName, "NuGet.config");
        Assert.True(File.Exists(nugetConfigPath), "NuGet.config should be created in working directory for in-place creation");
    }
 
    [Fact]
    public async Task NuGetConfigMerger_InPlaceCreation_WithExistingConfig_UpdatesWorkingDirectoryConfig()
    {
        // Arrange
        using var workspace = TemporaryWorkspace.Create(_outputHelper);
        var workingDir = workspace.WorkspaceRoot;
 
        // Create existing NuGet.config in working directory without the required source
        await WriteNuGetConfigAsync(workingDir,
            """
            <?xml version="1.0"?>
            <configuration>
                <packageSources>
                    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
                </packageSources>
            </configuration>
            """);
 
        var mappings = new[]
        {
            new PackageMapping("Aspire.*", "https://test.feed.example.com")
        };
        var channel = CreateExplicitChannel(mappings);
 
        // Act - Simulate in-place creation: output directory same as working directory
        await NuGetConfigMerger.CreateOrUpdateAsync(workingDir, channel);
 
        // Assert
        var nugetConfigPath = Path.Combine(workingDir.FullName, "NuGet.config");
        Assert.True(File.Exists(nugetConfigPath), "NuGet.config should exist in working directory");
 
        var content = await File.ReadAllTextAsync(nugetConfigPath);
        Assert.Contains("https://test.feed.example.com", content);
    }
 
    [Fact]
    public async Task NuGetConfigMerger_SubdirectoryCreation_WithParentConfig_IgnoresParentAndCreatesInOutputDirectory()
    {
        // Arrange
        using var workspace = TemporaryWorkspace.Create(_outputHelper);
        var workingDir = workspace.WorkspaceRoot;
        var outputDir = Directory.CreateDirectory(Path.Combine(workingDir.FullName, "MyProject"));
 
        // Create existing NuGet.config in working directory (parent)
        var parentConfigContent =
            """
            <?xml version="1.0"?>
            <configuration>
                <packageSources>
                    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
                </packageSources>
            </configuration>
            """;
        await WriteNuGetConfigAsync(workingDir, parentConfigContent);
 
        var mappings = new[]
        {
            new PackageMapping("Aspire.*", "https://test.feed.example.com")
        };
        var channel = CreateExplicitChannel(mappings);
 
        // Act - Simulate subdirectory creation: output directory different from working directory
        await NuGetConfigMerger.CreateOrUpdateAsync(outputDir, channel);
 
        // Assert
        // Parent NuGet.config should remain unchanged
        var parentConfigPath = Path.Combine(workingDir.FullName, "NuGet.config");
        var parentContent = await File.ReadAllTextAsync(parentConfigPath);
        Assert.Equal(parentConfigContent.ReplaceLineEndings(), parentContent.ReplaceLineEndings());
        Assert.DoesNotContain("https://test.feed.example.com", parentContent);
 
        // New NuGet.config should be created in output directory
        var outputConfigPath = Path.Combine(outputDir.FullName, "NuGet.config");
        Assert.True(File.Exists(outputConfigPath), "NuGet.config should be created in output directory");
 
        var outputContent = await File.ReadAllTextAsync(outputConfigPath);
        Assert.Contains("https://test.feed.example.com", outputContent);
    }
 
    [Fact]
    public async Task NuGetConfigMerger_SubdirectoryCreation_WithExistingConfigInOutputDirectory_MergesInOutputDirectory()
    {
        // Arrange
        using var workspace = TemporaryWorkspace.Create(_outputHelper);
        var workingDir = workspace.WorkspaceRoot;
        var outputDir = Directory.CreateDirectory(Path.Combine(workingDir.FullName, "MyProject"));
 
        // Create existing NuGet.config in output directory
        await WriteNuGetConfigAsync(outputDir,
            """
            <?xml version="1.0"?>
            <configuration>
                <packageSources>
                    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
                </packageSources>
            </configuration>
            """);
 
        var mappings = new[]
        {
            new PackageMapping("Aspire.*", "https://test.feed.example.com")
        };
        var channel = CreateExplicitChannel(mappings);
 
        // Act - Simulate subdirectory creation: merge into existing config in output directory
        await NuGetConfigMerger.CreateOrUpdateAsync(outputDir, channel);
 
        // Assert
        var outputConfigPath = Path.Combine(outputDir.FullName, "NuGet.config");
        Assert.True(File.Exists(outputConfigPath), "NuGet.config should exist in output directory");
 
        var content = await File.ReadAllTextAsync(outputConfigPath);
        Assert.Contains("https://test.feed.example.com", content);
        Assert.Contains("https://api.nuget.org/v3/index.json", content);
    }
 
    [Fact]
    public async Task NuGetConfigMerger_SubdirectoryCreation_WithoutAnyConfig_CreatesInOutputDirectory()
    {
        // Arrange
        using var workspace = TemporaryWorkspace.Create(_outputHelper);
        var workingDir = workspace.WorkspaceRoot;
        var outputDir = Directory.CreateDirectory(Path.Combine(workingDir.FullName, "MyProject"));
 
        var mappings = new[]
        {
            new PackageMapping("Aspire.*", "https://test.feed.example.com")
        };
        var channel = CreateExplicitChannel(mappings);
 
        // Act - Simulate subdirectory creation: create new config in output directory
        await NuGetConfigMerger.CreateOrUpdateAsync(outputDir, channel);
 
        // Assert
        // No NuGet.config should exist in working directory
        var workingConfigPath = Path.Combine(workingDir.FullName, "NuGet.config");
        Assert.False(File.Exists(workingConfigPath), "No NuGet.config should be created in working directory");
 
        // New NuGet.config should be created in output directory
        var outputConfigPath = Path.Combine(outputDir.FullName, "NuGet.config");
        Assert.True(File.Exists(outputConfigPath), "NuGet.config should be created in output directory");
 
        var content = await File.ReadAllTextAsync(outputConfigPath);
        Assert.Contains("https://test.feed.example.com", content);
    }
 
    [Fact]
    public async Task NuGetConfigMerger_ImplicitChannel_DoesNothing()
    {
        // Arrange
        using var workspace = TemporaryWorkspace.Create(_outputHelper);
        var workingDir = workspace.WorkspaceRoot;
        var outputDir = Directory.CreateDirectory(Path.Combine(workingDir.FullName, "MyProject"));
 
        var channel = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache());
 
        // Act
        await NuGetConfigMerger.CreateOrUpdateAsync(outputDir, channel);
 
        // Assert
        // No NuGet.config should be created anywhere
        var workingConfigPath = Path.Combine(workingDir.FullName, "NuGet.config");
        var outputConfigPath = Path.Combine(outputDir.FullName, "NuGet.config");
        Assert.False(File.Exists(workingConfigPath), "No NuGet.config should be created for implicit channel");
        Assert.False(File.Exists(outputConfigPath), "No NuGet.config should be created for implicit channel");
    }
 
    [Fact]
    public async Task NuGetConfigMerger_ExplicitChannelWithoutMappings_DoesNothing()
    {
        // Arrange
        using var workspace = TemporaryWorkspace.Create(_outputHelper);
        var workingDir = workspace.WorkspaceRoot;
        var outputDir = Directory.CreateDirectory(Path.Combine(workingDir.FullName, "MyProject"));
 
        var channel = CreateExplicitChannel([]); // No mappings
 
        // Act
        await NuGetConfigMerger.CreateOrUpdateAsync(outputDir, channel);
 
        // Assert
        // No NuGet.config should be created anywhere
        var workingConfigPath = Path.Combine(workingDir.FullName, "NuGet.config");
        var outputConfigPath = Path.Combine(outputDir.FullName, "NuGet.config");
        Assert.False(File.Exists(workingConfigPath), "No NuGet.config should be created when no mappings exist");
        Assert.False(File.Exists(outputConfigPath), "No NuGet.config should be created when no mappings exist");
    }
 
    [Fact]
    public void GetTemplates_WhenShowAllTemplatesIsEnabled_ReturnsAllTemplates()
    {
        // Arrange
        var features = new TestFeatures(showAllTemplates: true);
        var factory = CreateTemplateFactory(features);
 
        // Act
        var templates = factory.GetTemplates().ToList();
 
        // Assert
        var templateNames = templates.Select(t => t.Name).ToList();
        Assert.Contains("aspire-starter", templateNames);
        Assert.Contains("aspire", templateNames);
        Assert.Contains("aspire-apphost", templateNames);
        Assert.Contains("aspire-servicedefaults", templateNames);
        Assert.Contains("aspire-test", templateNames);
    }
 
    [Fact]
    public void GetTemplates_WhenShowAllTemplatesIsDisabled_ReturnsOnlyStarterTemplates()
    {
        // Arrange
        var features = new TestFeatures(showAllTemplates: false);
        var factory = CreateTemplateFactory(features);
 
        // Act
        var templates = factory.GetTemplates().ToList();
 
        // Assert
        var templateNames = templates.Select(t => t.Name).ToList();
        Assert.Contains("aspire-starter", templateNames);
        Assert.Contains("aspire", templateNames);
        Assert.DoesNotContain("aspire-apphost", templateNames);
        Assert.DoesNotContain("aspire-servicedefaults", templateNames);
        Assert.DoesNotContain("aspire-test", templateNames);
    }
 
    [Fact]
    public void GetTemplates_WhenShowAllTemplatesIsDisabled_SingleFileAppHostIsAlsoHidden()
    {
        // Arrange - disable showAllTemplates but enable singleFileAppHost
        var features = new TestFeatures(showAllTemplates: false, singleFileAppHostEnabled: true);
        var factory = CreateTemplateFactory(features);
 
        // Act
        var templates = factory.GetTemplates().ToList();
 
        // Assert
        var templateNames = templates.Select(t => t.Name).ToList();
        Assert.DoesNotContain("aspire-apphost-singlefile", templateNames);
    }
 
    [Fact]
    public void GetTemplates_WhenShowAllTemplatesIsEnabled_SingleFileAppHostIsVisibleIfFeatureEnabled()
    {
        // Arrange - enable showAllTemplates and enable singleFileAppHost
        var features = new TestFeatures(showAllTemplates: true, singleFileAppHostEnabled: true);
        var factory = CreateTemplateFactory(features);
 
        // Act
        var templates = factory.GetTemplates().ToList();
 
        // Assert
        var templateNames = templates.Select(t => t.Name).ToList();
        Assert.Contains("aspire-apphost-singlefile", templateNames);
    }
 
    private static DotNetTemplateFactory CreateTemplateFactory(TestFeatures features)
    {
        var interactionService = new TestInteractionService();
        var runner = new TestDotNetCliRunner();
        var certificateService = new TestCertificateService();
        var packagingService = new TestPackagingService();
        var prompter = new TestNewCommandPrompter();
        var workingDirectory = new DirectoryInfo("/tmp");
        var hivesDirectory = new DirectoryInfo("/tmp/hives");
        var cacheDirectory = new DirectoryInfo("/tmp/cache");
        var executionContext = new CliExecutionContext(workingDirectory, hivesDirectory, cacheDirectory);
 
        return new DotNetTemplateFactory(
            interactionService,
            runner,
            certificateService,
            packagingService,
            prompter,
            executionContext,
            features);
    }
 
    private sealed class TestFeatures : IFeatures
    {
        private readonly bool _showAllTemplates;
        private readonly bool _singleFileAppHostEnabled;
 
        public TestFeatures(bool showAllTemplates = false, bool singleFileAppHostEnabled = false)
        {
            _showAllTemplates = showAllTemplates;
            _singleFileAppHostEnabled = singleFileAppHostEnabled;
        }
 
        public bool IsFeatureEnabled(string featureFlag, bool defaultValue)
        {
            return featureFlag switch
            {
                "showAllTemplates" => _showAllTemplates,
                "singlefileAppHostEnabled" => _singleFileAppHostEnabled,
                _ => defaultValue
            };
        }
    }
 
    private sealed class TestInteractionService : IInteractionService
    {
        public Task<T> PromptForSelectionAsync<T>(string prompt, IEnumerable<T> choices, Func<T, string> displaySelector, CancellationToken cancellationToken) where T : notnull
            => throw new NotImplementedException();
 
        public Task<IReadOnlyList<T>> PromptForSelectionsAsync<T>(string promptText, IEnumerable<T> choices, Func<T, string> choiceFormatter, CancellationToken cancellationToken = default) where T : notnull
            => throw new NotImplementedException();
 
        public Task<string> PromptForStringAsync(string promptText, string? defaultValue = null, Func<string, ValidationResult>? validator = null, bool isSecret = false, bool required = false, CancellationToken cancellationToken = default)
            => throw new NotImplementedException();
 
        public Task<bool> ConfirmAsync(string prompt, bool defaultAnswer, CancellationToken cancellationToken)
            => throw new NotImplementedException();
 
        public Task<TResult> ShowStatusAsync<TResult>(string message, Func<Task<TResult>> work)
            => throw new NotImplementedException();
 
        public Task ShowStatusAsync(string message, Func<Task> work)
            => throw new NotImplementedException();
 
        public void ShowStatus(string message, Action work)
            => throw new NotImplementedException();
 
        public void DisplaySuccess(string message) { }
        public void DisplayError(string message) { }
        public void DisplayMessage(string emoji, string message) { }
        public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) { }
        public void DisplayCancellationMessage() { }
        public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion) => 0;
        public void DisplayPlainText(string text) { }
        public void DisplayMarkdown(string markdown) { }
        public void DisplaySubtleMessage(string message) { }
        public void DisplayEmptyLine() { }
        public void DisplayVersionUpdateNotification(string message) { }
        public void WriteConsoleLog(string message, int? resourceHashCode, string? resourceName, bool isError) { }
    }
 
    private sealed class TestDotNetCliRunner : IDotNetCliRunner
    {
        public Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, FileInfo? nugetConfigFile, string? nugetSource, bool force, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
            => throw new NotImplementedException();
 
        public Task<int> NewProjectAsync(string templateName, string projectName, string outputPath, string[] extraArgs, DotNetCliRunnerInvocationOptions? options, CancellationToken cancellationToken)
            => throw new NotImplementedException();
 
        public Task<int> BuildAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
            => throw new NotImplementedException();
 
        public Task<int> AddPackageAsync(FileInfo projectFile, string packageName, string version, string? packageSourceUrl, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
            => throw new NotImplementedException();
 
        public Task<int> AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
            => throw new NotImplementedException();
 
        public Task<(int ExitCode, NuGetPackageCli[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
            => throw new NotImplementedException();
 
        public Task<(int ExitCode, bool IsAspireHost, string? AspireHostingVersion)> GetAppHostInformationAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
            => throw new NotImplementedException();
 
        public Task<(int ExitCode, JsonDocument? Output)> GetProjectItemsAndPropertiesAsync(FileInfo projectFile, string[] items, string[] properties, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
            => throw new NotImplementedException();
 
        public Task<int> RunAsync(FileInfo projectFile, bool watch, bool noBuild, string[] args, IDictionary<string, string>? env, TaskCompletionSource<IAppHostBackchannel>? backchannelCompletionSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
            => throw new NotImplementedException();
 
        public Task<int> CheckHttpCertificateAsync(DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
            => throw new NotImplementedException();
 
        public Task<int> TrustHttpCertificateAsync(DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
            => throw new NotImplementedException();
 
        public Task<(int ExitCode, string[] ConfigPaths)> GetNuGetConfigPathsAsync(DirectoryInfo workingDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
            => throw new NotImplementedException();
 
        public Task<(int ExitCode, IReadOnlyList<FileInfo> Projects)> GetSolutionProjectsAsync(FileInfo solutionFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
        {
            throw new NotImplementedException();
        }
 
        public Task<int> AddProjectReferenceAsync(FileInfo projectFile, FileInfo referencedProject, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
        {
            throw new NotImplementedException();
        }
    }
 
    private sealed class TestCertificateService : ICertificateService
    {
        public Task EnsureCertificatesTrustedAsync(IDotNetCliRunner runner, CancellationToken cancellationToken)
            => Task.CompletedTask;
    }
 
    private sealed class TestPackagingService : IPackagingService
    {
        public Task<IEnumerable<PackageChannel>> GetChannelsAsync(CancellationToken cancellationToken)
            => throw new NotImplementedException();
    }
 
    private sealed class TestNewCommandPrompter : INewCommandPrompter
    {
        public Task<string> PromptForProjectNameAsync(string defaultName, CancellationToken cancellationToken)
            => throw new NotImplementedException();
 
        public Task<string> PromptForOutputPath(string defaultPath, CancellationToken cancellationToken)
            => throw new NotImplementedException();
 
        public Task<(Aspire.Shared.NuGetPackageCli Package, PackageChannel Channel)> PromptForTemplatesVersionAsync(IEnumerable<(Aspire.Shared.NuGetPackageCli Package, PackageChannel Channel)> packages, CancellationToken cancellationToken)
            => throw new NotImplementedException();
 
        public Task<ITemplate> PromptForTemplateAsync(ITemplate[] templates, CancellationToken cancellationToken)
            => throw new NotImplementedException();
    }
}