From 59a26436def8654d54abb9ad41971de65e525df9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 07:17:46 +0000 Subject: [PATCH 1/4] Initial plan From 6f6f03980765e884ad18a38c1fd1ad4569b4241a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 07:32:37 +0000 Subject: [PATCH 2/4] Filter package versions to show only highest per channel Co-authored-by: mitchdenny <513398+mitchdenny@users.noreply.github.com> --- src/Aspire.Cli/Commands/AddCommand.cs | 40 +++++++++++++++++---------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index 91f82ac63b2..308b29957ad 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -300,14 +300,20 @@ 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 @@ -315,24 +321,22 @@ static string FormatVersionLabel((string FriendlyName, NuGetPackage Package, Pac 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) )); } @@ -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; From bba4849b30ee735be986f7ff6ce6242d7f6a6b20 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 07:39:37 +0000 Subject: [PATCH 3/4] Add tests to validate package version filtering Co-authored-by: mitchdenny <513398+mitchdenny@users.noreply.github.com> --- .../Commands/AddCommandTests.cs | 164 ++++++++++++++++++ .../TestConsoleInteractionService.cs | 9 + 2 files changed, 173 insertions(+) diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index b55aacde3d0..523af583941 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -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(); + + 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? 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().ToList(); + displayedChoices = choicesList; + return choicesList.First(); + }; + return mockInteraction; + }; + }); + var provider = services.BuildServiceProvider(); + var interactionService = provider.GetRequiredService(); + + 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? 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().ToList(); + displayedChoices = choicesList; + return choicesList.First(); + }; + return mockInteraction; + }; + }); + var provider = services.BuildServiceProvider(); + var interactionService = provider.GetRequiredService(); + + 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> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => Task.FromResult>([]); + + public Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => Task.FromResult>([]); + + public Task> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => Task.FromResult>([]); + + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + => Task.FromResult>([]); + } } internal sealed class TestAddCommandPrompter(IInteractionService interactionService) : AddCommandPrompter(interactionService) diff --git a/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs index c1ff12f4125..7d037d828c1 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs @@ -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; @@ -14,6 +15,7 @@ internal sealed class TestConsoleInteractionService : IInteractionService public Action? DisplayConsoleWriteLineMessage { get; set; } public Func? ConfirmCallback { get; set; } public Action? ShowStatusCallback { get; set; } + public Func, CancellationToken, object>? PromptForSelectionCallback { get; set; } public Task ShowStatusAsync(string statusText, Func> action) { @@ -38,6 +40,13 @@ public Task PromptForSelectionAsync(string promptText, IEnumerable choi throw new EmptyChoicesException($"No items available for selection: {promptText}"); } + if (PromptForSelectionCallback is not null) + { + // Invoke the callback with the choices as object types + var result = PromptForSelectionCallback(promptText, choices, o => choiceFormatter((T)o), cancellationToken); + return Task.FromResult((T)result); + } + return Task.FromResult(choices.First()); } From 765c30a82bf7d0411b2945afa3f0df55c103c1a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 07:42:55 +0000 Subject: [PATCH 4/4] Address code review comments with documentation and type safety improvements Co-authored-by: mitchdenny <513398+mitchdenny@users.noreply.github.com> --- .../TestServices/TestConsoleInteractionService.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs index 7d037d828c1..35dd197ed8f 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs @@ -15,6 +15,12 @@ internal sealed class TestConsoleInteractionService : IInteractionService public Action? DisplayConsoleWriteLineMessage { get; set; } public Func? ConfirmCallback { get; set; } public Action? ShowStatusCallback { get; set; } + + /// + /// Callback for capturing selection prompts in tests. Uses non-generic IEnumerable and object + /// to work with the generic PromptForSelectionAsync<T> method regardless of T's type. + /// This allows tests to inspect what choices are presented without knowing the generic type at compile time. + /// public Func, CancellationToken, object>? PromptForSelectionCallback { get; set; } public Task ShowStatusAsync(string statusText, Func> action) @@ -42,7 +48,10 @@ public Task PromptForSelectionAsync(string promptText, IEnumerable choi if (PromptForSelectionCallback is not null) { - // Invoke the callback with the choices as object types + // Invoke the callback - casting is safe here because: + // 1. 'choices' is IEnumerable, 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); }