From bea9aa32949f414a87469bab6e7c113fa28e0834 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Thu, 12 Feb 2026 11:12:38 -0800 Subject: [PATCH 1/6] Use --exact-match in dotnet package search when stagingVersionPrefix is set dotnet package search only returns the latest version per package ID, so when the shared dotnet9 feed has both 13.2 and 13.3 prerelease packages, only 13.3 is returned. The stagingVersionPrefix filter then discards it, resulting in "no templates found". When VersionPrefix is set on a channel, pass --exact-match to get all versions from the feed, enabling the prefix filter to find the correct version line. Also update ParsePackageSearchResults to handle the "version" JSON field used by --exact-match (vs "latestVersion" in normal search). --- src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 11 +- .../NuGet/BundleNuGetPackageCache.cs | 6 +- src/Aspire.Cli/NuGet/NuGetPackageCache.cs | 17 ++- src/Aspire.Cli/Packaging/PackageChannel.cs | 5 +- src/Shared/PackageUpdateHelpers.cs | 4 +- .../Commands/AddCommandTests.cs | 22 +-- .../Commands/InitCommandTests.cs | 10 +- .../Commands/NewCommandTests.cs | 28 ++-- .../Mcp/MockPackagingService.cs | 4 +- .../NuGet/NuGetPackageCacheTests.cs | 10 +- .../NuGetConfigMergerSnapshotTests.cs | 4 +- .../Packaging/NuGetConfigMergerTests.cs | 4 +- .../Packaging/PackageChannelTests.cs | 4 +- .../Packaging/PackagingServiceTests.cs | 137 +++++++++++++++++- .../Projects/AppHostServerProjectTests.cs | 4 +- .../Projects/ProjectUpdaterTests.cs | 44 +++--- .../Templating/DotNetTemplateFactoryTests.cs | 6 +- .../TestServices/FakeNuGetPackageCache.cs | 4 +- .../TestServices/TestDotNetCliRunner.cs | 6 +- .../CliUpdateNotificationServiceTests.cs | 6 +- 20 files changed, 240 insertions(+), 96 deletions(-) diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index 0f481a7917d..580bf0d9fca 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -34,7 +34,7 @@ internal interface IDotNetCliRunner Task BuildAsync(FileInfo projectFilePath, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); - Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); + Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken, bool exactMatch = false); Task<(int ExitCode, string[] ConfigPaths)> GetNuGetConfigPathsAsync(DirectoryInfo workingDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task<(int ExitCode, IReadOnlyList Projects)> GetSolutionProjectsAsync(FileInfo solutionFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task AddProjectReferenceAsync(FileInfo projectFile, FileInfo referencedProject, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); @@ -874,7 +874,7 @@ public async Task ComputeNuGetConfigHierarchySha256Async(DirectoryInfo w return result; } - public async Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public async Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken, bool exactMatch = false) { using var activity = telemetry.StartDiagnosticActivity(); @@ -899,7 +899,7 @@ public async Task ComputeNuGetConfigHierarchySha256Async(DirectoryInfo w // Build a cache key using the main discriminators, including CLI version. var cliVersion = VersionHelper.GetDefaultTemplateVersion(); - rawKey = $"query={query}|prerelease={prerelease}|take={take}|skip={skip}|nugetConfigHash={nugetConfigHash}|cliVersion={cliVersion}"; + rawKey = $"query={query}|prerelease={prerelease}|take={take}|skip={skip}|exactMatch={exactMatch}|nugetConfigHash={nugetConfigHash}|cliVersion={cliVersion}"; var cached = await _diskCache.GetAsync(rawKey, cancellationToken).ConfigureAwait(false); if (cached is not null) { @@ -945,6 +945,11 @@ public async Task ComputeNuGetConfigHierarchySha256Async(DirectoryInfo w cliArgs.Add("--prerelease"); } + if (exactMatch) + { + cliArgs.Add("--exact-match"); + } + int result = 0; string stdout = string.Empty; string stderr = string.Empty; diff --git a/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs b/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs index ce2a5e61136..182b72301aa 100644 --- a/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs +++ b/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs @@ -41,7 +41,8 @@ public async Task> GetTemplatePackagesAsync( DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool exactMatch = false) { var packages = await SearchPackagesInternalAsync( workingDirectory, @@ -92,7 +93,8 @@ public async Task> GetPackagesAsync( bool prerelease, FileInfo? nugetConfigFile, bool useCache, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool exactMatch = false) { var packages = await SearchPackagesInternalAsync( workingDirectory, diff --git a/src/Aspire.Cli/NuGet/NuGetPackageCache.cs b/src/Aspire.Cli/NuGet/NuGetPackageCache.cs index 3ca8347e88d..5f2950ee50f 100644 --- a/src/Aspire.Cli/NuGet/NuGetPackageCache.cs +++ b/src/Aspire.Cli/NuGet/NuGetPackageCache.cs @@ -13,10 +13,10 @@ namespace Aspire.Cli.NuGet; internal interface INuGetPackageCache { - Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken); + Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false); Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken); Task> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken); - Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken); + Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken, bool exactMatch = false); } internal sealed class NuGetPackageCache(IDotNetCliRunner cliRunner, IMemoryCache memoryCache, AspireCliTelemetry telemetry, IFeatures features) : INuGetPackageCache @@ -29,14 +29,14 @@ internal sealed class NuGetPackageCache(IDotNetCliRunner cliRunner, IMemoryCache "Aspire.Hosting.Dapr" }; - public async Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + public async Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) { var nuGetConfigHashSuffix = nugetConfigFile is not null ? await ComputeNuGetConfigHashSuffixAsync(nugetConfigFile, cancellationToken) : string.Empty; - var key = $"TemplatePackages-{workingDirectory.FullName}-{prerelease}-{nuGetConfigHashSuffix}"; + var key = $"TemplatePackages-{workingDirectory.FullName}-{prerelease}-{exactMatch}-{nuGetConfigHashSuffix}"; var packages = await memoryCache.GetOrCreateAsync(key, async (entry) => { - var packages = await GetPackagesAsync(workingDirectory, "Aspire.ProjectTemplates", null, prerelease, nugetConfigFile, true, cancellationToken); + var packages = await GetPackagesAsync(workingDirectory, "Aspire.ProjectTemplates", null, prerelease, nugetConfigFile, true, cancellationToken, exactMatch); return packages.Where(p => p.Id.Equals("Aspire.ProjectTemplates", StringComparison.OrdinalIgnoreCase)); }) ?? throw new NuGetPackageCacheException(ErrorStrings.FailedToRetrieveCachedTemplatePackages); @@ -73,7 +73,7 @@ private static async Task ComputeNuGetConfigHashSuffixAsync(FileInfo nug return Convert.ToHexString(hashBytes); } - public async Task> GetPackagesAsync(DirectoryInfo workingDirectory, string query, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + public async Task> GetPackagesAsync(DirectoryInfo workingDirectory, string query, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken, bool exactMatch = false) { using var activity = telemetry.StartDiagnosticActivity(); @@ -93,7 +93,8 @@ public async Task> GetPackagesAsync(DirectoryInfo work nugetConfigFile, useCache, // Pass through the useCache parameter new DotNetCliRunnerInvocationOptions { SuppressLogging = true }, - cancellationToken + cancellationToken, + exactMatch ); if (result.ExitCode != 0) @@ -107,7 +108,7 @@ public async Task> GetPackagesAsync(DirectoryInfo work collectedPackages.AddRange(result.Packages); } - if (result.Packages?.Length < SearchPageSize) + if (exactMatch || result.Packages?.Length < SearchPageSize) { continueFetching = false; } diff --git a/src/Aspire.Cli/Packaging/PackageChannel.cs b/src/Aspire.Cli/Packaging/PackageChannel.cs index 8152e83ca9c..40bcca38742 100644 --- a/src/Aspire.Cli/Packaging/PackageChannel.cs +++ b/src/Aspire.Cli/Packaging/PackageChannel.cs @@ -53,17 +53,18 @@ private static string ComputeSourceDetails(PackageMapping[]? mappings) public async Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken) { var tasks = new List>>(); + var useExactMatch = VersionPrefix is not null; using var tempNuGetConfig = Type is PackageChannelType.Explicit ? await TemporaryNuGetConfig.CreateAsync(Mappings!) : null; if (Quality is PackageChannelQuality.Stable || Quality is PackageChannelQuality.Both) { - tasks.Add(nuGetPackageCache.GetTemplatePackagesAsync(workingDirectory, false, tempNuGetConfig?.ConfigFile, cancellationToken)); + tasks.Add(nuGetPackageCache.GetTemplatePackagesAsync(workingDirectory, false, tempNuGetConfig?.ConfigFile, cancellationToken, useExactMatch)); } if (Quality is PackageChannelQuality.Prerelease || Quality is PackageChannelQuality.Both) { - tasks.Add(nuGetPackageCache.GetTemplatePackagesAsync(workingDirectory, true, tempNuGetConfig?.ConfigFile, cancellationToken)); + tasks.Add(nuGetPackageCache.GetTemplatePackagesAsync(workingDirectory, true, tempNuGetConfig?.ConfigFile, cancellationToken, useExactMatch)); } var packageResults = await Task.WhenAll(tasks); diff --git a/src/Shared/PackageUpdateHelpers.cs b/src/Shared/PackageUpdateHelpers.cs index 99ff9181778..d5335b94750 100644 --- a/src/Shared/PackageUpdateHelpers.cs +++ b/src/Shared/PackageUpdateHelpers.cs @@ -144,7 +144,9 @@ public static List ParsePackageSearchResults(string stdout, string { var id = packageResult.GetProperty("id").GetString()!; - var version = packageResult.GetProperty("latestVersion").GetString()!; + var version = packageResult.TryGetProperty("latestVersion", out var latestVersionProp) + ? latestVersionProp.GetString()! + : packageResult.GetProperty("version").GetString()!; if (packageId == null || id == packageId) { diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index 465b26ef4e4..bb97a2f7be5 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -47,7 +47,7 @@ public async Task AddCommandInteractiveFlowSmokeTest() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => { var dockerPackage = new NuGetPackage() { @@ -122,7 +122,7 @@ public async Task AddCommandDoesNotPromptForIntegrationArgumentIfSpecifiedOnComm options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => { var dockerPackage = new NuGetPackage() { @@ -205,7 +205,7 @@ public async Task AddCommandDoesNotPromptForVersionIfSpecifiedOnCommandLine() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => { var dockerPackage = new NuGetPackage() { @@ -284,7 +284,7 @@ public async Task AddCommandPromptsForDisambiguation() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => { var dockerPackage = new NuGetPackage() { @@ -365,7 +365,7 @@ public async Task AddCommandPreservesSourceArgumentInBothCommands() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => { var redisPackage = new NuGetPackage() { @@ -428,7 +428,7 @@ public async Task AddCommand_EmptyPackageList_DisplaysErrorMessage() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => { return (0, Array.Empty()); }; @@ -482,7 +482,7 @@ public async Task AddCommand_NoMatchingPackages_DisplaysNoMatchesMessage() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => { var dockerPackage = new NuGetPackage() { @@ -711,7 +711,7 @@ public async Task AddCommand_WithoutHives_UsesImplicitChannelWithoutPrompting() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => { var redisPackage = new NuGetPackage() { @@ -801,7 +801,7 @@ public async Task AddCommand_WithStartsWith_FindsMatchUsingFuzzySearch() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => { var postgresPackage = new NuGetPackage() { @@ -878,7 +878,7 @@ public async Task AddCommand_WithPartialMatch_FiltersUsingFuzzySearch() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => { var postgresPackage = new NuGetPackage() { @@ -954,7 +954,7 @@ public async Task AddCommand_WithTypo_FindsMatchUsingFuzzySearch() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => { var appContainersPackage = new NuGetPackage() { diff --git a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs index 985ffa8bf4b..797081cd04e 100644 --- a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs @@ -255,7 +255,7 @@ public async Task InitCommand_WithSingleFileAppHost_DoesNotPromptForProjectNameO }; // Mock package search for template version selection - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetConfigFile, useCache, invocationOptions, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetConfigFile, useCache, invocationOptions, cancellationToken, _) => { var package = new Aspire.Shared.NuGetPackageCli { @@ -338,7 +338,7 @@ public Task> GetChannelsAsync(CancellationToken canc private sealed class FakeNuGetPackageCache : INuGetPackageCache { - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) { var package = new Aspire.Shared.NuGetPackageCli { @@ -359,7 +359,7 @@ private sealed class FakeNuGetPackageCache : INuGetPackageCache return Task.FromResult>(Array.Empty()); } - public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken, bool exactMatch = false) { return Task.FromResult>(Array.Empty()); } @@ -462,7 +462,7 @@ public Task> GetChannelsAsync(CancellationToken canc private sealed class FakeNuGetPackageCacheWithTracking(string channelName, Action onChannelUsed) : INuGetPackageCache { - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) { onChannelUsed(channelName); var package = new Aspire.Shared.NuGetPackageCli @@ -484,7 +484,7 @@ private sealed class FakeNuGetPackageCacheWithTracking(string channelName, Actio return Task.FromResult>(Array.Empty()); } - public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken, bool exactMatch = false) { return Task.FromResult>(Array.Empty()); } diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index adb1530ad89..1c8a7a0eefc 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -51,7 +51,7 @@ public async Task NewCommandInteractiveFlowSmokeTest() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => { var package = new NuGetPackage() { @@ -108,7 +108,7 @@ public async Task NewCommandDerivesOutputPathFromProjectNameForStarterTemplate() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => { var package = new NuGetPackage() { @@ -161,7 +161,7 @@ public async Task NewCommandDoesNotPromptForProjectNameIfSpecifiedOnCommandLine( options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => { var package = new NuGetPackage() { @@ -216,7 +216,7 @@ public async Task NewCommandDoesNotPromptForOutputPathIfSpecifiedOnCommandLine() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => { var package = new NuGetPackage() { @@ -429,7 +429,7 @@ public async Task NewCommandDoesNotPromptForTemplateIfSpecifiedOnCommandLine() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => { var package = new NuGetPackage() { @@ -483,7 +483,7 @@ public async Task NewCommandDoesNotPromptForTemplateVersionIfSpecifiedOnCommandL options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetConfigFile, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetConfigFile, useCache, options, cancellationToken, _) => { var package = new NuGetPackage() { @@ -528,7 +528,7 @@ public async Task NewCommand_EmptyPackageList_DisplaysErrorMessage() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => { return (0, Array.Empty()); }; return runner; @@ -562,7 +562,7 @@ public async Task NewCommand_WhenCertificateServiceThrows_ReturnsNonZeroExitCode options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => { var package = new NuGetPackage() { @@ -615,7 +615,7 @@ public async Task NewCommandWithExitCode73ShowsUserFriendlyError() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => { var package = new NuGetPackage() { @@ -691,7 +691,7 @@ public async Task NewCommandPromptsForTemplateVersionBeforeTemplateOptions() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetConfigFile, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetConfigFile, useCache, options, cancellationToken, _) => { var package = new NuGetPackage() { @@ -773,7 +773,7 @@ public async Task NewCommandEscapesMarkupInProjectNameAndOutputPath() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => { var package = new NuGetPackage() { @@ -818,7 +818,7 @@ public async Task NewCommandNonInteractiveDoesNotPrompt() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => { var package = new NuGetPackage() { @@ -978,7 +978,7 @@ internal sealed class NewCommandTestFakeNuGetPackageCache : INuGetPackageCache { public Func>>? GetTemplatePackagesAsyncCallback { get; set; } - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) { if (GetTemplatePackagesAsyncCallback is not null) { @@ -1004,7 +1004,7 @@ public Task> GetCliPackagesAsync(DirectoryInfo working return Task.FromResult>(Array.Empty()); } - public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken, bool exactMatch = false) { return Task.FromResult>(Array.Empty()); } diff --git a/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs b/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs index 27505f5e9a3..aa672216259 100644 --- a/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs +++ b/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs @@ -34,7 +34,7 @@ public MockNuGetPackageCache(NuGetPackageCli[]? packages = null) _packages = packages ?? []; } - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) => Task.FromResult>([]); public Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) @@ -43,7 +43,7 @@ public Task> GetIntegrationPackagesAsync(DirectoryI 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) + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken, bool exactMatch = false) => Task.FromResult>([]); } diff --git a/tests/Aspire.Cli.Tests/NuGet/NuGetPackageCacheTests.cs b/tests/Aspire.Cli.Tests/NuGet/NuGetPackageCacheTests.cs index 27ef3717480..403ab41c567 100644 --- a/tests/Aspire.Cli.Tests/NuGet/NuGetPackageCacheTests.cs +++ b/tests/Aspire.Cli.Tests/NuGet/NuGetPackageCacheTests.cs @@ -21,7 +21,7 @@ public async Task NonAspireCliPackagesWillNotBeConsidered() configure.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _) => + runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _, _) => { // Simulate a search that returns packages that do not match Aspire.Cli return (0, [ @@ -54,7 +54,7 @@ public async Task DeprecatedPackagesAreFilteredByDefault() configure.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _) => + runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _, _) => { // Simulate a search that returns both regular and deprecated packages return (0, [ @@ -92,7 +92,7 @@ public async Task DeprecatedPackagesAreIncludedWhenShowDeprecatedPackagesEnabled configure.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _) => + runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _, _) => { // Simulate a search that returns both regular and deprecated packages return (0, [ @@ -127,7 +127,7 @@ public async Task CustomFilterBypassesDeprecatedPackageFiltering() configure.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _) => + runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _, _) => { // Simulate a search that returns both regular and deprecated packages return (0, [ @@ -171,7 +171,7 @@ public async Task DeprecatedPackageFilteringIsCaseInsensitive() configure.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _) => + runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _, _) => { // Test different casing of deprecated package name return (0, [ diff --git a/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs b/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs index 77fd8b738aa..cd075d51bad 100644 --- a/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs @@ -24,10 +24,10 @@ public NuGetConfigMergerSnapshotTests(ITestOutputHelper output) private sealed class FakeNuGetPackageCache : INuGetPackageCache { - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) => Task.FromResult>([]); + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) => 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>([]); + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken, bool exactMatch = false) => Task.FromResult>([]); } private sealed class FakeFeatures : IFeatures diff --git a/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerTests.cs b/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerTests.cs index 3f648c69a8c..975d20c1222 100644 --- a/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerTests.cs @@ -28,7 +28,7 @@ private static async Task WriteConfigAsync(DirectoryInfo dir, string c private sealed class FakeNuGetPackageCache : INuGetPackageCache { - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) { _ = workingDirectory; _ = prerelease; _ = nugetConfigFile; _ = cancellationToken; return Task.FromResult>([]); } @@ -40,7 +40,7 @@ private sealed class FakeNuGetPackageCache : INuGetPackageCache { _ = workingDirectory; _ = prerelease; _ = nugetConfigFile; _ = cancellationToken; return Task.FromResult>([]); } - public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken, bool exactMatch = false) { _ = workingDirectory; _ = packageId; _ = filter; _ = prerelease; _ = nugetConfigFile; _ = useCache; _ = cancellationToken; return Task.FromResult>([]); } diff --git a/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs index 5277c4323e7..212c0b8da45 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs @@ -11,10 +11,10 @@ public class PackageChannelTests { private sealed class FakeNuGetPackageCache : INuGetPackageCache { - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) => Task.FromResult>([]); + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) => 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>([]); + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken, bool exactMatch = false) => Task.FromResult>([]); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs index 74b71823ea7..a6d715cda6c 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs @@ -16,10 +16,10 @@ public class PackagingServiceTests(ITestOutputHelper outputHelper) private sealed class FakeNuGetPackageCache : INuGetPackageCache { - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) => Task.FromResult>([]); + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) => 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>([]); + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken, bool exactMatch = false) => Task.FromResult>([]); } private sealed class TestFeatures : IFeatures @@ -729,4 +729,137 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixInvalid_ChannelHasNoV var stagingChannel = channels.First(c => c.Name == "staging"); Assert.Null(stagingChannel.VersionPrefix); } + + /// + /// Simulates the dotnet9 shared feed which contains packages from multiple version lines. + /// Verifies that version prefix filtering correctly selects only the requested major.minor. + /// + [Fact] + public async Task StagingChannel_WithVersionPrefix_FiltersTemplatePackagesToMatchingMajorMinor() + { + // Arrange - simulate a shared feed that has packages from both 13.2 and 13.3 version lines + var fakeCache = new FakeNuGetPackageCacheWithPackages( + [ + new() { Id = "Aspire.ProjectTemplates", Version = "13.3.0-preview.1.26201.1", Source = "dotnet9" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.3.0-preview.1.26200.5", Source = "dotnet9" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.2.0-preview.1.26111.6", Source = "dotnet9" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.2.0-preview.1.26110.3", Source = "dotnet9" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.1.0", Source = "dotnet9" }, + ]); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingQuality"] = "Prerelease", + ["stagingVersionPrefix"] = "13.2" + }) + .Build(); + + var packagingService = new PackagingService(executionContext, fakeCache, features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + var stagingChannel = channels.First(c => c.Name == "staging"); + var templatePackages = await stagingChannel.GetTemplatePackagesAsync(tempDir, CancellationToken.None).DefaultTimeout(); + + // Assert + var packageList = templatePackages.ToList(); + outputHelper.WriteLine($"Template packages returned: {packageList.Count}"); + foreach (var p in packageList) + { + outputHelper.WriteLine($" {p.Id} {p.Version}"); + } + + Assert.NotEmpty(packageList); + Assert.All(packageList, p => + { + var semVer = Semver.SemVersion.Parse(p.Version); + Assert.Equal(13, semVer.Major); + Assert.Equal(2, semVer.Minor); + }); + // Should have exactly the two 13.2 prerelease packages + Assert.Equal(2, packageList.Count); + } + + /// + /// Verifies that without a version prefix, all prerelease packages from the feed are returned. + /// + [Fact] + public async Task StagingChannel_WithoutVersionPrefix_ReturnsAllPrereleasePackages() + { + // Arrange + var fakeCache = new FakeNuGetPackageCacheWithPackages( + [ + new() { Id = "Aspire.ProjectTemplates", Version = "13.3.0-preview.1.26201.1", Source = "dotnet9" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.2.0-preview.1.26111.6", Source = "dotnet9" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.1.0", Source = "dotnet9" }, + ]); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingQuality"] = "Prerelease" + // No stagingVersionPrefix — should return all prerelease + }) + .Build(); + + var packagingService = new PackagingService(executionContext, fakeCache, features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + var stagingChannel = channels.First(c => c.Name == "staging"); + var templatePackages = await stagingChannel.GetTemplatePackagesAsync(tempDir, CancellationToken.None).DefaultTimeout(); + + // Assert + var packageList = templatePackages.ToList(); + outputHelper.WriteLine($"Template packages returned: {packageList.Count}"); + foreach (var p in packageList) + { + outputHelper.WriteLine($" {p.Id} {p.Version}"); + } + + // Should return only the prerelease ones (quality filter), but both 13.3 and 13.2 + Assert.Equal(2, packageList.Count); + Assert.Contains(packageList, p => p.Version.StartsWith("13.3")); + Assert.Contains(packageList, p => p.Version.StartsWith("13.2")); + } + + private sealed class FakeNuGetPackageCacheWithPackages(List packages) : INuGetPackageCache + { + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) + { + // Simulate what the real cache does: filter by prerelease flag + var filtered = prerelease + ? packages.Where(p => Semver.SemVersion.Parse(p.Version).IsPrerelease) + : packages.Where(p => !Semver.SemVersion.Parse(p.Version).IsPrerelease); + return Task.FromResult>(filtered.ToList()); + } + + public Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => GetTemplatePackagesAsync(workingDirectory, prerelease, nugetConfigFile, cancellationToken); + + 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, bool exactMatch = false) + => GetTemplatePackagesAsync(workingDirectory, prerelease, nugetConfigFile, cancellationToken); + } } diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs index 7ebdea5e7fd..00010aa6cde 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs @@ -371,7 +371,7 @@ public Task> GetChannelsAsync(CancellationToken canc private sealed class FakeNuGetPackageCache : INuGetPackageCache { - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) => Task.FromResult>([]); public Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) @@ -380,7 +380,7 @@ public Task> GetIntegrationPackagesAsync(DirectoryI 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) + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken, bool exactMatch = false) => Task.FromResult>([]); } diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs index 0ccafc41816..a59a8d069bf 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs @@ -49,7 +49,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => { var packages = new List(); @@ -160,7 +160,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => { var packages = new List(); @@ -291,7 +291,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, prerelease, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, prerelease, _, _, _, _, _, _, _) => { var packages = new List(); @@ -444,7 +444,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => { var packages = new List(); @@ -604,7 +604,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => { var packages = new List(); @@ -723,7 +723,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => { var packages = new List(); @@ -825,7 +825,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => { var packages = new List(); @@ -959,7 +959,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => { var packages = new List(); @@ -1075,7 +1075,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => { var packages = new List(); @@ -1196,7 +1196,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => { var packages = new List(); @@ -1309,7 +1309,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => { var packages = new List(); @@ -1405,7 +1405,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => { var packages = new List(); @@ -1514,7 +1514,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => { var packages = new List(); @@ -1599,7 +1599,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => { var packages = new List(); @@ -1675,7 +1675,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => { var packages = new List(); @@ -1759,7 +1759,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => { var packages = new List(); @@ -1842,7 +1842,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => { var packages = new List(); @@ -1922,7 +1922,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => { var packages = new List(); @@ -2012,7 +2012,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => { var packages = new List(); @@ -2091,7 +2091,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => { var packages = new List(); @@ -2169,7 +2169,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => { var packages = new List(); @@ -2297,7 +2297,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => { var packages = new List(); diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index bbd51fec810..47dcb4c27de 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -30,7 +30,7 @@ public DotNetTemplateFactoryTests(ITestOutputHelper outputHelper) private sealed class FakeNuGetPackageCache : INuGetPackageCache { - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) { _ = workingDirectory; _ = prerelease; _ = nugetConfigFile; _ = cancellationToken; return Task.FromResult>([]); } @@ -42,7 +42,7 @@ private sealed class FakeNuGetPackageCache : INuGetPackageCache { _ = workingDirectory; _ = prerelease; _ = nugetConfigFile; _ = cancellationToken; return Task.FromResult>([]); } - public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken, bool exactMatch = false) { _ = workingDirectory; _ = packageId; _ = filter; _ = prerelease; _ = nugetConfigFile; _ = useCache; _ = cancellationToken; return Task.FromResult>([]); } @@ -469,7 +469,7 @@ public Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo proje public Task AddProjectReferenceAsync(FileInfo projectFile, FileInfo referencedProjectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task<(int ExitCode, NuGetPackageCli[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task<(int ExitCode, NuGetPackageCli[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken, bool exactMatch = false) => throw new NotImplementedException(); public Task<(int ExitCode, bool IsAspireHost, string? AspireHostingVersion)> GetAppHostInformationAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeNuGetPackageCache.cs b/tests/Aspire.Cli.Tests/TestServices/FakeNuGetPackageCache.cs index b628d68ae9e..59660b2ac22 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeNuGetPackageCache.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeNuGetPackageCache.cs @@ -8,7 +8,7 @@ namespace Aspire.Cli.Tests.TestServices; internal sealed class FakeNuGetPackageCache : INuGetPackageCache { - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) => Task.FromResult>([]); public Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) @@ -17,6 +17,6 @@ public Task> GetIntegrationPackagesAsync(DirectoryInfo 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) + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken, bool exactMatch = false) => Task.FromResult>([]); } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs index 94506b94fd8..36f64d0516a 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs @@ -20,7 +20,7 @@ internal sealed class TestDotNetCliRunner : IDotNetCliRunner public Func? InstallTemplateAsyncCallback { get; set; } public Func? NewProjectAsyncCallback { get; set; } public Func?, TaskCompletionSource?, DotNetCliRunnerInvocationOptions, CancellationToken, Task>? RunAsyncCallback { get; set; } - public Func? SearchPackagesAsyncCallback { get; set; } + public Func? SearchPackagesAsyncCallback { get; set; } public Func Projects)>? GetSolutionProjectsAsyncCallback { get; set; } public Func? AddProjectReferenceAsyncCallback { get; set; } @@ -98,10 +98,10 @@ public Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, bool n : throw new NotImplementedException(); } - public Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken, bool exactMatch = false) { return SearchPackagesAsyncCallback != null - ? Task.FromResult(SearchPackagesAsyncCallback(workingDirectory, query, prerelease, take, skip, nugetConfigFile, useCache, options, cancellationToken)) + ? Task.FromResult(SearchPackagesAsyncCallback(workingDirectory, query, prerelease, take, skip, nugetConfigFile, useCache, options, cancellationToken, exactMatch)) : throw new NotImplementedException(); } diff --git a/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs b/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs index ec634093d30..6e0bf9e953d 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs @@ -28,7 +28,7 @@ public async Task PrereleaseWillRecommendUpgradeToPrereleaseOnSameVersionFamily( { var cache = new TestNuGetPackageCache(); cache.SetMockCliPackages([ - // Should be ignored because its lower that current prerelease version. + // Should be ignored because it's lower than current prerelease version. new NuGetPackage { Id = "Aspire.Cli", Version = "9.3.1", Source = "nuget.org" }, // Should be selected because it is higher than 9.4.0-dev (dev and preview sort using alphabetical sort). @@ -293,7 +293,7 @@ public void SetMockCliPackages(IEnumerable packages) _cliPackages = packages; } - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) { return Task.FromResult(Enumerable.Empty()); } @@ -308,7 +308,7 @@ public Task> GetCliPackagesAsync(DirectoryInfo working return Task.FromResult(_cliPackages); } - public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken, bool exactMatch = false) { return Task.FromResult(Enumerable.Empty()); } From 6b6983217c519676e1e56c1cdc0c07fe8fb0deaa Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Thu, 12 Feb 2026 12:46:38 -0800 Subject: [PATCH 2/6] Use pinned CLI version for staging channel in shared feed mode When the staging channel is configured with Prerelease quality and no explicit feed override, packages are now pinned to the CLI's own version instead of searching NuGet. This avoids the dotnet package search limitation where only the latest version per package ID is returned, which caused version mismatches when the shared feed contains packages from multiple version lines (e.g. 13.2.x and 13.3.x). New config flag stagingPinToCliVersion (boolean) controls this behavior. When enabled alongside overrideStagingQuality=Prerelease: - Templates (aspire new): synthetic result with CLI version - Integrations (aspire add): discovers packages then overrides version - Specific packages (aspire update): synthetic result with CLI version Also cleans up reverted exact-match parameter plumbing from interfaces and test fakes. --- .../aspire-global-settings.schema.json | 10 +- extension/schemas/aspire-settings.schema.json | 10 +- src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 11 +- .../NuGet/BundleNuGetPackageCache.cs | 6 +- src/Aspire.Cli/NuGet/NuGetPackageCache.cs | 17 ++- src/Aspire.Cli/Packaging/PackageChannel.cs | 48 +++---- src/Aspire.Cli/Packaging/PackagingService.cs | 23 ++-- .../Commands/AddCommandTests.cs | 22 ++-- .../Commands/InitCommandTests.cs | 10 +- .../Commands/NewCommandTests.cs | 28 ++--- .../Mcp/MockPackagingService.cs | 4 +- .../NuGet/NuGetPackageCacheTests.cs | 10 +- .../NuGetConfigMergerSnapshotTests.cs | 4 +- .../Packaging/NuGetConfigMergerTests.cs | 4 +- .../Packaging/PackageChannelTests.cs | 4 +- .../Packaging/PackagingServiceTests.cs | 118 +++++++++++++----- .../Projects/AppHostServerProjectTests.cs | 4 +- .../Projects/ProjectUpdaterTests.cs | 44 +++---- .../Templating/DotNetTemplateFactoryTests.cs | 6 +- .../TestServices/FakeNuGetPackageCache.cs | 4 +- .../TestServices/TestDotNetCliRunner.cs | 6 +- .../CliUpdateNotificationServiceTests.cs | 4 +- 22 files changed, 225 insertions(+), 172 deletions(-) diff --git a/extension/schemas/aspire-global-settings.schema.json b/extension/schemas/aspire-global-settings.schema.json index 8f7a8db0414..3a6b4eebb98 100644 --- a/extension/schemas/aspire-global-settings.schema.json +++ b/extension/schemas/aspire-global-settings.schema.json @@ -308,9 +308,13 @@ "Both" ] }, - "stagingVersionPrefix": { - "description": "Filter staging channel packages to a specific Major.Minor version (e.g., \"13.2\"). When set, only packages matching this version prefix are shown, preventing newer daily versions from being selected.", - "type": "string" + "stagingPinToCliVersion": { + "description": "When set to \"true\" and using the staging channel with Prerelease quality on the shared feed, all template and integration packages are pinned to the exact version of the installed CLI. This bypasses NuGet search entirely, ensuring version consistency.", + "type": "string", + "enum": [ + "true", + "false" + ] } }, "additionalProperties": false diff --git a/extension/schemas/aspire-settings.schema.json b/extension/schemas/aspire-settings.schema.json index c2da807d981..c14933b5577 100644 --- a/extension/schemas/aspire-settings.schema.json +++ b/extension/schemas/aspire-settings.schema.json @@ -312,9 +312,13 @@ "Both" ] }, - "stagingVersionPrefix": { - "description": "Filter staging channel packages to a specific Major.Minor version (e.g., \"13.2\"). When set, only packages matching this version prefix are shown, preventing newer daily versions from being selected.", - "type": "string" + "stagingPinToCliVersion": { + "description": "When set to \"true\" and using the staging channel with Prerelease quality on the shared feed, all template and integration packages are pinned to the exact version of the installed CLI. This bypasses NuGet search entirely, ensuring version consistency.", + "type": "string", + "enum": [ + "true", + "false" + ] } }, "additionalProperties": false diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index 580bf0d9fca..0f481a7917d 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -34,7 +34,7 @@ internal interface IDotNetCliRunner Task BuildAsync(FileInfo projectFilePath, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); - Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken, bool exactMatch = false); + Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task<(int ExitCode, string[] ConfigPaths)> GetNuGetConfigPathsAsync(DirectoryInfo workingDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task<(int ExitCode, IReadOnlyList Projects)> GetSolutionProjectsAsync(FileInfo solutionFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task AddProjectReferenceAsync(FileInfo projectFile, FileInfo referencedProject, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); @@ -874,7 +874,7 @@ public async Task ComputeNuGetConfigHierarchySha256Async(DirectoryInfo w return result; } - public async Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken, bool exactMatch = false) + public async Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); @@ -899,7 +899,7 @@ public async Task ComputeNuGetConfigHierarchySha256Async(DirectoryInfo w // Build a cache key using the main discriminators, including CLI version. var cliVersion = VersionHelper.GetDefaultTemplateVersion(); - rawKey = $"query={query}|prerelease={prerelease}|take={take}|skip={skip}|exactMatch={exactMatch}|nugetConfigHash={nugetConfigHash}|cliVersion={cliVersion}"; + rawKey = $"query={query}|prerelease={prerelease}|take={take}|skip={skip}|nugetConfigHash={nugetConfigHash}|cliVersion={cliVersion}"; var cached = await _diskCache.GetAsync(rawKey, cancellationToken).ConfigureAwait(false); if (cached is not null) { @@ -945,11 +945,6 @@ public async Task ComputeNuGetConfigHierarchySha256Async(DirectoryInfo w cliArgs.Add("--prerelease"); } - if (exactMatch) - { - cliArgs.Add("--exact-match"); - } - int result = 0; string stdout = string.Empty; string stderr = string.Empty; diff --git a/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs b/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs index 182b72301aa..ce2a5e61136 100644 --- a/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs +++ b/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs @@ -41,8 +41,7 @@ public async Task> GetTemplatePackagesAsync( DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, - CancellationToken cancellationToken, - bool exactMatch = false) + CancellationToken cancellationToken) { var packages = await SearchPackagesInternalAsync( workingDirectory, @@ -93,8 +92,7 @@ public async Task> GetPackagesAsync( bool prerelease, FileInfo? nugetConfigFile, bool useCache, - CancellationToken cancellationToken, - bool exactMatch = false) + CancellationToken cancellationToken) { var packages = await SearchPackagesInternalAsync( workingDirectory, diff --git a/src/Aspire.Cli/NuGet/NuGetPackageCache.cs b/src/Aspire.Cli/NuGet/NuGetPackageCache.cs index 5f2950ee50f..3ca8347e88d 100644 --- a/src/Aspire.Cli/NuGet/NuGetPackageCache.cs +++ b/src/Aspire.Cli/NuGet/NuGetPackageCache.cs @@ -13,10 +13,10 @@ namespace Aspire.Cli.NuGet; internal interface INuGetPackageCache { - Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false); + Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken); Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken); Task> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken); - Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken, bool exactMatch = false); + Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken); } internal sealed class NuGetPackageCache(IDotNetCliRunner cliRunner, IMemoryCache memoryCache, AspireCliTelemetry telemetry, IFeatures features) : INuGetPackageCache @@ -29,14 +29,14 @@ internal sealed class NuGetPackageCache(IDotNetCliRunner cliRunner, IMemoryCache "Aspire.Hosting.Dapr" }; - public async Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) + public async Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) { var nuGetConfigHashSuffix = nugetConfigFile is not null ? await ComputeNuGetConfigHashSuffixAsync(nugetConfigFile, cancellationToken) : string.Empty; - var key = $"TemplatePackages-{workingDirectory.FullName}-{prerelease}-{exactMatch}-{nuGetConfigHashSuffix}"; + var key = $"TemplatePackages-{workingDirectory.FullName}-{prerelease}-{nuGetConfigHashSuffix}"; var packages = await memoryCache.GetOrCreateAsync(key, async (entry) => { - var packages = await GetPackagesAsync(workingDirectory, "Aspire.ProjectTemplates", null, prerelease, nugetConfigFile, true, cancellationToken, exactMatch); + var packages = await GetPackagesAsync(workingDirectory, "Aspire.ProjectTemplates", null, prerelease, nugetConfigFile, true, cancellationToken); return packages.Where(p => p.Id.Equals("Aspire.ProjectTemplates", StringComparison.OrdinalIgnoreCase)); }) ?? throw new NuGetPackageCacheException(ErrorStrings.FailedToRetrieveCachedTemplatePackages); @@ -73,7 +73,7 @@ private static async Task ComputeNuGetConfigHashSuffixAsync(FileInfo nug return Convert.ToHexString(hashBytes); } - public async Task> GetPackagesAsync(DirectoryInfo workingDirectory, string query, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken, bool exactMatch = false) + public async Task> GetPackagesAsync(DirectoryInfo workingDirectory, string query, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); @@ -93,8 +93,7 @@ public async Task> GetPackagesAsync(DirectoryInfo work nugetConfigFile, useCache, // Pass through the useCache parameter new DotNetCliRunnerInvocationOptions { SuppressLogging = true }, - cancellationToken, - exactMatch + cancellationToken ); if (result.ExitCode != 0) @@ -108,7 +107,7 @@ public async Task> GetPackagesAsync(DirectoryInfo work collectedPackages.AddRange(result.Packages); } - if (exactMatch || result.Packages?.Length < SearchPageSize) + if (result.Packages?.Length < SearchPageSize) { continueFetching = false; } diff --git a/src/Aspire.Cli/Packaging/PackageChannel.cs b/src/Aspire.Cli/Packaging/PackageChannel.cs index 40bcca38742..5bcd3bb2071 100644 --- a/src/Aspire.Cli/Packaging/PackageChannel.cs +++ b/src/Aspire.Cli/Packaging/PackageChannel.cs @@ -8,7 +8,7 @@ namespace Aspire.Cli.Packaging; -internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, SemVersion? versionPrefix = null) +internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, string? pinnedVersion = null) { public string Name { get; } = name; public PackageChannelQuality Quality { get; } = quality; @@ -16,20 +16,10 @@ internal class PackageChannel(string name, PackageChannelQuality quality, Packag public PackageChannelType Type { get; } = mappings is null ? PackageChannelType.Implicit : PackageChannelType.Explicit; public bool ConfigureGlobalPackagesFolder { get; } = configureGlobalPackagesFolder; public string? CliDownloadBaseUrl { get; } = cliDownloadBaseUrl; - public SemVersion? VersionPrefix { get; } = versionPrefix; + public string? PinnedVersion { get; } = pinnedVersion; public string SourceDetails { get; } = ComputeSourceDetails(mappings); - private bool MatchesVersionPrefix(SemVersion semVer) - { - if (VersionPrefix is null) - { - return true; - } - - return semVer.Major == VersionPrefix.Major && semVer.Minor == VersionPrefix.Minor; - } - private static string ComputeSourceDetails(PackageMapping[]? mappings) { if (mappings is null) @@ -52,19 +42,23 @@ private static string ComputeSourceDetails(PackageMapping[]? mappings) public async Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken) { + if (PinnedVersion is not null) + { + return [new NuGetPackage { Id = "Aspire.ProjectTemplates", Version = PinnedVersion, Source = SourceDetails }]; + } + var tasks = new List>>(); - var useExactMatch = VersionPrefix is not null; using var tempNuGetConfig = Type is PackageChannelType.Explicit ? await TemporaryNuGetConfig.CreateAsync(Mappings!) : null; if (Quality is PackageChannelQuality.Stable || Quality is PackageChannelQuality.Both) { - tasks.Add(nuGetPackageCache.GetTemplatePackagesAsync(workingDirectory, false, tempNuGetConfig?.ConfigFile, cancellationToken, useExactMatch)); + tasks.Add(nuGetPackageCache.GetTemplatePackagesAsync(workingDirectory, false, tempNuGetConfig?.ConfigFile, cancellationToken)); } if (Quality is PackageChannelQuality.Prerelease || Quality is PackageChannelQuality.Both) { - tasks.Add(nuGetPackageCache.GetTemplatePackagesAsync(workingDirectory, true, tempNuGetConfig?.ConfigFile, cancellationToken, useExactMatch)); + tasks.Add(nuGetPackageCache.GetTemplatePackagesAsync(workingDirectory, true, tempNuGetConfig?.ConfigFile, cancellationToken)); } var packageResults = await Task.WhenAll(tasks); @@ -81,7 +75,7 @@ public async Task> GetTemplatePackagesAsync(DirectoryI { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true, { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true, _ => false - }).Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); + }); return filteredPackages; } @@ -116,13 +110,25 @@ public async Task> GetIntegrationPackagesAsync(Directo { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true, { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true, _ => false - }).Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); + }); + + // When pinned to a specific version, override the version on each discovered package + // so the correct version gets installed regardless of what the feed reports as latest. + if (PinnedVersion is not null) + { + return filteredPackages.Select(p => new NuGetPackage { Id = p.Id, Version = PinnedVersion, Source = p.Source }); + } return filteredPackages; } public async Task> GetPackagesAsync(string packageId, DirectoryInfo workingDirectory, CancellationToken cancellationToken) { + if (PinnedVersion is not null) + { + return [new NuGetPackage { Id = packageId, Version = PinnedVersion, Source = SourceDetails }]; + } + var tasks = new List>>(); using var tempNuGetConfig = Type is PackageChannelType.Explicit ? await TemporaryNuGetConfig.CreateAsync(Mappings!) : null; @@ -171,7 +177,7 @@ public async Task> GetPackagesAsync(string packageId, useCache: true, // Enable caching for package channel resolution cancellationToken: cancellationToken); - return packages.Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); + return packages; } // When doing a `dotnet package search` the the results may include stable packages even when searching for @@ -182,14 +188,14 @@ public async Task> GetPackagesAsync(string packageId, { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true, { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true, _ => false - }).Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); + }); return filteredPackages; } - public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, SemVersion? versionPrefix = null) + public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, string? pinnedVersion = null) { - return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder, cliDownloadBaseUrl, versionPrefix); + return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder, cliDownloadBaseUrl, pinnedVersion); } public static PackageChannel CreateImplicitChannel(INuGetPackageCache nuGetPackageCache) diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index 7bde39025b0..d1e91bc06d4 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -4,7 +4,6 @@ using Aspire.Cli.Configuration; using Aspire.Cli.NuGet; using Microsoft.Extensions.Configuration; -using Semver; using System.Reflection; namespace Aspire.Cli.Packaging; @@ -92,13 +91,13 @@ public Task> GetChannelsAsync(CancellationToken canc return null; } - var versionPrefix = GetStagingVersionPrefix(); + var pinnedVersion = GetStagingPinnedVersion(useSharedFeed); var stagingChannel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Staging, stagingQuality, new[] { new PackageMapping("Aspire*", stagingFeedUrl), new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") - }, nuGetPackageCache, configureGlobalPackagesFolder: !useSharedFeed, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/rc/daily", versionPrefix: versionPrefix); + }, nuGetPackageCache, configureGlobalPackagesFolder: !useSharedFeed, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/rc/daily", pinnedVersion: pinnedVersion); return stagingChannel; } @@ -166,20 +165,18 @@ private PackageChannelQuality GetStagingQuality() return PackageChannelQuality.Stable; } - private SemVersion? GetStagingVersionPrefix() + private string? GetStagingPinnedVersion(bool useSharedFeed) { - var versionPrefixValue = configuration["stagingVersionPrefix"]; - if (string.IsNullOrEmpty(versionPrefixValue)) + // Only pin versions when using the shared feed and the config flag is set + var pinToCliVersion = configuration["stagingPinToCliVersion"]; + if (!useSharedFeed || !string.Equals(pinToCliVersion, "true", StringComparison.OrdinalIgnoreCase)) { return null; } - // Parse "Major.Minor" format (e.g., "13.2") as a SemVersion for comparison - if (SemVersion.TryParse($"{versionPrefixValue}.0", SemVersionStyles.Strict, out var semVersion)) - { - return semVersion; - } - - return null; + // Get the CLI's own version and strip build metadata (+hash) + var cliVersion = Utils.VersionHelper.GetDefaultTemplateVersion(); + var plusIndex = cliVersion.IndexOf('+'); + return plusIndex >= 0 ? cliVersion[..plusIndex] : cliVersion; } } diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index bb97a2f7be5..465b26ef4e4 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -47,7 +47,7 @@ public async Task AddCommandInteractiveFlowSmokeTest() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var dockerPackage = new NuGetPackage() { @@ -122,7 +122,7 @@ public async Task AddCommandDoesNotPromptForIntegrationArgumentIfSpecifiedOnComm options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var dockerPackage = new NuGetPackage() { @@ -205,7 +205,7 @@ public async Task AddCommandDoesNotPromptForVersionIfSpecifiedOnCommandLine() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var dockerPackage = new NuGetPackage() { @@ -284,7 +284,7 @@ public async Task AddCommandPromptsForDisambiguation() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var dockerPackage = new NuGetPackage() { @@ -365,7 +365,7 @@ public async Task AddCommandPreservesSourceArgumentInBothCommands() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var redisPackage = new NuGetPackage() { @@ -428,7 +428,7 @@ public async Task AddCommand_EmptyPackageList_DisplaysErrorMessage() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { return (0, Array.Empty()); }; @@ -482,7 +482,7 @@ public async Task AddCommand_NoMatchingPackages_DisplaysNoMatchesMessage() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var dockerPackage = new NuGetPackage() { @@ -711,7 +711,7 @@ public async Task AddCommand_WithoutHives_UsesImplicitChannelWithoutPrompting() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var redisPackage = new NuGetPackage() { @@ -801,7 +801,7 @@ public async Task AddCommand_WithStartsWith_FindsMatchUsingFuzzySearch() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var postgresPackage = new NuGetPackage() { @@ -878,7 +878,7 @@ public async Task AddCommand_WithPartialMatch_FiltersUsingFuzzySearch() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var postgresPackage = new NuGetPackage() { @@ -954,7 +954,7 @@ public async Task AddCommand_WithTypo_FindsMatchUsingFuzzySearch() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var appContainersPackage = new NuGetPackage() { diff --git a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs index 797081cd04e..985ffa8bf4b 100644 --- a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs @@ -255,7 +255,7 @@ public async Task InitCommand_WithSingleFileAppHost_DoesNotPromptForProjectNameO }; // Mock package search for template version selection - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetConfigFile, useCache, invocationOptions, cancellationToken, _) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetConfigFile, useCache, invocationOptions, cancellationToken) => { var package = new Aspire.Shared.NuGetPackageCli { @@ -338,7 +338,7 @@ public Task> GetChannelsAsync(CancellationToken canc private sealed class FakeNuGetPackageCache : INuGetPackageCache { - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) { var package = new Aspire.Shared.NuGetPackageCli { @@ -359,7 +359,7 @@ private sealed class FakeNuGetPackageCache : INuGetPackageCache return Task.FromResult>(Array.Empty()); } - public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken, bool exactMatch = false) + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) { return Task.FromResult>(Array.Empty()); } @@ -462,7 +462,7 @@ public Task> GetChannelsAsync(CancellationToken canc private sealed class FakeNuGetPackageCacheWithTracking(string channelName, Action onChannelUsed) : INuGetPackageCache { - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) { onChannelUsed(channelName); var package = new Aspire.Shared.NuGetPackageCli @@ -484,7 +484,7 @@ private sealed class FakeNuGetPackageCacheWithTracking(string channelName, Actio return Task.FromResult>(Array.Empty()); } - public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken, bool exactMatch = false) + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) { return Task.FromResult>(Array.Empty()); } diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 1c8a7a0eefc..adb1530ad89 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -51,7 +51,7 @@ public async Task NewCommandInteractiveFlowSmokeTest() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -108,7 +108,7 @@ public async Task NewCommandDerivesOutputPathFromProjectNameForStarterTemplate() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -161,7 +161,7 @@ public async Task NewCommandDoesNotPromptForProjectNameIfSpecifiedOnCommandLine( options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -216,7 +216,7 @@ public async Task NewCommandDoesNotPromptForOutputPathIfSpecifiedOnCommandLine() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -429,7 +429,7 @@ public async Task NewCommandDoesNotPromptForTemplateIfSpecifiedOnCommandLine() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -483,7 +483,7 @@ public async Task NewCommandDoesNotPromptForTemplateVersionIfSpecifiedOnCommandL options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetConfigFile, useCache, options, cancellationToken, _) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetConfigFile, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -528,7 +528,7 @@ public async Task NewCommand_EmptyPackageList_DisplaysErrorMessage() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => { + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { return (0, Array.Empty()); }; return runner; @@ -562,7 +562,7 @@ public async Task NewCommand_WhenCertificateServiceThrows_ReturnsNonZeroExitCode options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -615,7 +615,7 @@ public async Task NewCommandWithExitCode73ShowsUserFriendlyError() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -691,7 +691,7 @@ public async Task NewCommandPromptsForTemplateVersionBeforeTemplateOptions() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetConfigFile, useCache, options, cancellationToken, _) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetConfigFile, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -773,7 +773,7 @@ public async Task NewCommandEscapesMarkupInProjectNameAndOutputPath() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -818,7 +818,7 @@ public async Task NewCommandNonInteractiveDoesNotPrompt() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken, _) => + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -978,7 +978,7 @@ internal sealed class NewCommandTestFakeNuGetPackageCache : INuGetPackageCache { public Func>>? GetTemplatePackagesAsyncCallback { get; set; } - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) { if (GetTemplatePackagesAsyncCallback is not null) { @@ -1004,7 +1004,7 @@ public Task> GetCliPackagesAsync(DirectoryInfo working return Task.FromResult>(Array.Empty()); } - public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken, bool exactMatch = false) + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) { return Task.FromResult>(Array.Empty()); } diff --git a/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs b/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs index aa672216259..27505f5e9a3 100644 --- a/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs +++ b/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs @@ -34,7 +34,7 @@ public MockNuGetPackageCache(NuGetPackageCli[]? packages = null) _packages = packages ?? []; } - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) => Task.FromResult>([]); public Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) @@ -43,7 +43,7 @@ public Task> GetIntegrationPackagesAsync(DirectoryI 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, bool exactMatch = false) + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) => Task.FromResult>([]); } diff --git a/tests/Aspire.Cli.Tests/NuGet/NuGetPackageCacheTests.cs b/tests/Aspire.Cli.Tests/NuGet/NuGetPackageCacheTests.cs index 403ab41c567..27ef3717480 100644 --- a/tests/Aspire.Cli.Tests/NuGet/NuGetPackageCacheTests.cs +++ b/tests/Aspire.Cli.Tests/NuGet/NuGetPackageCacheTests.cs @@ -21,7 +21,7 @@ public async Task NonAspireCliPackagesWillNotBeConsidered() configure.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _, _) => + runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _) => { // Simulate a search that returns packages that do not match Aspire.Cli return (0, [ @@ -54,7 +54,7 @@ public async Task DeprecatedPackagesAreFilteredByDefault() configure.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _, _) => + runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _) => { // Simulate a search that returns both regular and deprecated packages return (0, [ @@ -92,7 +92,7 @@ public async Task DeprecatedPackagesAreIncludedWhenShowDeprecatedPackagesEnabled configure.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _, _) => + runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _) => { // Simulate a search that returns both regular and deprecated packages return (0, [ @@ -127,7 +127,7 @@ public async Task CustomFilterBypassesDeprecatedPackageFiltering() configure.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _, _) => + runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _) => { // Simulate a search that returns both regular and deprecated packages return (0, [ @@ -171,7 +171,7 @@ public async Task DeprecatedPackageFilteringIsCaseInsensitive() configure.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _, _) => + runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _) => { // Test different casing of deprecated package name return (0, [ diff --git a/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs b/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs index cd075d51bad..77fd8b738aa 100644 --- a/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs @@ -24,10 +24,10 @@ public NuGetConfigMergerSnapshotTests(ITestOutputHelper output) private sealed class FakeNuGetPackageCache : INuGetPackageCache { - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) => Task.FromResult>([]); + 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, bool exactMatch = false) => Task.FromResult>([]); + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) => Task.FromResult>([]); } private sealed class FakeFeatures : IFeatures diff --git a/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerTests.cs b/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerTests.cs index 975d20c1222..3f648c69a8c 100644 --- a/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerTests.cs @@ -28,7 +28,7 @@ private static async Task WriteConfigAsync(DirectoryInfo dir, string c private sealed class FakeNuGetPackageCache : INuGetPackageCache { - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) { _ = workingDirectory; _ = prerelease; _ = nugetConfigFile; _ = cancellationToken; return Task.FromResult>([]); } @@ -40,7 +40,7 @@ private sealed class FakeNuGetPackageCache : INuGetPackageCache { _ = workingDirectory; _ = prerelease; _ = nugetConfigFile; _ = cancellationToken; return Task.FromResult>([]); } - public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken, bool exactMatch = false) + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) { _ = workingDirectory; _ = packageId; _ = filter; _ = prerelease; _ = nugetConfigFile; _ = useCache; _ = cancellationToken; return Task.FromResult>([]); } diff --git a/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs index 212c0b8da45..5277c4323e7 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs @@ -11,10 +11,10 @@ public class PackageChannelTests { private sealed class FakeNuGetPackageCache : INuGetPackageCache { - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) => Task.FromResult>([]); + 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, bool exactMatch = false) => Task.FromResult>([]); + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) => Task.FromResult>([]); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs index a6d715cda6c..51ce56b6907 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs @@ -16,10 +16,10 @@ public class PackagingServiceTests(ITestOutputHelper outputHelper) private sealed class FakeNuGetPackageCache : INuGetPackageCache { - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) => Task.FromResult>([]); + 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, bool exactMatch = false) => Task.FromResult>([]); + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) => Task.FromResult>([]); } private sealed class TestFeatures : IFeatures @@ -637,7 +637,7 @@ public async Task NuGetConfigMerger_WhenStagingUsesSharedFeed_DoesNotAddGlobalPa } [Fact] - public async Task GetChannelsAsync_WhenStagingVersionPrefixSet_ChannelHasVersionPrefix() + public async Task GetChannelsAsync_WhenStagingPinToCliVersionSet_ChannelHasPinnedVersion() { // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -652,8 +652,8 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixSet_ChannelHasVersion var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["overrideStagingFeed"] = "https://example.com/nuget/v3/index.json", - ["stagingVersionPrefix"] = "13.2" + ["overrideStagingQuality"] = "Prerelease", + ["stagingPinToCliVersion"] = "true" }) .Build(); @@ -664,13 +664,13 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixSet_ChannelHasVersion // Assert var stagingChannel = channels.First(c => c.Name == "staging"); - Assert.NotNull(stagingChannel.VersionPrefix); - Assert.Equal(13, stagingChannel.VersionPrefix!.Major); - Assert.Equal(2, stagingChannel.VersionPrefix.Minor); + Assert.NotNull(stagingChannel.PinnedVersion); + // Should not contain build metadata (+hash) + Assert.DoesNotContain("+", stagingChannel.PinnedVersion); } [Fact] - public async Task GetChannelsAsync_WhenStagingVersionPrefixNotSet_ChannelHasNoVersionPrefix() + public async Task GetChannelsAsync_WhenStagingPinToCliVersionNotSet_ChannelHasNoPinnedVersion() { // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -685,7 +685,8 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixNotSet_ChannelHasNoVe var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["overrideStagingFeed"] = "https://example.com/nuget/v3/index.json" + ["overrideStagingQuality"] = "Prerelease" + // No stagingPinToCliVersion }) .Build(); @@ -696,13 +697,13 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixNotSet_ChannelHasNoVe // Assert var stagingChannel = channels.First(c => c.Name == "staging"); - Assert.Null(stagingChannel.VersionPrefix); + Assert.Null(stagingChannel.PinnedVersion); } [Fact] - public async Task GetChannelsAsync_WhenStagingVersionPrefixInvalid_ChannelHasNoVersionPrefix() + public async Task GetChannelsAsync_WhenStagingPinToCliVersionSetButNotSharedFeed_ChannelHasNoPinnedVersion() { - // Arrange + // Arrange - pin is set but explicit feed override means not using shared feed using var workspace = TemporaryWorkspace.Create(outputHelper); var tempDir = workspace.WorkspaceRoot; var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); @@ -716,7 +717,7 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixInvalid_ChannelHasNoV .AddInMemoryCollection(new Dictionary { ["overrideStagingFeed"] = "https://example.com/nuget/v3/index.json", - ["stagingVersionPrefix"] = "not-a-version" + ["stagingPinToCliVersion"] = "true" }) .Build(); @@ -727,15 +728,16 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixInvalid_ChannelHasNoV // Assert var stagingChannel = channels.First(c => c.Name == "staging"); - Assert.Null(stagingChannel.VersionPrefix); + // With explicit feed override, useSharedFeed is false, so pinning is not activated + Assert.Null(stagingChannel.PinnedVersion); } /// - /// Simulates the dotnet9 shared feed which contains packages from multiple version lines. - /// Verifies that version prefix filtering correctly selects only the requested major.minor. + /// Verifies that when pinned to CLI version, GetTemplatePackagesAsync returns a synthetic result + /// with the pinned version, bypassing actual NuGet search. /// [Fact] - public async Task StagingChannel_WithVersionPrefix_FiltersTemplatePackagesToMatchingMajorMinor() + public async Task StagingChannel_WithPinnedVersion_ReturnsSyntheticTemplatePackage() { // Arrange - simulate a shared feed that has packages from both 13.2 and 13.3 version lines var fakeCache = new FakeNuGetPackageCacheWithPackages( @@ -760,7 +762,7 @@ public async Task StagingChannel_WithVersionPrefix_FiltersTemplatePackagesToMatc .AddInMemoryCollection(new Dictionary { ["overrideStagingQuality"] = "Prerelease", - ["stagingVersionPrefix"] = "13.2" + ["stagingPinToCliVersion"] = "true" }) .Build(); @@ -771,7 +773,7 @@ public async Task StagingChannel_WithVersionPrefix_FiltersTemplatePackagesToMatc var stagingChannel = channels.First(c => c.Name == "staging"); var templatePackages = await stagingChannel.GetTemplatePackagesAsync(tempDir, CancellationToken.None).DefaultTimeout(); - // Assert + // Assert - should return exactly one synthetic package with the CLI's pinned version var packageList = templatePackages.ToList(); outputHelper.WriteLine($"Template packages returned: {packageList.Count}"); foreach (var p in packageList) @@ -779,22 +781,70 @@ public async Task StagingChannel_WithVersionPrefix_FiltersTemplatePackagesToMatc outputHelper.WriteLine($" {p.Id} {p.Version}"); } - Assert.NotEmpty(packageList); - Assert.All(packageList, p => + Assert.Single(packageList); + Assert.Equal("Aspire.ProjectTemplates", packageList[0].Id); + Assert.Equal(stagingChannel.PinnedVersion, packageList[0].Version); + // Pinned version should not contain build metadata + Assert.DoesNotContain("+", packageList[0].Version!); + } + + /// + /// Verifies that when pinned to CLI version, GetIntegrationPackagesAsync discovers packages + /// from the feed but overrides their version to the pinned version. + /// + [Fact] + public async Task StagingChannel_WithPinnedVersion_OverridesIntegrationPackageVersions() + { + // Arrange - integration packages with various versions + var fakeCache = new FakeNuGetPackageCacheWithPackages( + [ + new() { Id = "Aspire.Hosting.Redis", Version = "13.3.0-preview.1.26201.1", Source = "dotnet9" }, + new() { Id = "Aspire.Hosting.PostgreSQL", Version = "13.3.0-preview.1.26201.1", Source = "dotnet9" }, + ]); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingQuality"] = "Prerelease", + ["stagingPinToCliVersion"] = "true" + }) + .Build(); + + var packagingService = new PackagingService(executionContext, fakeCache, features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + var stagingChannel = channels.First(c => c.Name == "staging"); + var integrationPackages = await stagingChannel.GetIntegrationPackagesAsync(tempDir, CancellationToken.None).DefaultTimeout(); + + // Assert - should discover both packages but with pinned version + var packageList = integrationPackages.ToList(); + outputHelper.WriteLine($"Integration packages returned: {packageList.Count}"); + foreach (var p in packageList) { - var semVer = Semver.SemVersion.Parse(p.Version); - Assert.Equal(13, semVer.Major); - Assert.Equal(2, semVer.Minor); - }); - // Should have exactly the two 13.2 prerelease packages + outputHelper.WriteLine($" {p.Id} {p.Version}"); + } + Assert.Equal(2, packageList.Count); + Assert.All(packageList, p => Assert.Equal(stagingChannel.PinnedVersion, p.Version)); + Assert.Contains(packageList, p => p.Id == "Aspire.Hosting.Redis"); + Assert.Contains(packageList, p => p.Id == "Aspire.Hosting.PostgreSQL"); } /// - /// Verifies that without a version prefix, all prerelease packages from the feed are returned. + /// Verifies that without pinning, all prerelease packages from the feed are returned as-is. /// [Fact] - public async Task StagingChannel_WithoutVersionPrefix_ReturnsAllPrereleasePackages() + public async Task StagingChannel_WithoutPinnedVersion_ReturnsAllPrereleasePackages() { // Arrange var fakeCache = new FakeNuGetPackageCacheWithPackages( @@ -817,7 +867,7 @@ public async Task StagingChannel_WithoutVersionPrefix_ReturnsAllPrereleasePackag .AddInMemoryCollection(new Dictionary { ["overrideStagingQuality"] = "Prerelease" - // No stagingVersionPrefix — should return all prerelease + // No stagingPinToCliVersion — should return all prerelease }) .Build(); @@ -838,13 +888,13 @@ public async Task StagingChannel_WithoutVersionPrefix_ReturnsAllPrereleasePackag // Should return only the prerelease ones (quality filter), but both 13.3 and 13.2 Assert.Equal(2, packageList.Count); - Assert.Contains(packageList, p => p.Version.StartsWith("13.3")); - Assert.Contains(packageList, p => p.Version.StartsWith("13.2")); + Assert.Contains(packageList, p => p.Version!.StartsWith("13.3")); + Assert.Contains(packageList, p => p.Version!.StartsWith("13.2")); } private sealed class FakeNuGetPackageCacheWithPackages(List packages) : INuGetPackageCache { - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) { // Simulate what the real cache does: filter by prerelease flag var filtered = prerelease @@ -859,7 +909,7 @@ private sealed class FakeNuGetPackageCacheWithPackages(List> 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, bool exactMatch = false) + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) => GetTemplatePackagesAsync(workingDirectory, prerelease, nugetConfigFile, cancellationToken); } } diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs index 00010aa6cde..7ebdea5e7fd 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs @@ -371,7 +371,7 @@ public Task> GetChannelsAsync(CancellationToken canc private sealed class FakeNuGetPackageCache : INuGetPackageCache { - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) => Task.FromResult>([]); public Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) @@ -380,7 +380,7 @@ public Task> GetIntegrationPackagesAsync(DirectoryI 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, bool exactMatch = false) + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) => Task.FromResult>([]); } diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs index a59a8d069bf..0ccafc41816 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs @@ -49,7 +49,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => { var packages = new List(); @@ -160,7 +160,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => { var packages = new List(); @@ -291,7 +291,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, prerelease, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, prerelease, _, _, _, _, _, _) => { var packages = new List(); @@ -444,7 +444,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => { var packages = new List(); @@ -604,7 +604,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => { var packages = new List(); @@ -723,7 +723,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => { var packages = new List(); @@ -825,7 +825,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => { var packages = new List(); @@ -959,7 +959,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => { var packages = new List(); @@ -1075,7 +1075,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => { var packages = new List(); @@ -1196,7 +1196,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => { var packages = new List(); @@ -1309,7 +1309,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => { var packages = new List(); @@ -1405,7 +1405,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => { var packages = new List(); @@ -1514,7 +1514,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => { var packages = new List(); @@ -1599,7 +1599,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => { var packages = new List(); @@ -1675,7 +1675,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => { var packages = new List(); @@ -1759,7 +1759,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => { var packages = new List(); @@ -1842,7 +1842,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => { var packages = new List(); @@ -1922,7 +1922,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => { var packages = new List(); @@ -2012,7 +2012,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => { var packages = new List(); @@ -2091,7 +2091,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => { var packages = new List(); @@ -2169,7 +2169,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => { var packages = new List(); @@ -2297,7 +2297,7 @@ await File.WriteAllTextAsync( { return new TestDotNetCliRunner() { - SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => + SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _) => { var packages = new List(); diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index 47dcb4c27de..bbd51fec810 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -30,7 +30,7 @@ public DotNetTemplateFactoryTests(ITestOutputHelper outputHelper) private sealed class FakeNuGetPackageCache : INuGetPackageCache { - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) { _ = workingDirectory; _ = prerelease; _ = nugetConfigFile; _ = cancellationToken; return Task.FromResult>([]); } @@ -42,7 +42,7 @@ private sealed class FakeNuGetPackageCache : INuGetPackageCache { _ = workingDirectory; _ = prerelease; _ = nugetConfigFile; _ = cancellationToken; return Task.FromResult>([]); } - public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken, bool exactMatch = false) + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) { _ = workingDirectory; _ = packageId; _ = filter; _ = prerelease; _ = nugetConfigFile; _ = useCache; _ = cancellationToken; return Task.FromResult>([]); } @@ -469,7 +469,7 @@ public Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo proje public Task AddProjectReferenceAsync(FileInfo projectFile, FileInfo referencedProjectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task<(int ExitCode, NuGetPackageCli[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken, bool exactMatch = false) + public Task<(int ExitCode, NuGetPackageCli[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task<(int ExitCode, bool IsAspireHost, string? AspireHostingVersion)> GetAppHostInformationAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeNuGetPackageCache.cs b/tests/Aspire.Cli.Tests/TestServices/FakeNuGetPackageCache.cs index 59660b2ac22..b628d68ae9e 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeNuGetPackageCache.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeNuGetPackageCache.cs @@ -8,7 +8,7 @@ namespace Aspire.Cli.Tests.TestServices; internal sealed class FakeNuGetPackageCache : INuGetPackageCache { - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) => Task.FromResult>([]); public Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) @@ -17,6 +17,6 @@ public Task> GetIntegrationPackagesAsync(DirectoryInfo 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, bool exactMatch = false) + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) => Task.FromResult>([]); } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs index 36f64d0516a..94506b94fd8 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs @@ -20,7 +20,7 @@ internal sealed class TestDotNetCliRunner : IDotNetCliRunner public Func? InstallTemplateAsyncCallback { get; set; } public Func? NewProjectAsyncCallback { get; set; } public Func?, TaskCompletionSource?, DotNetCliRunnerInvocationOptions, CancellationToken, Task>? RunAsyncCallback { get; set; } - public Func? SearchPackagesAsyncCallback { get; set; } + public Func? SearchPackagesAsyncCallback { get; set; } public Func Projects)>? GetSolutionProjectsAsyncCallback { get; set; } public Func? AddProjectReferenceAsyncCallback { get; set; } @@ -98,10 +98,10 @@ public Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, bool n : throw new NotImplementedException(); } - public Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken, bool exactMatch = false) + public Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { return SearchPackagesAsyncCallback != null - ? Task.FromResult(SearchPackagesAsyncCallback(workingDirectory, query, prerelease, take, skip, nugetConfigFile, useCache, options, cancellationToken, exactMatch)) + ? Task.FromResult(SearchPackagesAsyncCallback(workingDirectory, query, prerelease, take, skip, nugetConfigFile, useCache, options, cancellationToken)) : throw new NotImplementedException(); } diff --git a/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs b/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs index 6e0bf9e953d..77deca3237b 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs @@ -293,7 +293,7 @@ public void SetMockCliPackages(IEnumerable packages) _cliPackages = packages; } - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken, bool exactMatch = false) + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) { return Task.FromResult(Enumerable.Empty()); } @@ -308,7 +308,7 @@ public Task> GetCliPackagesAsync(DirectoryInfo working return Task.FromResult(_cliPackages); } - public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken, bool exactMatch = false) + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) { return Task.FromResult(Enumerable.Empty()); } From bfd2dce814651966db3f87045ef90a46dd0255b9 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Thu, 12 Feb 2026 13:09:12 -0800 Subject: [PATCH 3/6] Mark staging override settings as internal in JSON schemas --- extension/schemas/aspire-global-settings.schema.json | 6 +++--- extension/schemas/aspire-settings.schema.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/extension/schemas/aspire-global-settings.schema.json b/extension/schemas/aspire-global-settings.schema.json index 3a6b4eebb98..bf1ec2494b1 100644 --- a/extension/schemas/aspire-global-settings.schema.json +++ b/extension/schemas/aspire-global-settings.schema.json @@ -296,11 +296,11 @@ "type": "string" }, "overrideStagingFeed": { - "description": "Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.", + "description": "[Internal] Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.", "type": "string" }, "overrideStagingQuality": { - "description": "Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".", + "description": "[Internal] Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".", "type": "string", "enum": [ "Stable", @@ -309,7 +309,7 @@ ] }, "stagingPinToCliVersion": { - "description": "When set to \"true\" and using the staging channel with Prerelease quality on the shared feed, all template and integration packages are pinned to the exact version of the installed CLI. This bypasses NuGet search entirely, ensuring version consistency.", + "description": "[Internal] When set to \"true\" and using the staging channel with Prerelease quality on the shared feed, all template and integration packages are pinned to the exact version of the installed CLI. This bypasses NuGet search entirely, ensuring version consistency.", "type": "string", "enum": [ "true", diff --git a/extension/schemas/aspire-settings.schema.json b/extension/schemas/aspire-settings.schema.json index c14933b5577..bd71628f932 100644 --- a/extension/schemas/aspire-settings.schema.json +++ b/extension/schemas/aspire-settings.schema.json @@ -300,11 +300,11 @@ "type": "string" }, "overrideStagingFeed": { - "description": "Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.", + "description": "[Internal] Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.", "type": "string" }, "overrideStagingQuality": { - "description": "Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".", + "description": "[Internal] Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".", "type": "string", "enum": [ "Stable", @@ -313,7 +313,7 @@ ] }, "stagingPinToCliVersion": { - "description": "When set to \"true\" and using the staging channel with Prerelease quality on the shared feed, all template and integration packages are pinned to the exact version of the installed CLI. This bypasses NuGet search entirely, ensuring version consistency.", + "description": "[Internal] When set to \"true\" and using the staging channel with Prerelease quality on the shared feed, all template and integration packages are pinned to the exact version of the installed CLI. This bypasses NuGet search entirely, ensuring version consistency.", "type": "string", "enum": [ "true", From 85f6989efb2662e70857e7f0b516488ccde91e21 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Thu, 12 Feb 2026 13:16:29 -0800 Subject: [PATCH 4/6] Add E2E test for staging channel config and channel switching --- .../StagingChannelTests.cs | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs new file mode 100644 index 00000000000..3ed2ec1c576 --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs @@ -0,0 +1,172 @@ +// 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.EndToEnd.Tests.Helpers; +using Aspire.Cli.Tests.Utils; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// End-to-end tests for staging channel configuration and self-update channel switching. +/// Verifies that staging settings (overrideStagingQuality, stagingPinToCliVersion) are +/// correctly persisted and that aspire update --self saves the channel to global settings. +/// +public sealed class StagingChannelTests(ITestOutputHelper output) +{ + [Fact] + public async Task StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels() + { + var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels)); + + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (isCI) + { + sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireCliEnvironment(counter); + sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); + } + + // Step 1: Configure staging channel settings via aspire config set + // Enable the staging channel feature flag + sequenceBuilder + .Type("aspire config set features.stagingChannelEnabled true -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Set quality to Prerelease (triggers shared feed mode) + sequenceBuilder + .Type("aspire config set overrideStagingQuality Prerelease -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Enable pinned version mode + sequenceBuilder + .Type("aspire config set stagingPinToCliVersion true -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Set channel to staging + sequenceBuilder + .Type("aspire config set channel staging -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 2: Verify the settings were persisted in the global settings file + var settingsFilePattern = new CellPatternSearcher() + .Find("stagingPinToCliVersion"); + + sequenceBuilder + .ClearScreen(counter) + .Type("cat ~/.aspire/settings.json") + .Enter() + .WaitUntil(s => settingsFilePattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .WaitForSuccessPrompt(counter); + + // Step 3: Verify aspire config get returns the correct values + var stagingChannelPattern = new CellPatternSearcher() + .Find("staging"); + + sequenceBuilder + .ClearScreen(counter) + .Type("aspire config get channel -g") + .Enter() + .WaitUntil(s => stagingChannelPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .WaitForSuccessPrompt(counter); + + // Step 4: Verify the CLI version is available (basic smoke test that the CLI works with these settings) + sequenceBuilder + .ClearScreen(counter) + .Type("aspire --version") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 5: Switch channel to stable via config set (simulating what update --self does) + sequenceBuilder + .Type("aspire config set channel stable -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 6: Verify channel was changed to stable + var stableChannelPattern = new CellPatternSearcher() + .Find("stable"); + + sequenceBuilder + .ClearScreen(counter) + .Type("aspire config get channel -g") + .Enter() + .WaitUntil(s => stableChannelPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .WaitForSuccessPrompt(counter); + + // Step 7: Switch back to staging + sequenceBuilder + .Type("aspire config set channel staging -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 8: Verify channel is staging again and staging settings are still present + sequenceBuilder + .ClearScreen(counter) + .Type("aspire config get channel -g") + .Enter() + .WaitUntil(s => stagingChannelPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .WaitForSuccessPrompt(counter); + + // Verify the staging-specific settings survived the channel switch + var prereleasePattern = new CellPatternSearcher() + .Find("Prerelease"); + + sequenceBuilder + .ClearScreen(counter) + .Type("aspire config get overrideStagingQuality -g") + .Enter() + .WaitUntil(s => prereleasePattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .WaitForSuccessPrompt(counter); + + // Clean up: remove staging settings to avoid polluting other tests + sequenceBuilder + .Type("aspire config delete features.stagingChannelEnabled -g") + .Enter() + .WaitForSuccessPrompt(counter) + .Type("aspire config delete overrideStagingQuality -g") + .Enter() + .WaitForSuccessPrompt(counter) + .Type("aspire config delete stagingPinToCliVersion -g") + .Enter() + .WaitForSuccessPrompt(counter) + .Type("aspire config delete channel -g") + .Enter() + .WaitForSuccessPrompt(counter); + + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + + await pendingRun; + } +} From 8df0be1a97b8e3495efe9e837228709662632265 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Thu, 12 Feb 2026 15:39:55 -0800 Subject: [PATCH 5/6] Fix E2E test: use correct global settings path (~/.aspire/globalsettings.json) --- tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs index 3ed2ec1c576..fb0547c7273 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs @@ -79,7 +79,7 @@ public async Task StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels() sequenceBuilder .ClearScreen(counter) - .Type("cat ~/.aspire/settings.json") + .Type("cat ~/.aspire/globalsettings.json") .Enter() .WaitUntil(s => settingsFilePattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) .WaitForSuccessPrompt(counter); From 6a062748591a6cb945159a17aab0b8c65dbdab49 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Thu, 12 Feb 2026 16:11:53 -0800 Subject: [PATCH 6/6] Fix E2E test: config get doesn't support -g flag --- tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs index fb0547c7273..14498306c9d 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs @@ -90,7 +90,7 @@ public async Task StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels() sequenceBuilder .ClearScreen(counter) - .Type("aspire config get channel -g") + .Type("aspire config get channel") .Enter() .WaitUntil(s => stagingChannelPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) .WaitForSuccessPrompt(counter); @@ -114,7 +114,7 @@ public async Task StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels() sequenceBuilder .ClearScreen(counter) - .Type("aspire config get channel -g") + .Type("aspire config get channel") .Enter() .WaitUntil(s => stableChannelPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) .WaitForSuccessPrompt(counter); @@ -128,7 +128,7 @@ public async Task StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels() // Step 8: Verify channel is staging again and staging settings are still present sequenceBuilder .ClearScreen(counter) - .Type("aspire config get channel -g") + .Type("aspire config get channel") .Enter() .WaitUntil(s => stagingChannelPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) .WaitForSuccessPrompt(counter); @@ -139,7 +139,7 @@ public async Task StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels() sequenceBuilder .ClearScreen(counter) - .Type("aspire config get overrideStagingQuality -g") + .Type("aspire config get overrideStagingQuality") .Enter() .WaitUntil(s => prereleasePattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) .WaitForSuccessPrompt(counter);