diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index 45003f16469..b3195378572 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -18,19 +18,22 @@ internal sealed class AddCommand : BaseCommand private readonly INuGetPackageCache _nuGetPackageCache; private readonly IInteractionService _interactionService; private readonly IProjectLocator _projectLocator; + private readonly IAddCommandPrompter _prompter; - public AddCommand(IDotNetCliRunner runner, INuGetPackageCache nuGetPackageCache, IInteractionService interactionService, IProjectLocator projectLocator) + public AddCommand(IDotNetCliRunner runner, INuGetPackageCache nuGetPackageCache, IInteractionService interactionService, IProjectLocator projectLocator, IAddCommandPrompter prompter) : base("add", "Add an integration to the Aspire project.") { ArgumentNullException.ThrowIfNull(runner); ArgumentNullException.ThrowIfNull(nuGetPackageCache); ArgumentNullException.ThrowIfNull(interactionService); ArgumentNullException.ThrowIfNull(projectLocator); - + ArgumentNullException.ThrowIfNull(prompter); + _runner = runner; _nuGetPackageCache = nuGetPackageCache; _interactionService = interactionService; _projectLocator = projectLocator; + _prompter = prompter; var integrationArgument = new Argument("integration"); integrationArgument.Description = "The name of the integration to add (e.g. redis, postgres)."; @@ -170,23 +173,11 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell { var distinctPackages = possiblePackages.DistinctBy(p => p.Package.Id); - var packagePrompt = new SelectionPrompt<(string FriendlyName, NuGetPackage Package)>() - .Title("Select an integration to add:") - .UseConverter(PackageNameWithFriendlyNameIfAvailable) - .PageSize(10) - .EnableSearch() - .HighlightStyle(Style.Parse("darkmagenta")) - .AddChoices(distinctPackages); - // If there is only one package, we can skip the prompt and just use it. var selectedPackage = distinctPackages.Count() switch { 1 => distinctPackages.First(), - > 1 => await _interactionService.PromptForSelectionAsync( - "Select an integration to add:", - distinctPackages, - PackageNameWithFriendlyNameIfAvailable, - cancellationToken), + > 1 => await _prompter.PromptForIntegrationAsync(distinctPackages, cancellationToken), _ => throw new InvalidOperationException("Unexpected number of packages found.") }; @@ -201,25 +192,9 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } // ... otherwise we had better prompt. - var version = await _interactionService.PromptForSelectionAsync( - $"Select a version of the {selectedPackage.Package.Id}:", - packageVersions, - p => p.Package.Version, - cancellationToken); + var version = await _prompter.PromptForIntegrationVersionAsync(packageVersions, cancellationToken); return version; - - static string PackageNameWithFriendlyNameIfAvailable((string FriendlyName, NuGetPackage Package) packageWithFriendlyName) - { - if (packageWithFriendlyName.FriendlyName is { } friendlyName) - { - return $"[bold]{friendlyName}[/] ({packageWithFriendlyName.Package.Id})"; - } - else - { - return packageWithFriendlyName.Package.Id; - } - } } private static (string FriendlyName, NuGetPackage Package) GenerateFriendlyName(NuGetPackage package) @@ -244,3 +219,45 @@ private static (string FriendlyName, NuGetPackage Package) GenerateFriendlyName( return (shortNameBuilder.ToString(), package); } } + +internal interface IAddCommandPrompter +{ + Task<(string FriendlyName, NuGetPackage Package)> PromptForIntegrationAsync(IEnumerable<(string FriendlyName, NuGetPackage Package)> packages, CancellationToken cancellationToken); + Task<(string FriendlyName, NuGetPackage Package)> PromptForIntegrationVersionAsync(IEnumerable<(string FriendlyName, NuGetPackage Package)> packages, CancellationToken cancellationToken); +} + +internal class AddCommandPrompter(IInteractionService interactionService) : IAddCommandPrompter +{ + public virtual async Task<(string FriendlyName, NuGetPackage Package)> PromptForIntegrationVersionAsync(IEnumerable<(string FriendlyName, NuGetPackage Package)> packages, CancellationToken cancellationToken) + { + var selectedPackage = packages.First(); + var version = await interactionService.PromptForSelectionAsync( + $"Select a version of the {selectedPackage.Package.Id}:", + packages, + p => p.Package.Version, + cancellationToken); + return version; + } + + public virtual async Task<(string FriendlyName, NuGetPackage Package)> PromptForIntegrationAsync(IEnumerable<(string FriendlyName, NuGetPackage Package)> packages, CancellationToken cancellationToken) + { + var selectedIntegration = await interactionService.PromptForSelectionAsync( + "Select an integration to add:", + packages, + PackageNameWithFriendlyNameIfAvailable, + cancellationToken); + return selectedIntegration; + } + + private static string PackageNameWithFriendlyNameIfAvailable((string FriendlyName, NuGetPackage Package) packageWithFriendlyName) + { + if (packageWithFriendlyName.FriendlyName is { } friendlyName) + { + return $"[bold]{friendlyName}[/] ({packageWithFriendlyName.Package.Id})"; + } + else + { + return packageWithFriendlyName.Package.Id; + } + } +} diff --git a/src/Aspire.Cli/Interaction/InteractionService.cs b/src/Aspire.Cli/Interaction/InteractionService.cs index 92fb2c235a2..4e1b56f28b6 100644 --- a/src/Aspire.Cli/Interaction/InteractionService.cs +++ b/src/Aspire.Cli/Interaction/InteractionService.cs @@ -9,9 +9,21 @@ namespace Aspire.Cli.Interaction; internal class InteractionService : IInteractionService { + private readonly IAnsiConsole _ansiConsole; + + public InteractionService() : this(AnsiConsole.Console) + { + } + + public InteractionService(IAnsiConsole ansiConsole) + { + ArgumentNullException.ThrowIfNull(ansiConsole); + _ansiConsole = ansiConsole; + } + public async Task ShowStatusAsync(string statusText, Func> action) { - return await AnsiConsole.Status() + return await _ansiConsole.Status() .Spinner(Spinner.Known.Dots3) .SpinnerStyle(Style.Parse("purple")) .StartAsync(statusText, (context) => action()); @@ -19,7 +31,7 @@ public async Task ShowStatusAsync(string statusText, Func> action) public void ShowStatus(string statusText, Action action) { - AnsiConsole.Status() + _ansiConsole.Status() .Spinner(Spinner.Known.Dots3) .SpinnerStyle(Style.Parse("purple")) .Start(statusText, (context) => action()); @@ -40,8 +52,8 @@ public async Task PromptForStringAsync(string promptText, string? defaul { prompt.Validate(validator); } - - return await AnsiConsole.PromptAsync(prompt, cancellationToken); + + return await _ansiConsole.PromptAsync(prompt, cancellationToken); } public async Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull @@ -58,7 +70,7 @@ public async Task PromptForSelectionAsync(string promptText, IEnumerable(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddTransient(); diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index 3bb7979050c..58327402f87 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -2,18 +2,21 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.Commands; +using Aspire.Cli.Interaction; +using Aspire.Cli.Projects; +using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.DependencyInjection; using Xunit; namespace Aspire.Cli.Tests.Commands; -public class AddCommandTests +public class AddCommandTests(ITestOutputHelper outputHelper) { [Fact] public async Task AddCommandWithHelpArgumentReturnsZero() { - var services = CliTestHelper.CreateServiceCollection(); + var services = CliTestHelper.CreateServiceCollection(outputHelper); var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); @@ -22,4 +25,345 @@ public async Task AddCommandWithHelpArgumentReturnsZero() var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); Assert.Equal(0, exitCode); } -} \ No newline at end of file + + [Fact] + public async Task AddCommandInteractiveFlowSmokeTest() + { + var services = CliTestHelper.CreateServiceCollection(outputHelper, options => { + + options.AddCommandPrompterFactory = (sp) => + { + var interactionService = sp.GetRequiredService(); + return new TestAddCommandPrompter(interactionService); + }; + + options.ProjectLocatorFactory = _ => new FakeProjectLocator(); + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner(); + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, cancellationToken) => + { + var dockerPackage = new NuGetPackage() + { + Id = "Aspire.Hosting.Docker", + Source = "nuget", + Version = "9.2.0" + }; + + var redisPackage = new NuGetPackage() + { + Id = "Aspire.Hosting.Redis", + Source = "nuget", + Version = "9.2.0" + }; + + var azureRedisPackage = new NuGetPackage() + { + Id = "Aspire.Hosting.Azure.Redis", + Source = "nuget", + Version = "9.2.0" + }; + + return ( + 0, // Exit code. + new NuGetPackage[] { dockerPackage, redisPackage, azureRedisPackage } // + ); + }; + + runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, cancellationToken) => + { + // Simulate adding the package. + return 0; // Success. + }; + + return runner; + }; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("add"); + + var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task AddCommandDoesNotPromptForIntegrationArgumentIfSpecifiedOnCommandLine() + { + var promptedForIntegrationPackages = false; + + var services = CliTestHelper.CreateServiceCollection(outputHelper, options => { + + options.AddCommandPrompterFactory = (sp) => + { + var interactionService = sp.GetRequiredService(); + var prompter = new TestAddCommandPrompter(interactionService); + + prompter.PromptForIntegrationCallback = (packages) => + { + promptedForIntegrationPackages = true; + throw new InvalidOperationException("Should not have been prompted for integration packages."); + }; + + return prompter; + }; + + options.ProjectLocatorFactory = _ => new FakeProjectLocator(); + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner(); + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, cancellationToken) => + { + var dockerPackage = new NuGetPackage() + { + Id = "Aspire.Hosting.Docker", + Source = "nuget", + Version = "9.2.0" + }; + + var redisPackage = new NuGetPackage() + { + Id = "Aspire.Hosting.Redis", + Source = "nuget", + Version = "9.2.0" + }; + + var azureRedisPackage = new NuGetPackage() + { + Id = "Aspire.Hosting.Azure.Redis", + Source = "nuget", + Version = "9.2.0" + }; + + return ( + 0, // Exit code. + new NuGetPackage[] { dockerPackage, redisPackage, azureRedisPackage } // + ); + }; + + runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, cancellationToken) => + { + // Simulate adding the package. + return 0; // Success. + }; + + return runner; + }; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("add docker"); + + var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + Assert.Equal(0, exitCode); + Assert.False(promptedForIntegrationPackages); + } + + [Fact] + public async Task AddCommandDoesNotPromptForVersionIfSpecifiedOnCommandLine() + { + var promptedForIntegrationPackages = false; + var promptedForVersion = false; + + var services = CliTestHelper.CreateServiceCollection(outputHelper, options => { + + options.AddCommandPrompterFactory = (sp) => + { + var interactionService = sp.GetRequiredService(); + var prompter = new TestAddCommandPrompter(interactionService); + + prompter.PromptForIntegrationCallback = (packages) => + { + promptedForIntegrationPackages = true; + throw new InvalidOperationException("Should not have been prompted for integration packages."); + }; + + prompter.PromptForIntegrationVersionCallback = (packages) => + { + promptedForVersion = true; + throw new InvalidOperationException("Should not have been prompted for integration version."); + }; + + return prompter; + }; + + options.ProjectLocatorFactory = _ => new FakeProjectLocator(); + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner(); + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, cancellationToken) => + { + var dockerPackage = new NuGetPackage() + { + Id = "Aspire.Hosting.Docker", + Source = "nuget", + Version = "9.2.0" + }; + + var redisPackage = new NuGetPackage() + { + Id = "Aspire.Hosting.Redis", + Source = "nuget", + Version = "9.2.0" + }; + + var azureRedisPackage = new NuGetPackage() + { + Id = "Aspire.Hosting.Azure.Redis", + Source = "nuget", + Version = "9.2.0" + }; + + return ( + 0, // Exit code. + new NuGetPackage[] { dockerPackage, redisPackage, azureRedisPackage } // + ); + }; + + runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, cancellationToken) => + { + // Simulate adding the package. + return 0; // Success. + }; + + return runner; + }; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("add docker --version 9.2.0"); + + var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + Assert.Equal(0, exitCode); + Assert.False(promptedForIntegrationPackages); + Assert.False(promptedForVersion); + } + + [Fact] + public async Task AddCommandPromptsForDisambiguation() + { + IEnumerable<(string FriendlyName, NuGetPackage Package)>? promptedPackages = null; + string? addedPackageName = null; + string? addedPackageVersion = null; + + var services = CliTestHelper.CreateServiceCollection(outputHelper, options => { + + options.AddCommandPrompterFactory = (sp) => + { + var interactionService = sp.GetRequiredService(); + var prompter = new TestAddCommandPrompter(interactionService); + + prompter.PromptForIntegrationCallback = (packages) => + { + promptedPackages = packages; + return packages.Single(p => p.Package.Id == "Aspire.Hosting.Redis"); + }; + + return prompter; + }; + + options.ProjectLocatorFactory = _ => new FakeProjectLocator(); + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner(); + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, cancellationToken) => + { + var dockerPackage = new NuGetPackage() + { + Id = "Aspire.Hosting.Docker", + Source = "nuget", + Version = "9.2.0" + }; + + var redisPackage = new NuGetPackage() + { + Id = "Aspire.Hosting.Redis", + Source = "nuget", + Version = "9.2.0" + }; + + var azureRedisPackage = new NuGetPackage() + { + Id = "Aspire.Hosting.Azure.Redis", + Source = "nuget", + Version = "9.2.0" + }; + + return ( + 0, // Exit code. + new NuGetPackage[] { dockerPackage, redisPackage, azureRedisPackage } // + ); + }; + + runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, cancellationToken) => + { + addedPackageName = packageName; + addedPackageVersion = packageVersion; + return 0; // Success. + }; + + return runner; + }; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("add red"); + + var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + Assert.Equal(0, exitCode); + Assert.Collection( + promptedPackages!, + p => Assert.Equal("Aspire.Hosting.Redis", p.Package.Id), + p => Assert.Equal("Aspire.Hosting.Azure.Redis", p.Package.Id) + ); + Assert.Equal("Aspire.Hosting.Redis", addedPackageName); + Assert.Equal("9.2.0", addedPackageVersion); + } + +} + +internal sealed class FakeProjectLocator : IProjectLocator +{ + public FileInfo? UseOrFindAppHostProjectFile(FileInfo? projectFile) + { + if (projectFile != null) + { + return projectFile; + } + + var fakeProjectFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString(), "AppHost.csproj"); + return new FileInfo(fakeProjectFilePath); + } +} + +internal sealed class TestAddCommandPrompter(IInteractionService interactionService) : AddCommandPrompter(interactionService) +{ + public Func, (string FriendlyName, NuGetPackage Package)>? PromptForIntegrationCallback { get; set; } + public Func, (string FriendlyName, NuGetPackage Package)>? PromptForIntegrationVersionCallback { get; set; } + + public override Task<(string FriendlyName, NuGetPackage Package)> PromptForIntegrationAsync(IEnumerable<(string FriendlyName, NuGetPackage Package)> packages, CancellationToken cancellationToken) + { + return PromptForIntegrationCallback switch + { + { } callback => Task.FromResult(callback(packages)), + _ => Task.FromResult(packages.First()) // If no callback is provided just accept the first package. + }; + } + + public override Task<(string FriendlyName, NuGetPackage Package)> PromptForIntegrationVersionAsync(IEnumerable<(string FriendlyName, NuGetPackage Package)> packages, CancellationToken cancellationToken) + { + return PromptForIntegrationVersionCallback switch + { + { } callback => Task.FromResult(callback(packages)), + _ => Task.FromResult(packages.First()) // If no callback is provided just accept the first package. + }; + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 217117ff55a..1e987bae0a0 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -1,23 +1,21 @@ // 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.Commands; using Aspire.Cli.Interaction; +using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; -using Aspire.Cli.Utils; using Microsoft.Extensions.DependencyInjection; using Xunit; namespace Aspire.Cli.Tests.Commands; -public class NewCommandTests +public class NewCommandTests(ITestOutputHelper outputHelper) { [Fact] public async Task NewCommandWithHelpArgumentReturnsZero() { - var services = CliTestHelper.CreateServiceCollection(); + var services = CliTestHelper.CreateServiceCollection(outputHelper); var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); @@ -30,7 +28,7 @@ public async Task NewCommandWithHelpArgumentReturnsZero() [Fact] public async Task NewCommandInteractiveFlowSmokeTest() { - var services = CliTestHelper.CreateServiceCollection(options => { + var services = CliTestHelper.CreateServiceCollection(outputHelper, options => { // Set of options that we'll give when prompted. options.NewCommandPrompterFactory = (sp) => @@ -72,9 +70,9 @@ public async Task NewCommandInteractiveFlowSmokeTest() [Fact] public async Task NewCommandDoesNotPromptForProjectNameIfSpecifiedOnCommandLine() { - bool promptedForName = false; + var promptedForName = false; - var services = CliTestHelper.CreateServiceCollection(options => { + var services = CliTestHelper.CreateServiceCollection(outputHelper, options => { // Set of options that we'll give when prompted. options.NewCommandPrompterFactory = (sp) => @@ -127,7 +125,7 @@ public async Task NewCommandDoesNotPromptForOutputPathIfSpecifiedOnCommandLine() { bool promptedForPath = false; - var services = CliTestHelper.CreateServiceCollection(options => { + var services = CliTestHelper.CreateServiceCollection(outputHelper, options => { // Set of options that we'll give when prompted. options.NewCommandPrompterFactory = (sp) => @@ -180,7 +178,7 @@ public async Task NewCommandDoesNotPromptForTemplateIfSpecifiedOnCommandLine() { bool promptedForTemplate = false; - var services = CliTestHelper.CreateServiceCollection(options => { + var services = CliTestHelper.CreateServiceCollection(outputHelper, options => { // Set of options that we'll give when prompted. options.NewCommandPrompterFactory = (sp) => @@ -233,7 +231,7 @@ public async Task NewCommandDoesNotPromptForTemplateVersionIfSpecifiedOnCommandL { bool promptedForTemplateVersion = false; - var services = CliTestHelper.CreateServiceCollection(options => { + var services = CliTestHelper.CreateServiceCollection(outputHelper, options => { // Set of options that we'll give when prompted. options.NewCommandPrompterFactory = (sp) => @@ -325,89 +323,3 @@ public override Task PromptForTemplatesVersionAsync(IEnumerable? AddPackageAsyncCallback { get; set; } - public Func? BuildAsyncCallback { get; set; } - public Func? CheckHttpCertificateAsyncCallback { get; set; } - public Func? GetAppHostInformationAsyncCallback { get; set; } - public Func? GetProjectItemsAndPropertiesAsyncCallback { get; set; } - public Func? InstallTemplateAsyncCallback { get; set; } - public Func? NewProjectAsyncCallback { get; set; } - public Func?, TaskCompletionSource?, CancellationToken, int>? RunAsyncCallback { get; set; } - public Func? SearchPackagesAsyncCallback { get; set; } - public Func? TrustHttpCertificateAsyncCallback { get; set; } - - public Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, CancellationToken cancellationToken) - { - return AddPackageAsyncCallback != null - ? Task.FromResult(AddPackageAsyncCallback(projectFilePath, packageName, packageVersion, cancellationToken)) - : throw new NotImplementedException(); - } - - public Task BuildAsync(FileInfo projectFilePath, CancellationToken cancellationToken) - { - return BuildAsyncCallback != null - ? Task.FromResult(BuildAsyncCallback(projectFilePath, cancellationToken)) - : throw new NotImplementedException(); - } - - public Task CheckHttpCertificateAsync(CancellationToken cancellationToken) - { - return CheckHttpCertificateAsyncCallback != null - ? Task.FromResult(CheckHttpCertificateAsyncCallback(cancellationToken)) - : Task.FromResult(0); // Return success if not overridden. - } - - public Task<(int ExitCode, bool IsAspireHost, string? AspireHostingSdkVersion)> GetAppHostInformationAsync(FileInfo projectFile, CancellationToken cancellationToken) - { - var informationalVersion = VersionHelper.GetDefaultTemplateVersion(); - - return GetAppHostInformationAsyncCallback != null - ? Task.FromResult(GetAppHostInformationAsyncCallback(projectFile, cancellationToken)) - : Task.FromResult<(int, bool, string?)>((0, true, informationalVersion)); - } - - public Task<(int ExitCode, JsonDocument? Output)> GetProjectItemsAndPropertiesAsync(FileInfo projectFile, string[] items, string[] properties, CancellationToken cancellationToken) - { - return GetProjectItemsAndPropertiesAsyncCallback != null - ? Task.FromResult(GetProjectItemsAndPropertiesAsyncCallback(projectFile, items, properties, cancellationToken)) - : throw new NotImplementedException(); - } - - public Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, string? nugetSource, bool force, CancellationToken cancellationToken) - { - return InstallTemplateAsyncCallback != null - ? Task.FromResult(InstallTemplateAsyncCallback(packageName, version, nugetSource, force, cancellationToken)) - : Task.FromResult<(int, string?)>((0, version)); // If not overridden, just return success for the version specified. - } - - public Task NewProjectAsync(string templateName, string name, string outputPath, CancellationToken cancellationToken) - { - return NewProjectAsyncCallback != null - ? Task.FromResult(NewProjectAsyncCallback(templateName, name, outputPath, cancellationToken)) - : Task.FromResult(0); // If not overridden, just return success. - } - - public Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, string[] args, IDictionary? env, TaskCompletionSource? backchannelCompletionSource, CancellationToken cancellationToken) - { - return RunAsyncCallback != null - ? Task.FromResult(RunAsyncCallback(projectFile, watch, noBuild, args, env, backchannelCompletionSource, cancellationToken)) - : throw new NotImplementedException(); - } - - public Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, string? nugetSource, CancellationToken cancellationToken) - { - return SearchPackagesAsyncCallback != null - ? Task.FromResult(SearchPackagesAsyncCallback(workingDirectory, query, prerelease, take, skip, nugetSource, cancellationToken)) - : throw new NotImplementedException(); - } - - public Task TrustHttpCertificateAsync(CancellationToken cancellationToken) - { - return TrustHttpCertificateAsyncCallback != null - ? Task.FromResult(TrustHttpCertificateAsyncCallback(cancellationToken)) - : throw new NotImplementedException(); - } -} diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandTests.cs index a1fc1f1d905..709abd64ea1 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandTests.cs @@ -8,12 +8,12 @@ namespace Aspire.Cli.Tests.Commands; -public class PublishCommandTests +public class PublishCommandTests(ITestOutputHelper outputHelper) { [Fact] public async Task PublishCommandWithHelpArgumentReturnsZero() { - var services = CliTestHelper.CreateServiceCollection(); + var services = CliTestHelper.CreateServiceCollection(outputHelper); var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); @@ -22,4 +22,4 @@ public async Task PublishCommandWithHelpArgumentReturnsZero() var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); Assert.Equal(0, exitCode); } -} \ No newline at end of file +} diff --git a/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs index 909891769a7..386ed1fc5d8 100644 --- a/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs @@ -8,12 +8,12 @@ namespace Aspire.Cli.Tests.Commands; -public class RootCommandTests +public class RootCommandTests(ITestOutputHelper outputHelper) { [Fact] public async Task RootCommandWithHelpArgumentReturnsZero() { - var services = CliTestHelper.CreateServiceCollection(); + var services = CliTestHelper.CreateServiceCollection(outputHelper); var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); @@ -22,4 +22,4 @@ public async Task RootCommandWithHelpArgumentReturnsZero() var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); Assert.Equal(0, exitCode); } -} \ No newline at end of file +} diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index b91e3caf4ff..6d589497e22 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -8,12 +8,12 @@ namespace Aspire.Cli.Tests.Commands; -public class RunCommandTests +public class RunCommandTests(ITestOutputHelper outputHelper) { [Fact] public async Task RunCommandWithHelpArgumentReturnsZero() { - var services = CliTestHelper.CreateServiceCollection(); + var services = CliTestHelper.CreateServiceCollection(outputHelper); var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); @@ -22,4 +22,4 @@ public async Task RunCommandWithHelpArgumentReturnsZero() var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); Assert.Equal(0, exitCode); } -} \ No newline at end of file +} diff --git a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs new file mode 100644 index 00000000000..dd6dac5c39d --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs @@ -0,0 +1,94 @@ +// 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.Utils; + +namespace Aspire.Cli.Tests.TestServices; + +internal sealed class TestDotNetCliRunner : IDotNetCliRunner +{ + public Func? AddPackageAsyncCallback { get; set; } + public Func? BuildAsyncCallback { get; set; } + public Func? CheckHttpCertificateAsyncCallback { get; set; } + public Func? GetAppHostInformationAsyncCallback { get; set; } + public Func? GetProjectItemsAndPropertiesAsyncCallback { get; set; } + public Func? InstallTemplateAsyncCallback { get; set; } + public Func? NewProjectAsyncCallback { get; set; } + public Func?, TaskCompletionSource?, CancellationToken, int>? RunAsyncCallback { get; set; } + public Func? SearchPackagesAsyncCallback { get; set; } + public Func? TrustHttpCertificateAsyncCallback { get; set; } + + public Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, CancellationToken cancellationToken) + { + return AddPackageAsyncCallback != null + ? Task.FromResult(AddPackageAsyncCallback(projectFilePath, packageName, packageVersion, cancellationToken)) + : throw new NotImplementedException(); + } + + public Task BuildAsync(FileInfo projectFilePath, CancellationToken cancellationToken) + { + return BuildAsyncCallback != null + ? Task.FromResult(BuildAsyncCallback(projectFilePath, cancellationToken)) + : throw new NotImplementedException(); + } + + public Task CheckHttpCertificateAsync(CancellationToken cancellationToken) + { + return CheckHttpCertificateAsyncCallback != null + ? Task.FromResult(CheckHttpCertificateAsyncCallback(cancellationToken)) + : Task.FromResult(0); // Return success if not overridden. + } + + public Task<(int ExitCode, bool IsAspireHost, string? AspireHostingSdkVersion)> GetAppHostInformationAsync(FileInfo projectFile, CancellationToken cancellationToken) + { + var informationalVersion = VersionHelper.GetDefaultTemplateVersion(); + + return GetAppHostInformationAsyncCallback != null + ? Task.FromResult(GetAppHostInformationAsyncCallback(projectFile, cancellationToken)) + : Task.FromResult<(int, bool, string?)>((0, true, informationalVersion)); + } + + public Task<(int ExitCode, JsonDocument? Output)> GetProjectItemsAndPropertiesAsync(FileInfo projectFile, string[] items, string[] properties, CancellationToken cancellationToken) + { + return GetProjectItemsAndPropertiesAsyncCallback != null + ? Task.FromResult(GetProjectItemsAndPropertiesAsyncCallback(projectFile, items, properties, cancellationToken)) + : throw new NotImplementedException(); + } + + public Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, string? nugetSource, bool force, CancellationToken cancellationToken) + { + return InstallTemplateAsyncCallback != null + ? Task.FromResult(InstallTemplateAsyncCallback(packageName, version, nugetSource, force, cancellationToken)) + : Task.FromResult<(int, string?)>((0, version)); // If not overridden, just return success for the version specified. + } + + public Task NewProjectAsync(string templateName, string name, string outputPath, CancellationToken cancellationToken) + { + return NewProjectAsyncCallback != null + ? Task.FromResult(NewProjectAsyncCallback(templateName, name, outputPath, cancellationToken)) + : Task.FromResult(0); // If not overridden, just return success. + } + + public Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, string[] args, IDictionary? env, TaskCompletionSource? backchannelCompletionSource, CancellationToken cancellationToken) + { + return RunAsyncCallback != null + ? Task.FromResult(RunAsyncCallback(projectFile, watch, noBuild, args, env, backchannelCompletionSource, cancellationToken)) + : throw new NotImplementedException(); + } + + public Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, string? nugetSource, CancellationToken cancellationToken) + { + return SearchPackagesAsyncCallback != null + ? Task.FromResult(SearchPackagesAsyncCallback(workingDirectory, query, prerelease, take, skip, nugetSource, cancellationToken)) + : throw new NotImplementedException(); + } + + public Task TrustHttpCertificateAsync(CancellationToken cancellationToken) + { + return TrustHttpCertificateAsyncCallback != null + ? Task.FromResult(TrustHttpCertificateAsyncCallback(cancellationToken)) + : throw new NotImplementedException(); + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 739470f3ada..c2bdf42de8b 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -1,20 +1,23 @@ // 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; using Aspire.Cli.Certificates; using Aspire.Cli.Commands; using Aspire.Cli.Interaction; using Aspire.Cli.Projects; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Spectre.Console; +using Xunit; namespace Aspire.Cli.Tests.Utils; internal static class CliTestHelper { - public static IServiceCollection CreateServiceCollection(Action? configure = null) + public static IServiceCollection CreateServiceCollection(ITestOutputHelper outputHelper, Action? configure = null) { - var options = new CliServiceCollectionTestOptions(); + var options = new CliServiceCollectionTestOptions(outputHelper); configure?.Invoke(options); var services = new ServiceCollection(); @@ -24,6 +27,7 @@ public static IServiceCollection CreateServiceCollection(Action(); @@ -36,7 +40,7 @@ public static IServiceCollection CreateServiceCollection(Action NewCommandPrompterFactory { get; set; } = (IServiceProvider serviceProvider) => { @@ -44,13 +48,26 @@ internal sealed class CliServiceCollectionTestOptions return new NewCommandPrompter(interactionService); }; + public Func AddCommandPrompterFactory { get; set; } = (IServiceProvider serviceProvider) => + { + var interactionService = serviceProvider.GetRequiredService(); + return new AddCommandPrompter(interactionService); + }; + public Func ProjectLocatorFactory { get; set; } = (IServiceProvider serviceProvider) => { var logger = serviceProvider.GetRequiredService>(); return new ProjectLocator(logger, Directory.GetCurrentDirectory()); }; public Func InteractiveServiceFactory { get; set; } = (IServiceProvider serviceProvider) => { - return new InteractionService(); + AnsiConsoleSettings settings = new AnsiConsoleSettings() + { + Ansi = AnsiSupport.Yes, + Interactive = InteractionSupport.Yes, + ColorSystem = ColorSystemSupport.Standard, + Out = new AnsiConsoleOutput(new TestOutputTextWriter(outputHelper)) + }; + return new InteractionService(AnsiConsole.Create(settings)); }; public Func CertificateServiceFactory { get; set; } = (IServiceProvider serviceProvider) => { @@ -69,3 +86,19 @@ internal sealed class CliServiceCollectionTestOptions return new NuGetPackageCache(logger, runner); }; } + +internal sealed class TestOutputTextWriter(ITestOutputHelper outputHelper) : TextWriter +{ + public override Encoding Encoding => Encoding.UTF8; + + public override void WriteLine(string? message) + { + outputHelper.WriteLine(message!); + } + + public override void Write(string? message) + { + outputHelper.Write(message!); + } + +}