Skip to content
Open
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
40 changes: 25 additions & 15 deletions src/Aspire.Cli/Commands/AddCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -300,39 +300,43 @@ static string FormatVersionLabel((string FriendlyName, NuGetPackage Package, Pac
return selection.Result;
}

// Group the incoming package versions by channel
// Group the incoming package versions by channel and filter to highest version per channel
var byChannel = packages
.GroupBy(p => p.Channel)
.Select(g => new
{
Channel = g.Key,
// Keep only the highest version in each channel
HighestVersion = g.OrderByDescending(p => SemVersion.Parse(p.Package.Version), SemVersion.PrecedenceComparer).First()
})
.ToArray();

var implicitGroup = byChannel.FirstOrDefault(g => g.Key.Type is Packaging.PackageChannelType.Implicit);
var implicitGroup = byChannel.FirstOrDefault(g => g.Channel.Type is Packaging.PackageChannelType.Implicit);
var explicitGroups = byChannel
.Where(g => g.Key.Type is Packaging.PackageChannelType.Explicit)
.Where(g => g.Channel.Type is Packaging.PackageChannelType.Explicit)
.ToArray();

// Build the root menu: implicit channel packages directly, explicit channels as submenus
var rootChoices = new List<(string Label, Func<CancellationToken, Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)>> Action)>();

if (implicitGroup is not null)
{
foreach (var item in implicitGroup)
{
var captured = item;
rootChoices.Add((
Label: FormatVersionLabel(captured),
Action: ct => Task.FromResult(captured)
));
}
var captured = implicitGroup.HighestVersion;
rootChoices.Add((
Label: FormatVersionLabel(captured),
Action: ct => Task.FromResult(captured)
));
}

foreach (var channelGroup in explicitGroups)
{
var channel = channelGroup.Key;
var items = channelGroup.ToArray();
var channel = channelGroup.Channel;
var item = channelGroup.HighestVersion;

rootChoices.Add((
Label: channel.Name,
Action: ct => PromptForChannelPackagesAsync(channel, items, ct)
// For explicit channels, we still show submenu but with only the highest version
Action: ct => PromptForChannelPackagesAsync(channel, new[] { item }, ct)
));
}

Expand All @@ -353,9 +357,15 @@ static string FormatVersionLabel((string FriendlyName, NuGetPackage Package, Pac

public virtual async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> PromptForIntegrationAsync(IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> packages, CancellationToken cancellationToken)
{
// Filter to show only the highest version for each package ID
var filteredPackages = packages
.GroupBy(p => p.Package.Id)
.Select(g => g.OrderByDescending(p => SemVersion.Parse(p.Package.Version), SemVersion.PrecedenceComparer).First())
.ToArray();

var selectedIntegration = await interactionService.PromptForSelectionAsync(
AddCommandStrings.SelectAnIntegrationToAdd,
packages,
filteredPackages,
PackageNameWithFriendlyNameIfAvailable,
cancellationToken);
return selectedIntegration;
Expand Down
164 changes: 164 additions & 0 deletions tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,170 @@ public void GenerateFriendlyName_ProducesExpectedResults(string packageId, strin
Assert.Equal(expectedFriendlyName, result.FriendlyName);
Assert.Equal(package, result.Package);
}

[Fact]
public async Task AddCommandPrompter_FiltersToHighestVersionPerPackageId()
{
// Arrange
List<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)>? displayedPackages = null;

using var workspace = TemporaryWorkspace.Create(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = (sp) =>
{
var mockInteraction = new TestConsoleInteractionService();
mockInteraction.PromptForSelectionCallback = (message, choices, formatter, ct) =>
{
// Capture what the prompter passes to the interaction service
var choicesList = choices.Cast<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)>().ToList();
displayedPackages = choicesList;
return choicesList.First();
};
return mockInteraction;
};
});
var provider = services.BuildServiceProvider();
var interactionService = provider.GetRequiredService<IInteractionService>();

var prompter = new AddCommandPrompter(interactionService);

// Create a fake channel
var fakeCache = new FakeNuGetPackageCache();
var channel = PackageChannel.CreateImplicitChannel(fakeCache);

// Create multiple versions of the same package
var packages = new[]
{
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.0.0", Source = "nuget" }, channel),
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.2.0", Source = "nuget" }, channel),
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.1.0", Source = "nuget" }, channel),
};

// Act
await prompter.PromptForIntegrationAsync(packages, CancellationToken.None);

// Assert - should only show highest version (9.2.0) for the package ID
Assert.NotNull(displayedPackages);
Assert.Single(displayedPackages!);
Assert.Equal("9.2.0", displayedPackages!.First().Package.Version);
}

[Fact]
public async Task AddCommandPrompter_FiltersToHighestVersionPerChannel()
{
// Arrange
List<object>? displayedChoices = null;

using var workspace = TemporaryWorkspace.Create(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = (sp) =>
{
var mockInteraction = new TestConsoleInteractionService();
mockInteraction.PromptForSelectionCallback = (message, choices, formatter, ct) =>
{
// Capture what the prompter passes to the interaction service
var choicesList = choices.Cast<object>().ToList();
displayedChoices = choicesList;
return choicesList.First();
};
return mockInteraction;
};
});
var provider = services.BuildServiceProvider();
var interactionService = provider.GetRequiredService<IInteractionService>();

var prompter = new AddCommandPrompter(interactionService);

// Create a fake channel
var fakeCache = new FakeNuGetPackageCache();
var channel = PackageChannel.CreateImplicitChannel(fakeCache);

// Create multiple versions of the same package from same channel
var packages = new[]
{
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.0.0", Source = "nuget" }, channel),
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.2.0", Source = "nuget" }, channel),
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.1.0", Source = "nuget" }, channel),
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.0.1-preview.1", Source = "nuget" }, channel),
};

// Act
await prompter.PromptForIntegrationVersionAsync(packages, CancellationToken.None);

// Assert - For implicit channel, should only show highest version (9.2.0) directly
// The root menu shows: (string Label, Func<...> Action) tuples
Assert.NotNull(displayedChoices);
Assert.Single(displayedChoices!); // Only one choice for implicit channel
}

[Fact]
public async Task AddCommandPrompter_ShowsHighestVersionPerChannelWhenMultipleChannels()
{
// Arrange
List<object>? displayedChoices = null;

using var workspace = TemporaryWorkspace.Create(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = (sp) =>
{
var mockInteraction = new TestConsoleInteractionService();
mockInteraction.PromptForSelectionCallback = (message, choices, formatter, ct) =>
{
// Capture what the prompter passes to the interaction service
var choicesList = choices.Cast<object>().ToList();
displayedChoices = choicesList;
return choicesList.First();
};
return mockInteraction;
};
});
var provider = services.BuildServiceProvider();
var interactionService = provider.GetRequiredService<IInteractionService>();

var prompter = new AddCommandPrompter(interactionService);

// Create two different channels
var fakeCache = new FakeNuGetPackageCache();
var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache);

var mappings = new[] { new PackageMapping("Aspire*", "https://preview-feed") };
var explicitChannel = PackageChannel.CreateExplicitChannel("preview", PackageChannelQuality.Prerelease, mappings, fakeCache);

// Create packages from different channels with different versions
var packages = new[]
{
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.0.0", Source = "nuget" }, implicitChannel),
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.1.0", Source = "nuget" }, implicitChannel),
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.2.0", Source = "nuget" }, implicitChannel),
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "10.0.0-preview.1", Source = "preview-feed" }, explicitChannel),
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "10.0.0-preview.2", Source = "preview-feed" }, explicitChannel),
};

// Act
await prompter.PromptForIntegrationVersionAsync(packages, CancellationToken.None);

// Assert - should show 2 root choices: one for implicit channel, one submenu for explicit channel
Assert.NotNull(displayedChoices);
Assert.Equal(2, displayedChoices!.Count);
}

private sealed class FakeNuGetPackageCache : Aspire.Cli.NuGet.INuGetPackageCache
{
public Task<IEnumerable<NuGetPackage>> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken)
=> Task.FromResult<IEnumerable<NuGetPackage>>([]);

public Task<IEnumerable<NuGetPackage>> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken)
=> Task.FromResult<IEnumerable<NuGetPackage>>([]);

public Task<IEnumerable<NuGetPackage>> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken)
=> Task.FromResult<IEnumerable<NuGetPackage>>([]);

public Task<IEnumerable<NuGetPackage>> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func<string, bool>? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken)
=> Task.FromResult<IEnumerable<NuGetPackage>>([]);
}
}

internal sealed class TestAddCommandPrompter(IInteractionService interactionService) : AddCommandPrompter(interactionService)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Interaction;
using Spectre.Console;
Expand All @@ -14,6 +15,13 @@ internal sealed class TestConsoleInteractionService : IInteractionService
public Action<string>? DisplayConsoleWriteLineMessage { get; set; }
public Func<string, bool, bool>? ConfirmCallback { get; set; }
public Action<string>? ShowStatusCallback { get; set; }

/// <summary>
/// Callback for capturing selection prompts in tests. Uses non-generic IEnumerable and object
/// to work with the generic PromptForSelectionAsync&lt;T&gt; method regardless of T's type.
/// This allows tests to inspect what choices are presented without knowing the generic type at compile time.
/// </summary>
public Func<string, IEnumerable, Func<object, string>, CancellationToken, object>? PromptForSelectionCallback { get; set; }

public Task<T> ShowStatusAsync<T>(string statusText, Func<Task<T>> action)
{
Expand All @@ -38,6 +46,16 @@ public Task<T> PromptForSelectionAsync<T>(string promptText, IEnumerable<T> choi
throw new EmptyChoicesException($"No items available for selection: {promptText}");
}

if (PromptForSelectionCallback is not null)
{
// Invoke the callback - casting is safe here because:
// 1. 'choices' is IEnumerable<T>, and we cast items to T when calling choiceFormatter
// 2. 'result' comes from the callback which receives 'choices', so it must be of type T
// 3. These casts are for test infrastructure only, not production code
var result = PromptForSelectionCallback(promptText, choices, o => choiceFormatter((T)o), cancellationToken);
return Task.FromResult((T)result);
}

return Task.FromResult(choices.First());
}

Expand Down