From 159c96058969a037bf7759958038be4da4471b44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 May 2025 00:46:08 +0000 Subject: [PATCH 1/2] Initial plan for issue From be2048aab7fccc56cac8e48e1c99ac8a04b22f44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 May 2025 01:09:05 +0000 Subject: [PATCH 2/2] Add empty collection check for prompts in InteractionService Co-authored-by: mitchdenny <513398+mitchdenny@users.noreply.github.com> --- src/Aspire.Cli/Commands/AddCommand.cs | 10 +++ src/Aspire.Cli/Commands/NewCommand.cs | 10 +++ .../Interaction/EmptyChoicesException.cs | 8 +++ .../Interaction/InteractionService.cs | 6 ++ .../Commands/AddCommandTests.cs | 36 ++++++++++ .../Commands/NewCommandTests.cs | 34 ++++++++++ .../Interaction/InteractionServiceTests.cs | 23 +++++++ .../TestServices/TestInteractionService.cs | 68 +++++++++++++++++++ 8 files changed, 195 insertions(+) create mode 100644 src/Aspire.Cli/Interaction/EmptyChoicesException.cs create mode 100644 tests/Aspire.Cli.Tests/Interaction/InteractionServiceTests.cs create mode 100644 tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index b530c14b279..738bc16312a 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -88,6 +88,11 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell () => _nuGetPackageCache.GetIntegrationPackagesAsync(effectiveAppHostProjectFile.Directory!, prerelease, source, cancellationToken) ); + if (!packages.Any()) + { + throw new EmptyChoicesException("No integration packages were found. Please check your internet connection or NuGet source configuration."); + } + var version = parseResult.GetValue("--version"); var packagesWithShortName = packages.Select(GenerateFriendlyName); @@ -174,6 +179,11 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell _interactionService.DisplayCancellationMessage(); return ExitCodeConstants.FailedToAddPackage; } + catch (EmptyChoicesException ex) + { + _interactionService.DisplayError(ex.Message); + return ExitCodeConstants.FailedToAddPackage; + } catch (Exception ex) { _interactionService.DisplayLines(outputCollector.GetLines()); diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index ea4978b0e58..e54575e9b13 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -126,6 +126,11 @@ private async Task GetProjectTemplatesVersionAsync(ParseResult parseResu () => _nuGetPackageCache.GetTemplatePackagesAsync(workingDirectory, prerelease, source, cancellationToken) ); + if (!candidatePackages.Any()) + { + throw new EmptyChoicesException("No template versions were found. Please check your internet connection or NuGet source configuration."); + } + var orderedCandidatePackages = candidatePackages.OrderByDescending(p => SemVersion.Parse(p.Version), SemVersion.PrecedenceComparer); var selectedPackage = await _prompter.PromptForTemplatesVersionAsync(orderedCandidatePackages, cancellationToken); return selectedPackage.Version; @@ -209,6 +214,11 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell _interactionService.DisplayError($"An error occurred while trusting the certificates: {ex.Message}"); return ExitCodeConstants.FailedToTrustCertificates; } + catch (EmptyChoicesException ex) + { + _interactionService.DisplayError(ex.Message); + return ExitCodeConstants.FailedToCreateNewProject; + } } } diff --git a/src/Aspire.Cli/Interaction/EmptyChoicesException.cs b/src/Aspire.Cli/Interaction/EmptyChoicesException.cs new file mode 100644 index 00000000000..1df21f71ee9 --- /dev/null +++ b/src/Aspire.Cli/Interaction/EmptyChoicesException.cs @@ -0,0 +1,8 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Interaction; + +internal sealed class EmptyChoicesException(string message) : Exception(message) +{ +} \ No newline at end of file diff --git a/src/Aspire.Cli/Interaction/InteractionService.cs b/src/Aspire.Cli/Interaction/InteractionService.cs index f286ab615ce..687642792c7 100644 --- a/src/Aspire.Cli/Interaction/InteractionService.cs +++ b/src/Aspire.Cli/Interaction/InteractionService.cs @@ -58,6 +58,12 @@ public async Task PromptForSelectionAsync(string promptText, IEnumerable() .Title(promptText) .UseConverter(choiceFormatter) diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index 70cfcdbf6cc..573b5c59ea1 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -477,6 +477,42 @@ public async Task AddCommandPromptsForDisambiguation() Assert.Equal("9.2.0", addedPackageVersion); } + [Fact] + public async Task AddCommand_EmptyPackageList_DisplaysErrorMessage() + { + string? displayedErrorMessage = null; + + var services = CliTestHelper.CreateServiceCollection(outputHelper, options => { + options.InteractionServiceFactory = (sp) => { + var testInteractionService = new TestInteractionService(); + testInteractionService.DisplayErrorCallback = (message) => { + displayedErrorMessage = message; + }; + return testInteractionService; + }; + + options.ProjectLocatorFactory = _ => new TestProjectLocator(); + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner(); + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, options, cancellationToken) => + { + return (0, Array.Empty()); + }; + + 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(ExitCodeConstants.FailedToAddPackage, exitCode); + Assert.Contains("No integration packages were found", displayedErrorMessage); + } } internal sealed class TestAddCommandPrompter(IInteractionService interactionService) : AddCommandPrompter(interactionService) diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 55d34dca9a1..b02dd7564cf 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -589,6 +589,40 @@ public async Task NewCommandDoesNotPromptForTemplateVersionIfSpecifiedOnCommandL Assert.False(promptedForTemplateVersion); } + [Fact] + public async Task NewCommand_EmptyPackageList_DisplaysErrorMessage() + { + string? displayedErrorMessage = null; + + var services = CliTestHelper.CreateServiceCollection(outputHelper, options => { + options.InteractionServiceFactory = (sp) => { + var testInteractionService = new TestInteractionService(); + testInteractionService.DisplayErrorCallback = (message) => { + displayedErrorMessage = message; + }; + return testInteractionService; + }; + + options.DotNetCliRunnerFactory = (sp) => { + var runner = new TestDotNetCliRunner(); + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, options, cancellationToken) => { + return (0, Array.Empty()); + }; + return runner; + }; + }); + + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("new"); + + var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + + Assert.Equal(ExitCodeConstants.FailedToCreateNewProject, exitCode); + Assert.Contains("No template versions were found", displayedErrorMessage); + } + [Fact] public async Task NewCommand_WhenCertificateServiceThrows_ReturnsNonZeroExitCode() { diff --git a/tests/Aspire.Cli.Tests/Interaction/InteractionServiceTests.cs b/tests/Aspire.Cli.Tests/Interaction/InteractionServiceTests.cs new file mode 100644 index 00000000000..3585c8b9963 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Interaction/InteractionServiceTests.cs @@ -0,0 +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 Aspire.Cli.Interaction; +using Spectre.Console; +using Xunit; + +namespace Aspire.Cli.Tests.Interaction; + +public class InteractionServiceTests +{ + [Fact] + public async Task PromptForSelectionAsync_EmptyChoices_ThrowsEmptyChoicesException() + { + // Arrange + var interactionService = new InteractionService(AnsiConsole.Console); + var choices = Array.Empty(); + + // Act & Assert + await Assert.ThrowsAsync(() => + interactionService.PromptForSelectionAsync("Select an item:", choices, x => x, CancellationToken.None)); + } +} \ No newline at end of file diff --git a/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs new file mode 100644 index 00000000000..322aaa3a4fd --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs @@ -0,0 +1,68 @@ +// 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.Backchannel; +using Aspire.Cli.Interaction; +using Spectre.Console; + +namespace Aspire.Cli.Tests.TestServices; + +internal sealed class TestInteractionService : IInteractionService +{ + public Action? DisplayErrorCallback { get; set; } + + public Task ShowStatusAsync(string statusText, Func> action) + { + return action(); + } + + public void ShowStatus(string statusText, Action action) + { + action(); + } + + public Task PromptForStringAsync(string promptText, string? defaultValue = null, Func? validator = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(defaultValue ?? string.Empty); + } + + public Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull + { + if (!choices.Any()) + { + throw new EmptyChoicesException($"No items available for selection: {promptText}"); + } + + return Task.FromResult(choices.First()); + } + + public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingSdkVersion) + { + return 0; + } + + public void DisplayError(string errorMessage) + { + DisplayErrorCallback?.Invoke(errorMessage); + } + + public void DisplayMessage(string emoji, string message) + { + } + + public void DisplaySuccess(string message) + { + } + + public void DisplayDashboardUrls((string BaseUrlWithLoginToken, string? CodespacesUrlWithLoginToken) dashboardUrls) + { + } + + public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) + { + } + + public void DisplayCancellationMessage() + { + } +} \ No newline at end of file