Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/Aspire.Cli/Commands/AddCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ protected override async Task<int> 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<string?>("--version");

var packagesWithShortName = packages.Select(GenerateFriendlyName);
Expand Down Expand Up @@ -174,6 +179,11 @@ protected override async Task<int> 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());
Expand Down
10 changes: 10 additions & 0 deletions src/Aspire.Cli/Commands/NewCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ private async Task<string> 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;
Expand Down Expand Up @@ -209,6 +214,11 @@ protected override async Task<int> 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;
}
}
}

Expand Down
8 changes: 8 additions & 0 deletions src/Aspire.Cli/Interaction/EmptyChoicesException.cs
Original file line number Diff line number Diff line change
@@ -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)
{
}
6 changes: 6 additions & 0 deletions src/Aspire.Cli/Interaction/InteractionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ public async Task<T> PromptForSelectionAsync<T>(string promptText, IEnumerable<T
ArgumentNullException.ThrowIfNull(choices, nameof(choices));
ArgumentNullException.ThrowIfNull(choiceFormatter, nameof(choiceFormatter));

// Check if the choices collection is empty to avoid throwing an InvalidOperationException
if (!choices.Any())
{
throw new EmptyChoicesException($"No items available for selection: {promptText}");
}

var prompt = new SelectionPrompt<T>()
.Title(promptText)
.UseConverter(choiceFormatter)
Expand Down
36 changes: 36 additions & 0 deletions tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<NuGetPackage>());
};

return runner;
};
});
var provider = services.BuildServiceProvider();

var command = provider.GetRequiredService<AddCommand>();
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)
Expand Down
34 changes: 34 additions & 0 deletions tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<NuGetPackage>());
};
return runner;
};
});

var provider = services.BuildServiceProvider();

var command = provider.GetRequiredService<NewCommand>();
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()
{
Expand Down
23 changes: 23 additions & 0 deletions tests/Aspire.Cli.Tests/Interaction/InteractionServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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<string>();

// Act & Assert
await Assert.ThrowsAsync<EmptyChoicesException>(() =>
interactionService.PromptForSelectionAsync("Select an item:", choices, x => x, CancellationToken.None));
}
}
68 changes: 68 additions & 0 deletions tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs
Original file line number Diff line number Diff line change
@@ -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<string>? DisplayErrorCallback { get; set; }

public Task<T> ShowStatusAsync<T>(string statusText, Func<Task<T>> action)
{
return action();
}

public void ShowStatus(string statusText, Action action)
{
action();
}

public Task<string> PromptForStringAsync(string promptText, string? defaultValue = null, Func<string, ValidationResult>? validator = null, CancellationToken cancellationToken = default)
{
return Task.FromResult(defaultValue ?? string.Empty);
}

public Task<T> PromptForSelectionAsync<T>(string promptText, IEnumerable<T> choices, Func<T, string> 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()
{
}
}
Loading