From 17b3a75b2f33fcbabafe467453e23338cc223427 Mon Sep 17 00:00:00 2001 From: Muckenbatscher Date: Mon, 23 Mar 2026 23:54:48 +0100 Subject: [PATCH 1/4] Don't show interactive flow when the --version flag is supplied to aspire add The interactive flow would be initiated when a package was found but the version passed by the --version flag is not the latest version of the integration nuget package. Now fetch all the versions for the selected package from all configured channels. If the CLI supplied version is found in the list of the available versions skip the interactive flow and use the CLI supplied version. --- src/Aspire.Cli/Commands/AddCommand.cs | 56 ++++++++++---- src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 28 ++++--- .../NuGet/BundleNuGetPackageCache.cs | 18 +++++ src/Aspire.Cli/NuGet/NuGetPackageCache.cs | 54 ++++++++++++- src/Aspire.Cli/Packaging/PackageChannel.cs | 77 +++++++++++++++++-- 5 files changed, 199 insertions(+), 34 deletions(-) diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index fc6f1db8282..ea20c3b5e55 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -91,8 +91,6 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } } - var source = parseResult.GetValue(s_sourceOption); - // For non-.NET projects, read the channel from the local Aspire configuration if available. // Unlike .NET projects which have a nuget.config, polyglot apphosts persist the channel // in aspire.config.json (or the legacy settings.json during migration). @@ -162,8 +160,6 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => throw new EmptyChoicesException(AddCommandStrings.NoIntegrationPackagesFound); } - var version = parseResult.GetValue(s_versionOption); - var packagesWithShortName = packagesWithChannels.Select(GenerateFriendlyName).OrderBy(p => p.FriendlyName, new CommunityToolkitFirstComparer()); if (!packagesWithShortName.Any()) @@ -172,7 +168,8 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => return ExitCodeConstants.FailedToAddPackage; } - var filteredPackagesWithShortName = packagesWithShortName.Where(p => p.FriendlyName == integrationName || p.Package.Id == integrationName); + var filteredPackagesWithShortName = packagesWithShortName + .Where(p => p.FriendlyName == integrationName || p.Package.Id == integrationName); if (!filteredPackagesWithShortName.Any() && integrationName is not null) { @@ -194,19 +191,22 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => .ToList(); } + var version = parseResult.GetValue(s_versionOption); + // If we didn't match any, show a complete list. If we matched one, and its // an exact match, then we still prompt, but it will only prompt for // the version. If there is more than one match then we prompt. var selectedNuGetPackage = filteredPackagesWithShortName.Count() switch { - 0 => await GetPackageByInteractiveFlowWithNoMatchesMessage(packagesWithShortName, integrationName, cancellationToken), + 0 => await GetPackageByInteractiveFlowWithNoMatchesMessage(effectiveAppHostProjectFile.Directory!, packagesWithShortName, integrationName, cancellationToken), 1 => filteredPackagesWithShortName.First().Package.Version == version ? filteredPackagesWithShortName.First() - : await GetPackageByInteractiveFlow(filteredPackagesWithShortName, null, cancellationToken), - > 1 => await GetPackageByInteractiveFlow(filteredPackagesWithShortName, version, cancellationToken), + : await GetPackageByInteractiveFlow(effectiveAppHostProjectFile.Directory!, filteredPackagesWithShortName, null, cancellationToken), + > 1 => await GetPackageByInteractiveFlow(effectiveAppHostProjectFile.Directory!, filteredPackagesWithShortName, version, cancellationToken), _ => throw new InvalidOperationException(AddCommandStrings.UnexpectedNumberOfPackagesFound) }; + var source = parseResult.GetValue(s_sourceOption); // Add the package using the appropriate project handler context = new AddPackageContext { @@ -280,7 +280,24 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => } } - private async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> GetPackageByInteractiveFlow(IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> possiblePackages, string? preferredVersion, CancellationToken cancellationToken) + private static async Task> GetAllPackageVersions(DirectoryInfo workingDirectory, IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> possiblePackages, CancellationToken cancellationToken) + { + var distinctPackageIds = possiblePackages.DistinctBy(package => package.Package.Id); + var channels = possiblePackages.Select(package => package.Channel); + + var versions = new List<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)>(); + foreach (var channel in channels) + { + foreach (var package in distinctPackageIds) + { + var packages = await channel.GetPackageVersionsAsync(package.Package.Id, workingDirectory, cancellationToken); + versions.AddRange(packages.Select(p => (FriendlyName: package.FriendlyName, Package: p, Channel: channel))); + } + } + return versions; + } + + private async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> GetPackageByInteractiveFlow(DirectoryInfo workingDirectory, IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> possiblePackages, string? preferredVersion, CancellationToken cancellationToken) { var distinctPackages = possiblePackages.DistinctBy(p => p.Package.Id); @@ -298,10 +315,21 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => // If any of the package versions are an exact match for the preferred version // then we can skip the version prompt and just use that version. - if (packageVersions.Any(p => p.Package.Version == preferredVersion)) + if (!string.IsNullOrEmpty(preferredVersion)) { - var preferredVersionPackage = packageVersions.First(p => p.Package.Version == preferredVersion); - return preferredVersionPackage; + if (packageVersions.Any(p => p.Package.Version == preferredVersion)) + { + var preferredVersionPackage = packageVersions.First(p => p.Package.Version == preferredVersion); + return preferredVersionPackage; + } + else // search all versions of the selected package for a match + { + var allVersions = await GetAllPackageVersions(workingDirectory, possiblePackages, cancellationToken); + if (allVersions.Any(packageVersion => packageVersion.Package.Version == preferredVersion)) + { + return allVersions.First(package => package.Package.Version == preferredVersion); + } + } } // In non-interactive mode, prefer the implicit/default channel first to keep @@ -321,14 +349,14 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => return version; } - private async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> GetPackageByInteractiveFlowWithNoMatchesMessage(IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> possiblePackages, string? searchTerm, CancellationToken cancellationToken) + private async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> GetPackageByInteractiveFlowWithNoMatchesMessage(DirectoryInfo workingDirectory, IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> possiblePackages, string? searchTerm, CancellationToken cancellationToken) { if (searchTerm is not null) { InteractionService.DisplaySubtleMessage(string.Format(CultureInfo.CurrentCulture, AddCommandStrings.NoPackagesMatchedSearchTerm, searchTerm)); } - return await GetPackageByInteractiveFlow(possiblePackages, null, cancellationToken); + return await GetPackageByInteractiveFlow(workingDirectory, possiblePackages, null, cancellationToken); } internal static (string FriendlyName, NuGetPackage Package, PackageChannel Channel) GenerateFriendlyName((NuGetPackage Package, PackageChannel Channel) packageWithChannel) diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index 2f88d686563..9e90a67c75e 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -35,7 +35,7 @@ internal interface IDotNetCliRunner Task BuildAsync(FileInfo projectFilePath, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, bool noRestore, 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 exactMatch, 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); @@ -316,7 +316,7 @@ private async Task StartBackchannelAsync(IDotNetCliExecution? execution, string using var activity = telemetry.StartDiagnosticActivity(); var isSingleFileAppHost = projectFile.Name.Equals("apphost.cs", StringComparison.OrdinalIgnoreCase); - + // If we are a single file app host then we use the build command instead of msbuild command. var cliArgsList = new List { isSingleFileAppHost ? "build" : "msbuild" }; @@ -826,7 +826,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 exactMatch, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); @@ -851,7 +851,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}|exactMatch={exactMatch}|prerelease={prerelease}|take={take}|skip={skip}|nugetConfigHash={nugetConfigHash}|cliVersion={cliVersion}"; var cached = await _diskCache.GetAsync(rawKey, cancellationToken).ConfigureAwait(false); if (cached is not null) { @@ -878,14 +878,24 @@ public async Task ComputeNuGetConfigHierarchySha256Async(DirectoryInfo w "package", "search", query, - "--take", - take.ToString(CultureInfo.InvariantCulture), - "--skip", - skip.ToString(CultureInfo.InvariantCulture), "--format", "json" ]; + if (exactMatch) // search for all versions that match the query exactly + { + cliArgs.Add("--exact-match"); + } + else // 'exaxt-match' flag causes the take and skip arguments to be ignored + { + cliArgs.AddRange([ + "--take", + take.ToString(CultureInfo.InvariantCulture), + "--skip", + skip.ToString(CultureInfo.InvariantCulture), + ]); + } + if (nugetConfigFile is not null) { cliArgs.Add("--configfile"); @@ -1073,7 +1083,7 @@ public async Task ComputeNuGetConfigHierarchySha256Async(DirectoryInfo w // Parse output - skip header lines (Project(s) and ----------) var projects = new List(); var startParsing = false; - + foreach (var line in stdoutLines) { if (string.IsNullOrWhiteSpace(line)) diff --git a/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs b/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs index 7b21897deed..52f6e779f4a 100644 --- a/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs +++ b/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs @@ -104,6 +104,24 @@ public async Task> GetPackagesAsync( return FilterPackages(packages, filter); } + public async Task> GetPackageVersionsAsync( + DirectoryInfo workingDirectory, + string exactPackageId, + bool prerelease, + FileInfo? nugetConfigFile, + bool useCache, + CancellationToken cancellationToken) + { + return await GetPackagesAsync( + workingDirectory, + exactPackageId, + filter: id => string.Equals(id, exactPackageId, StringComparison.OrdinalIgnoreCase), + prerelease, + nugetConfigFile, + useCache, + cancellationToken); + } + private async Task> SearchPackagesInternalAsync( DirectoryInfo workingDirectory, string query, diff --git a/src/Aspire.Cli/NuGet/NuGetPackageCache.cs b/src/Aspire.Cli/NuGet/NuGetPackageCache.cs index 3ca8347e88d..87d68fe22ec 100644 --- a/src/Aspire.Cli/NuGet/NuGetPackageCache.cs +++ b/src/Aspire.Cli/NuGet/NuGetPackageCache.cs @@ -17,12 +17,13 @@ internal interface INuGetPackageCache 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> GetPackageVersionsAsync(DirectoryInfo workingDirectory, string exactPackageId, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken); } internal sealed class NuGetPackageCache(IDotNetCliRunner cliRunner, IMemoryCache memoryCache, AspireCliTelemetry telemetry, IFeatures features) : INuGetPackageCache { private const int SearchPageSize = 1000; - + // List of deprecated packages that should be filtered by default private static readonly HashSet s_deprecatedPackages = new(StringComparer.OrdinalIgnoreCase) { @@ -87,6 +88,7 @@ public async Task> GetPackagesAsync(DirectoryInfo work var result = await cliRunner.SearchPackagesAsync( workingDirectory, query, + exactMatch: false, prerelease, SearchPageSize, skip, @@ -121,7 +123,7 @@ public async Task> GetPackagesAsync(DirectoryInfo work // If no specific filter is specified we use the fallback filter which is useful in most circumstances // other that aspire update which really needs to see all the packages to work effectively. - var effectiveFilter = (NuGetPackage p) => + var effectiveFilter = (NuGetPackage p) => { if (filter is not null) { @@ -129,7 +131,7 @@ public async Task> GetPackagesAsync(DirectoryInfo work } var isOfficialPackage = IsOfficialOrCommunityToolkitPackage(p.Id); - + // Apply deprecated package filter unless the user wants to show deprecated packages if (isOfficialPackage && !features.IsFeatureEnabled(KnownFeatures.ShowDeprecatedPackages, defaultValue: false)) { @@ -138,7 +140,7 @@ public async Task> GetPackagesAsync(DirectoryInfo work return isOfficialPackage; }; - + return collectedPackages.Where(effectiveFilter); static bool IsOfficialOrCommunityToolkitPackage(string packageName) @@ -157,6 +159,50 @@ static bool IsOfficialOrCommunityToolkitPackage(string packageName) return isHostingOrCommunityToolkitNamespaced && !isExcluded; } } + + public async Task> GetPackageVersionsAsync(DirectoryInfo workingDirectory, string exactPackageId, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + { + using var activity = telemetry.StartDiagnosticActivity(); + + var collectedPackages = new List(); + + var result = await cliRunner.SearchPackagesAsync( + workingDirectory, + exactPackageId, + exactMatch: true, + prerelease, + take: 0, + skip: 0, // skip and take parameters are ignored when exactMatch is true + nugetConfigFile, + useCache, // Pass through the useCache parameter + new DotNetCliRunnerInvocationOptions { SuppressLogging = true }, + cancellationToken + ); + + if (result.ExitCode != 0) + { + throw new NuGetPackageCacheException(string.Format(CultureInfo.CurrentCulture, ErrorStrings.FailedToSearchForPackages, result.ExitCode)); + } + + if (result.Packages?.Length > 0) + { + collectedPackages.AddRange(result.Packages); + } + + // If no specific filter is specified we use the fallback filter which is useful in most circumstances + // other that aspire update which really needs to see all the packages to work effectively. + var effectiveFilter = (NuGetPackage p) => + { + // Apply deprecated package filter unless the user wants to show deprecated packages + if (!features.IsFeatureEnabled(KnownFeatures.ShowDeprecatedPackages, defaultValue: false)) + { + return !s_deprecatedPackages.Contains(p.Id); + } + return true; + }; + + return collectedPackages.Where(effectiveFilter); + } } internal sealed class NuGetPackageCacheException(string message) : Exception(message) diff --git a/src/Aspire.Cli/Packaging/PackageChannel.cs b/src/Aspire.Cli/Packaging/PackageChannel.cs index 5bcd3bb2071..2ccd6e94db2 100644 --- a/src/Aspire.Cli/Packaging/PackageChannel.cs +++ b/src/Aspire.Cli/Packaging/PackageChannel.cs @@ -17,16 +17,16 @@ internal class PackageChannel(string name, PackageChannelQuality quality, Packag public bool ConfigureGlobalPackagesFolder { get; } = configureGlobalPackagesFolder; public string? CliDownloadBaseUrl { get; } = cliDownloadBaseUrl; public string? PinnedVersion { get; } = pinnedVersion; - + public string SourceDetails { get; } = ComputeSourceDetails(mappings); - + private static string ComputeSourceDetails(PackageMapping[]? mappings) { if (mappings is null) { return PackagingStrings.BasedOnNuGetConfig; } - + var aspireMapping = mappings.FirstOrDefault(m => m.PackageFilter.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase)); var allPackagesMapping = mappings.FirstOrDefault(m => m.PackageFilter == PackageMapping.AllPackages); @@ -67,7 +67,7 @@ public async Task> GetTemplatePackagesAsync(DirectoryI .SelectMany(p => p) .DistinctBy(p => $"{p.Id}-{p.Version}"); - // When doing a `dotnet package search` the the results may include stable packages even when searching for + // When doing a `dotnet package search` the results may include stable packages even when searching for // prerelease packages. This filters out this noise. var filteredPackages = packages.Where(p => new { SemVer = SemVersion.Parse(p.Version), Quality = Quality } switch { @@ -102,7 +102,7 @@ public async Task> GetIntegrationPackagesAsync(Directo .SelectMany(p => p) .DistinctBy(p => $"{p.Id}-{p.Version}"); - // When doing a `dotnet package search` the the results may include stable packages even when searching for + // When doing a `dotnet package search` the results may include stable packages even when searching for // prerelease packages. This filters out this noise. var filteredPackages = packages.Where(p => new { SemVer = SemVersion.Parse(p.Version), Quality = Quality } switch { @@ -180,7 +180,70 @@ public async Task> GetPackagesAsync(string packageId, return packages; } - // When doing a `dotnet package search` the the results may include stable packages even when searching for + // When doing a `dotnet package search` the results may include stable packages even when searching for + // prerelease packages. This filters out this noise. + var filteredPackages = packages.Where(p => new { SemVer = SemVersion.Parse(p.Version), Quality = Quality } switch + { + { Quality: PackageChannelQuality.Both } => true, + { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true, + { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true, + _ => false + }); + + return filteredPackages; + } + + public async Task> GetPackageVersionsAsync(string packageId, DirectoryInfo workingDirectory, CancellationToken cancellationToken) + { + var tasks = new List>>(); + + using var tempNuGetConfig = Type is PackageChannelType.Explicit ? await TemporaryNuGetConfig.CreateAsync(Mappings!) : null; + + if (Quality is PackageChannelQuality.Stable || Quality is PackageChannelQuality.Both) + { + tasks.Add(nuGetPackageCache.GetPackageVersionsAsync( + workingDirectory: workingDirectory, + exactPackageId: packageId, + prerelease: false, + nugetConfigFile: tempNuGetConfig?.ConfigFile, + useCache: true, // Enable caching for package channel resolution + cancellationToken: cancellationToken)); + } + + if (Quality is PackageChannelQuality.Prerelease || Quality is PackageChannelQuality.Both) + { + tasks.Add(nuGetPackageCache.GetPackageVersionsAsync( + workingDirectory: workingDirectory, + exactPackageId: packageId, + prerelease: true, + nugetConfigFile: tempNuGetConfig?.ConfigFile, + useCache: true, // Enable caching for package channel resolution + cancellationToken: cancellationToken)); + } + + var packageResults = await Task.WhenAll(tasks); + + var packages = packageResults + .SelectMany(p => p) + .DistinctBy(p => $"{p.Id}-{p.Version}"); + + // In the event that we have no stable packages we fallback to + // returning prerelease packages. Example a package that is currently + // in preview (Aspire.Hosting.Docker circa 9.4). + if (Quality is PackageChannelQuality.Stable && !packages.Any()) + { + packages = await nuGetPackageCache.GetPackageVersionsAsync( + workingDirectory: workingDirectory, + exactPackageId: packageId, + prerelease: true, + nugetConfigFile: tempNuGetConfig?.ConfigFile, + useCache: true, // Enable caching for package channel resolution + cancellationToken: cancellationToken); + + return packages; + } + + // When doing a `dotnet package search` the results may include stable packages even when searching for // prerelease packages. This filters out this noise. var filteredPackages = packages.Where(p => new { SemVer = SemVersion.Parse(p.Version), Quality = Quality } switch { @@ -207,4 +270,4 @@ public static PackageChannel CreateImplicitChannel(INuGetPackageCache nuGetPacka // for broader templating options. return new PackageChannel("default", PackageChannelQuality.Both, null, nuGetPackageCache); } -} \ No newline at end of file +} From 488200db3e9915af662724f60f85e188977b76f1 Mon Sep 17 00:00:00 2001 From: Muckenbatscher Date: Tue, 24 Mar 2026 00:22:16 +0100 Subject: [PATCH 2/4] Made Aspire CLI Unit Tests build again --- .../Commands/AddCommandTests.cs | 32 ++-- .../Commands/InitCommandTests.cs | 29 ++-- .../Commands/NewCommandTests.cs | 81 +++++----- .../DotNet/DotNetCliRunnerTests.cs | 7 +- .../Mcp/MockPackagingService.cs | 2 + .../NuGet/NuGetPackageCacheTests.cs | 24 +-- .../NuGetConfigMergerSnapshotTests.cs | 14 +- .../Packaging/NuGetConfigMergerTests.cs | 90 ++++++----- .../Packaging/PackageChannelTests.cs | 15 +- .../Packaging/PackagingServiceTests.cs | 150 ++++++++++-------- .../Projects/AppHostServerProjectTests.cs | 9 +- .../Projects/ProjectUpdaterTests.cs | 44 ++--- .../Templating/DotNetTemplateFactoryTests.cs | 6 +- .../TestServices/FakeNuGetPackageCache.cs | 3 + .../TestServices/TestDotNetCliRunner.cs | 8 +- .../CliUpdateNotificationServiceTests.cs | 5 + 16 files changed, 294 insertions(+), 225 deletions(-) diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index 6b7921dac29..c30062ad98b 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, exactMatch, 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, exactMatch, 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, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var dockerPackage = new NuGetPackage() { @@ -285,7 +285,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, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var dockerPackage = new NuGetPackage() { @@ -366,7 +366,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, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var redisPackage = new NuGetPackage() { @@ -425,7 +425,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, exactMatch, 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, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var dockerPackage = new NuGetPackage() { @@ -666,7 +666,7 @@ public async Task AddCommandPrompter_ShowsHighestVersionPerChannelWhenMultipleCh // Create two different channels var fakeCache = new FakeNuGetPackageCache(); var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache); - + var mappings = new[] { new PackageMapping("Aspire*", "https://preview-feed") }; var explicitChannel = PackageChannel.CreateExplicitChannel("preview", PackageChannelQuality.Prerelease, mappings, fakeCache); @@ -693,9 +693,9 @@ public async Task AddCommand_WithoutHives_UsesImplicitChannelWithoutPrompting() { // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); - + var selectedPackageId = string.Empty; - + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { options.ProjectLocatorFactory = _ => new TestProjectLocator(); @@ -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, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var redisPackage = new NuGetPackage() { @@ -732,7 +732,7 @@ public async Task AddCommand_WithoutHives_UsesImplicitChannelWithoutPrompting() return runner; }; }); - + var provider = services.BuildServiceProvider(); // Act - without hives, should automatically select from implicit channel without prompting @@ -770,7 +770,7 @@ public async Task AddCommand_WithHives_PrefersImplicitChannelVersionInNonInterac options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) => { var implicitPackage = new NuGetPackage { @@ -868,7 +868,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, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var postgresPackage = new NuGetPackage() { @@ -947,7 +947,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, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var postgresPackage = new NuGetPackage() { @@ -1023,7 +1023,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, exactMatch, 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 a671db89691..b1bee98370f 100644 --- a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs @@ -7,6 +7,7 @@ using Aspire.Cli.Packaging; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; +using Aspire.Shared; using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.InternalTesting; @@ -352,7 +353,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, exactMatch, prerelease, take, skip, nugetConfigFile, useCache, invocationOptions, cancellationToken) => { var package = new Aspire.Shared.NuGetPackageCli { @@ -460,13 +461,18 @@ private sealed class FakeNuGetPackageCache : INuGetPackageCache { return Task.FromResult>(Array.Empty()); } + + public Task> GetPackageVersionsAsync(DirectoryInfo workingDirectory, string exactPackageId, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + { + return Task.FromResult>(Array.Empty()); + } } [Fact] public async Task InitCommandWithChannelOptionUsesSpecifiedChannel() { using var workspace = TemporaryWorkspace.Create(outputHelper); - + string? channelNameUsed = null; bool promptedForVersion = false; @@ -476,13 +482,13 @@ public async Task InitCommandWithChannelOptionUsesSpecifiedChannel() { var interactionService = sp.GetRequiredService(); var prompter = new TestNewCommandPrompter(interactionService); - + prompter.PromptForTemplatesVersionCallback = (packages) => { promptedForVersion = true; throw new InvalidOperationException("Should not prompt for version when --channel is specified"); }; - + return prompter; }; @@ -490,7 +496,7 @@ public async Task InitCommandWithChannelOptionUsesSpecifiedChannel() { return new TestPackagingServiceWithChannelTracking((channelName) => channelNameUsed = channelName); }; - + options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); @@ -513,7 +519,7 @@ public async Task InitCommandWithChannelOptionUsesSpecifiedChannel() var result = command.Parse("init --channel stable"); var exitCode = await result.InvokeAsync().DefaultTimeout(); - + // Assert Assert.Equal(0, exitCode); Assert.Equal("stable", channelNameUsed); @@ -538,7 +544,7 @@ public async Task InitCommandWithInvalidChannelShowsError() var result = command.Parse("init --channel invalid-channel"); var exitCode = await result.InvokeAsync().DefaultTimeout(); - + // Assert - should fail with non-zero exit code for invalid channel Assert.NotEqual(0, exitCode); } @@ -549,10 +555,10 @@ public Task> GetChannelsAsync(CancellationToken canc { var stableCache = new FakeNuGetPackageCacheWithTracking("stable", onChannelUsed); var dailyCache = new FakeNuGetPackageCacheWithTracking("daily", onChannelUsed); - + var stableChannel = PackageChannel.CreateExplicitChannel("stable", PackageChannelQuality.Both, [], stableCache); var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache); - + return Task.FromResult>(new[] { stableChannel, dailyChannel }); } } @@ -585,5 +591,10 @@ private sealed class FakeNuGetPackageCacheWithTracking(string channelName, Actio { return Task.FromResult>(Array.Empty()); } + + public Task> GetPackageVersionsAsync(DirectoryInfo workingDirectory, string exactPackageId, 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 d671a48055d..ff837b19f84 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -82,7 +82,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, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -139,7 +139,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, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -192,7 +192,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, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -247,7 +247,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, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -279,7 +279,7 @@ public async Task NewCommandDoesNotPromptForOutputPathIfSpecifiedOnCommandLine() public async Task NewCommandWithChannelOptionUsesSpecifiedChannel() { using var workspace = TemporaryWorkspace.Create(outputHelper); - + string? channelNameUsed = null; bool promptedForVersion = false; @@ -289,13 +289,13 @@ public async Task NewCommandWithChannelOptionUsesSpecifiedChannel() { var interactionService = sp.GetRequiredService(); var prompter = new TestNewCommandPrompter(interactionService); - + prompter.PromptForTemplatesVersionCallback = (packages) => { promptedForVersion = true; throw new InvalidOperationException("Should not prompt for version when --channel is specified"); }; - + return prompter; }; @@ -311,7 +311,7 @@ public async Task NewCommandWithChannelOptionUsesSpecifiedChannel() var package = new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "9.2.0" }; return Task.FromResult>([package]); }; - + var dailyCache = new NewCommandTestFakeNuGetPackageCache(); dailyCache.GetTemplatePackagesAsyncCallback = (dir, prerelease, nugetConfig, ct) => { @@ -319,13 +319,13 @@ public async Task NewCommandWithChannelOptionUsesSpecifiedChannel() var package = new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "10.0.0-dev" }; return Task.FromResult>([package]); }; - + var stableChannel = PackageChannel.CreateExplicitChannel("stable", PackageChannelQuality.Both, [], stableCache); var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache); - + return Task.FromResult>([stableChannel, dailyChannel]); }; - + return packagingService; }; @@ -349,7 +349,7 @@ public async Task NewCommandWithChannelOptionUsesSpecifiedChannel() var result = command.Parse("new aspire-starter --channel stable --use-redis-cache --test-framework None"); var exitCode = await result.InvokeAsync().DefaultTimeout(); - + // Assert Assert.Equal(0, exitCode); Assert.Equal("stable", channelNameUsed); // Verify the stable channel was used @@ -360,7 +360,7 @@ public async Task NewCommandWithChannelOptionUsesSpecifiedChannel() public async Task NewCommandWithChannelOptionAutoSelectsHighestVersion() { using var workspace = TemporaryWorkspace.Create(outputHelper); - + string? selectedVersion = null; bool promptedForVersion = false; @@ -370,13 +370,13 @@ public async Task NewCommandWithChannelOptionAutoSelectsHighestVersion() { var interactionService = sp.GetRequiredService(); var prompter = new TestNewCommandPrompter(interactionService); - + prompter.PromptForTemplatesVersionCallback = (packages) => { promptedForVersion = true; throw new InvalidOperationException("Should not prompt for version when --channel is specified"); }; - + return prompter; }; @@ -397,14 +397,14 @@ public async Task NewCommandWithChannelOptionAutoSelectsHighestVersion() }; return Task.FromResult>(packages); }; - + var stableChannel = PackageChannel.CreateExplicitChannel("stable", PackageChannelQuality.Both, [], fakeCache); return Task.FromResult>([stableChannel]); }; - + return packagingService; }; - + options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); @@ -426,7 +426,7 @@ public async Task NewCommandWithChannelOptionAutoSelectsHighestVersion() var result = command.Parse("new aspire-starter --channel stable --use-redis-cache --test-framework None"); var exitCode = await result.InvokeAsync().DefaultTimeout(); - + // Assert Assert.Equal(0, exitCode); Assert.Equal("9.2.0", selectedVersion); // Should auto-select highest version (9.2.0) @@ -460,7 +460,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, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -514,7 +514,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, exactMatch, prerelease, take, skip, nugetConfigFile, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -556,7 +556,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, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { return (0, Array.Empty()); }; return runner; @@ -591,7 +591,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, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -644,7 +644,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, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -733,7 +733,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, exactMatch, prerelease, take, skip, nugetConfigFile, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -780,7 +780,7 @@ public async Task NewCommandEscapesMarkupInProjectNameAndOutputPath() // This test validates that project names containing Spectre markup characters // (like '[' and ']') are properly escaped when displayed as default values in prompts. // This prevents crashes when the markup parser encounters malformed markup. - + var projectNameWithMarkup = "[27;5;13~"; // Example of input that could crash the markup parser var capturedProjectNameDefault = string.Empty; var capturedOutputPathDefault = string.Empty; @@ -815,7 +815,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, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -860,7 +860,7 @@ public async Task NewCommandWithoutTemplateCanCreateTypeScriptEmptyTemplate() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, dotnetOptions, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, dotnetOptions, cancellationToken) => { var package = new NuGetPackage() { @@ -956,7 +956,7 @@ public async Task NewCommandWithoutTemplatePromptsWithDistinctLanguageSpecificEm options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, dotnetOptions, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, dotnetOptions, cancellationToken) => { var package = new NuGetPackage() { @@ -993,7 +993,7 @@ public async Task NewCommandWithExplicitCSharpEmptyTemplateCreatesCSharpAppHost( options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -1054,7 +1054,7 @@ public async Task NewCommandWithEmptyTemplateAndCSharpPromptsForLocalhostTldAndU options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -1096,7 +1096,7 @@ public async Task NewCommandWithTypeScriptEmptyTemplateUsesScaffolding() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -1152,7 +1152,7 @@ public async Task NewCommandWithEmptyTemplateNormalizesDefaultOutputPath() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -1231,7 +1231,7 @@ public async Task NewCommandWithEmptyTemplateAndTypeScriptPromptsForLocalhostTld options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -1300,7 +1300,7 @@ public async Task NewCommandWithTypeScriptStarterGeneratesSdkArtifacts() { options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner { - SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, runnerOptions, cancellationToken) => + SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, runnerOptions, cancellationToken) => { var package = new NuGetPackage { @@ -1373,7 +1373,7 @@ public async Task NewCommandWithTypeScriptStarterReturnsFailedToBuildArtifactsWh { options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner { - SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, runnerOptions, cancellationToken) => + SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, runnerOptions, cancellationToken) => { var package = new NuGetPackage { @@ -1441,7 +1441,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, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => { var package = new NuGetPackage() { @@ -1601,7 +1601,7 @@ public Task> GetChannelsAsync(CancellationToken canc { return GetChannelsAsyncCallback(cancellationToken); } - + // Default: Return a fake channel var testChannel = PackageChannel.CreateImplicitChannel(new NewCommandTestFakeNuGetPackageCache()); return Task.FromResult>(new[] { testChannel }); @@ -1642,6 +1642,11 @@ public Task> GetPackagesAsync(DirectoryInfo workingDir { return Task.FromResult>(Array.Empty()); } + + public Task> GetPackageVersionsAsync(DirectoryInfo workingDirectory, string exactPackageId, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + { + return Task.FromResult>(Array.Empty()); + } } internal sealed class TestScaffoldingService : IScaffoldingService diff --git a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs index a7974739d47..3c5e0d6f947 100644 --- a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs +++ b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs @@ -1197,7 +1197,7 @@ public async Task GetProjectItemsAndPropertiesAsync_UsesBuild_ForSingleFileAppHo // Verify that "build" command is used for single-file app host Assert.Contains("build", args); Assert.DoesNotContain("msbuild", args); - + // Provide valid JSON output invocationOptions.StandardOutputCallback?.Invoke("{\"Properties\":{\"MSBuildVersion\":\"17.0.0\",\"AspireHostingSDKVersion\":\"9.0.0\"},\"Items\":{\"PackageReference\":[]}}"); }, @@ -1233,7 +1233,7 @@ public async Task GetProjectItemsAndPropertiesAsync_UsesMsBuild_ForCsProjFile() // Verify that "msbuild" command is used for .csproj files Assert.Contains("msbuild", args); Assert.DoesNotContain("build", args); - + // Provide valid JSON output invocationOptions.StandardOutputCallback?.Invoke("{\"Properties\":{\"MSBuildVersion\":\"17.0.0\",\"AspireHostingSDKVersion\":\"9.0.0\"},\"Items\":{\"PackageReference\":[]}}"); }, @@ -1280,6 +1280,7 @@ public async Task SearchPackagesAsyncRetriesOnFailureAndSucceedsOnSecondAttempt( var result = await runner.SearchPackagesAsync( workspace.WorkspaceRoot, "Aspire.Hosting", + exactMatch: false, prerelease: false, take: 100, skip: 0, @@ -1321,6 +1322,7 @@ public async Task SearchPackagesAsyncRetriesMaxTimesAndReturnsFailure() var result = await runner.SearchPackagesAsync( workspace.WorkspaceRoot, "Aspire.Hosting", + exactMatch: false, prerelease: false, take: 100, skip: 0, @@ -1362,6 +1364,7 @@ public async Task SearchPackagesAsyncSucceedsOnFirstAttemptWithoutRetry() var result = await runner.SearchPackagesAsync( workspace.WorkspaceRoot, "Aspire.Hosting", + exactMatch: false, prerelease: false, take: 100, skip: 0, diff --git a/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs b/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs index 27505f5e9a3..a7be1f355c5 100644 --- a/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs +++ b/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs @@ -45,6 +45,8 @@ public Task> GetCliPackagesAsync(DirectoryInfo work public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) => Task.FromResult>([]); + public Task> GetPackageVersionsAsync(DirectoryInfo workingDirectory, string exactPackageId, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + => Task.FromResult>([]); } internal static class TestExecutionContextFactory diff --git a/tests/Aspire.Cli.Tests/NuGet/NuGetPackageCacheTests.cs b/tests/Aspire.Cli.Tests/NuGet/NuGetPackageCacheTests.cs index 27ef3717480..7f3d833d81e 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, [ @@ -88,11 +88,11 @@ public async Task DeprecatedPackagesAreIncludedWhenShowDeprecatedPackagesEnabled { // Enable showing deprecated packages configure.EnabledFeatures = [Aspire.Cli.KnownFeatures.ShowDeprecatedPackages]; - + 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, [ @@ -144,14 +144,14 @@ public async Task CustomFilterBypassesDeprecatedPackageFiltering() var provider = services.BuildServiceProvider(); var nuGetPackageCache = provider.GetRequiredService(); - + // Use a custom filter that includes all packages containing "Dapr" var packages = await nuGetPackageCache.GetPackagesAsync( - workspace.WorkspaceRoot, - "Aspire.Hosting", - filter: id => id.Contains("Dapr", StringComparison.OrdinalIgnoreCase), - prerelease: false, - nugetConfigFile: null, + workspace.WorkspaceRoot, + "Aspire.Hosting", + filter: id => id.Contains("Dapr", StringComparison.OrdinalIgnoreCase), + prerelease: false, + nugetConfigFile: null, useCache: true, CancellationToken.None).DefaultTimeout(); @@ -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..be727f93c5b 100644 --- a/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs @@ -24,10 +24,16 @@ public NuGetConfigMergerSnapshotTests(ITestOutputHelper output) private sealed class FakeNuGetPackageCache : INuGetPackageCache { - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) => Task.FromResult>([]); - public Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) => Task.FromResult>([]); - public Task> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) => Task.FromResult>([]); - public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) => Task.FromResult>([]); + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => Task.FromResult>([]); + public Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => Task.FromResult>([]); + public Task> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => Task.FromResult>([]); + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + => Task.FromResult>([]); + public Task> GetPackageVersionsAsync(DirectoryInfo workingDirectory, string exactPackageId, 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 3f648c69a8c..ca9b6ee512d 100644 --- a/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerTests.cs @@ -7,6 +7,7 @@ using Aspire.Cli.Packaging; using Aspire.Cli.NuGet; using Aspire.Cli.Tests.Utils; +using Aspire.Shared; namespace Aspire.Cli.Tests.Packaging; @@ -44,6 +45,11 @@ private sealed class FakeNuGetPackageCache : INuGetPackageCache { _ = workingDirectory; _ = packageId; _ = filter; _ = prerelease; _ = nugetConfigFile; _ = useCache; _ = cancellationToken; return Task.FromResult>([]); } + + public Task> GetPackageVersionsAsync(DirectoryInfo workingDirectory, string exactPackageId, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + { + _ = workingDirectory; _ = exactPackageId; _ = prerelease; _ = nugetConfigFile; _ = useCache; _ = cancellationToken; return Task.FromResult>([]); + } } private static PackageChannel CreateChannel(PackageMapping[] mappings) => PackageChannel.CreateExplicitChannel("test", PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache()); @@ -60,14 +66,14 @@ public async Task CreateOrUpdateAsync_CreatesConfigFromMappings_WhenNoExistingCo new PackageMapping(PackageMapping.AllPackages, "https://feed2.example") }; - var channel = CreateChannel(mappings); - await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); + var channel = CreateChannel(mappings); + await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); var targetConfigPath = Path.Combine(root.FullName, "nuget.config"); Assert.True(File.Exists(targetConfigPath)); - using var tempConfig = await TemporaryNuGetConfig.CreateAsync(mappings); - var expected = await File.ReadAllTextAsync(tempConfig.ConfigFile.FullName); + using var tempConfig = await TemporaryNuGetConfig.CreateAsync(mappings); + var expected = await File.ReadAllTextAsync(tempConfig.ConfigFile.FullName); var actual = await File.ReadAllTextAsync(targetConfigPath); Assert.Equal(NormalizeLineEndings(expected), NormalizeLineEndings(actual)); } @@ -84,8 +90,8 @@ public async Task CreateOrUpdateAsync_GeneratesConfigFromMappings_WhenChannelPro new PackageMapping(PackageMapping.AllPackages, "https://feed2.example") }; - var channel = CreateChannel(mappings); - await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); + var channel = CreateChannel(mappings); + await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); var targetConfigPath = Path.Combine(root.FullName, "nuget.config"); Assert.True(File.Exists(targetConfigPath)); @@ -128,8 +134,8 @@ await WriteConfigAsync(root, new PackageMapping("Microsoft.*", "https://feed2.example") // feed2 missing }; - var channel = CreateChannel(mappings); - await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); + var channel = CreateChannel(mappings); + await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); var xml = XDocument.Load(Path.Combine(root.FullName, "nuget.config")); var packageSources = xml.Root!.Element("packageSources")!; @@ -167,8 +173,8 @@ await WriteConfigAsync(root, new PackageMapping("Lib.*", "https://new.example") }; - var channel = CreateChannel(mappings); - await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); + var channel = CreateChannel(mappings); + await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); var xml = XDocument.Load(Path.Combine(root.FullName, "nuget.config")); var packageSources = xml.Root!.Element("packageSources")!; @@ -207,8 +213,8 @@ await WriteConfigAsync(root, new PackageMapping("Microsoft.*", "https://feed2.example") }; - var channel = CreateChannel(mappings); - await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); + var channel = CreateChannel(mappings); + await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); var xml = XDocument.Load(Path.Combine(root.FullName, "nuget.config")); var psm = xml.Root!.Element("packageSourceMapping"); @@ -221,9 +227,9 @@ public void HasMissingSources_ReturnsTrue_WhenConfigAbsent() { using var workspace = TemporaryWorkspace.Create(_outputHelper); var root = workspace.WorkspaceRoot; - var mappings = new[] { new PackageMapping("Aspire.*", "https://feed.example") }; - var channel = CreateChannel(mappings); - Assert.True(NuGetConfigMerger.HasMissingSources(root, channel)); + var mappings = new[] { new PackageMapping("Aspire.*", "https://feed.example") }; + var channel = CreateChannel(mappings); + Assert.True(NuGetConfigMerger.HasMissingSources(root, channel)); } [Fact] @@ -253,8 +259,8 @@ await WriteConfigAsync(root, new PackageMapping("Aspire.*", "https://feed2.example") // should be feed2, but config has feed1 }; - var channel = CreateChannel(mappings); - Assert.True(NuGetConfigMerger.HasMissingSources(root, channel)); + var channel = CreateChannel(mappings); + Assert.True(NuGetConfigMerger.HasMissingSources(root, channel)); } [Fact] @@ -288,8 +294,8 @@ await WriteConfigAsync(root, new PackageMapping("Microsoft.*", "https://feed2.example") }; - var channel = CreateChannel(mappings); - Assert.False(NuGetConfigMerger.HasMissingSources(root, channel)); + var channel = CreateChannel(mappings); + Assert.False(NuGetConfigMerger.HasMissingSources(root, channel)); } [Fact] @@ -322,7 +328,7 @@ await WriteConfigAsync(root, var xml = XDocument.Load(Path.Combine(root.FullName, "nuget.config")); var packageSources = xml.Root!.Element("packageSources")!; - + // Existing sources should still be present with their original keys Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("key") == "nuget" && (string?)e.Attribute("value") == "https://api.nuget.org/v3/index.json"); Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("key") == "dotnet9" && (string?)e.Attribute("value") == "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json"); @@ -372,7 +378,7 @@ await WriteConfigAsync(root, var xml = XDocument.Load(Path.Combine(root.FullName, "nuget.config")); var packageSources = xml.Root!.Element("packageSources")!; - + // All original sources should still be present Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("key") == "nuget.org"); Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("key") == "custom"); @@ -388,7 +394,7 @@ await WriteConfigAsync(root, // Since the original config had NO packageSourceMapping, all existing sources should get "*" patterns // so they can continue to serve packages var psm = xml.Root!.Element("packageSourceMapping")!; - + // The aspire source should have its specific pattern var aspireMapping = psm.Elements("packageSource").FirstOrDefault(ps => (string?)ps.Attribute("key") == "https://example.com/aspire-daily"); Assert.NotNull(aspireMapping); @@ -441,7 +447,7 @@ await WriteConfigAsync(root, var xml = XDocument.Load(Path.Combine(root.FullName, "nuget.config")); var packageSources = xml.Root!.Element("packageSources")!; - + // Original source should still be present Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("key") == "nuget.org"); @@ -454,7 +460,7 @@ await WriteConfigAsync(root, // Package source mapping should have both the original wildcard and the new specific mappings var psm = xml.Root!.Element("packageSourceMapping")!; - + // Original nuget.org should still have the wildcard pattern var nugetMapping = psm.Elements("packageSource").FirstOrDefault(ps => (string?)ps.Attribute("key") == "nuget.org"); Assert.NotNull(nugetMapping); @@ -507,31 +513,31 @@ await WriteConfigAsync(root, var xml = XDocument.Load(Path.Combine(root.FullName, "nuget.config")); var packageSources = xml.Root!.Element("packageSources")!; - + // The PR hive source should be removed because it's safe to remove and no longer needed - Assert.DoesNotContain(packageSources.Elements("add"), + Assert.DoesNotContain(packageSources.Elements("add"), e => (string?)e.Attribute("value") == "C:\\Users\\user\\.aspire\\hives\\invalid-pr"); - + // The user-defined source should be preserved even though its patterns were remapped - Assert.Contains(packageSources.Elements("add"), + Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("value") == "https://valid.example"); - + // NuGet.org should be added for all the patterns - Assert.Contains(packageSources.Elements("add"), + Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("value") == "https://api.nuget.org/v3/index.json"); var psm = xml.Root!.Element("packageSourceMapping")!; - + // The PR hive source should not have any mapping entries (removed entirely) - Assert.DoesNotContain(psm.Elements("packageSource"), + Assert.DoesNotContain(psm.Elements("packageSource"), ps => (string?)ps.Attribute("key") == "C:\\Users\\user\\.aspire\\hives\\invalid-pr"); - + // The user-defined source should get a wildcard pattern to remain functional var validExampleMapping = psm.Elements("packageSource") .FirstOrDefault(ps => (string?)ps.Attribute("key") == "https://valid.example"); Assert.NotNull(validExampleMapping); Assert.Contains(validExampleMapping.Elements("package"), p => (string?)p.Attribute("pattern") == "*"); - + // NuGet.org should have all the patterns var nugetMapping = psm.Elements("packageSource") .FirstOrDefault(ps => (string?)ps.Attribute("key") == "https://api.nuget.org/v3/index.json"); @@ -539,7 +545,7 @@ await WriteConfigAsync(root, Assert.Contains(nugetMapping.Elements("package"), p => (string?)p.Attribute("pattern") == "Aspire*"); Assert.Contains(nugetMapping.Elements("package"), p => (string?)p.Attribute("pattern") == "Microsoft.Extensions.ServiceDiscovery*"); Assert.Contains(nugetMapping.Elements("package"), p => (string?)p.Attribute("pattern") == "*"); - + // There should be two packageSource elements (nuget.org and valid.example) Assert.Equal(2, psm.Elements("packageSource").Count()); } @@ -556,7 +562,7 @@ public async Task CreateOrUpdateAsync_CallbackInvokedForNewConfig() }; var channel = CreateChannel(mappings); - + bool callbackInvoked = false; FileInfo? callbackTargetFile = null; XmlDocument? callbackOriginalContent = null; @@ -595,7 +601,7 @@ public async Task CreateOrUpdateAsync_CallbackCanPreventNewConfigCreation() }; var channel = CreateChannel(mappings); - + bool callbackInvoked = false; await NuGetConfigMerger.CreateOrUpdateAsync(root, channel, (targetFile, originalContent, proposedContent, cancellationToken) => @@ -627,7 +633,7 @@ public async Task CreateOrUpdateAsync_CallbackInvokedForExistingConfig() """; - + await WriteConfigAsync(root, existingConfig).DefaultTimeout(); var mappings = new[] @@ -636,7 +642,7 @@ public async Task CreateOrUpdateAsync_CallbackInvokedForExistingConfig() }; var channel = CreateChannel(mappings); - + bool callbackInvoked = false; FileInfo? callbackTargetFile = null; XmlDocument? callbackOriginalContent = null; @@ -678,7 +684,7 @@ public async Task CreateOrUpdateAsync_CallbackCanPreventExistingConfigUpdate() """; - + await WriteConfigAsync(root, existingConfig).DefaultTimeout(); var originalContent = await File.ReadAllTextAsync(Path.Combine(root.FullName, "nuget.config")).DefaultTimeout(); @@ -688,7 +694,7 @@ public async Task CreateOrUpdateAsync_CallbackCanPreventExistingConfigUpdate() }; var channel = CreateChannel(mappings); - + bool callbackInvoked = false; await NuGetConfigMerger.CreateOrUpdateAsync(root, channel, (targetFile, originalContent, proposedContent, cancellationToken) => @@ -718,7 +724,7 @@ public async Task CreateOrUpdateAsync_WorksWithoutCallback() }; var channel = CreateChannel(mappings); - + // Call without callback - should work as before await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); diff --git a/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs index 5277c4323e7..db99eb629b2 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs @@ -4,6 +4,7 @@ using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; using Aspire.Cli.Resources; +using Aspire.Shared; namespace Aspire.Cli.Tests.Packaging; @@ -11,10 +12,16 @@ public class PackageChannelTests { private sealed class FakeNuGetPackageCache : INuGetPackageCache { - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) => Task.FromResult>([]); - public Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) => Task.FromResult>([]); - public Task> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) => Task.FromResult>([]); - public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) => Task.FromResult>([]); + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => Task.FromResult>([]); + public Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => Task.FromResult>([]); + public Task> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => Task.FromResult>([]); + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + => Task.FromResult>([]); + public Task> GetPackageVersionsAsync(DirectoryInfo workingDirectory, string exactPackageId, 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 51ce56b6907..4b13cea14e1 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs @@ -6,6 +6,7 @@ using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; using Aspire.Cli.Tests.Utils; +using Aspire.Shared; using Microsoft.Extensions.Configuration; using System.Xml.Linq; @@ -16,10 +17,16 @@ 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> 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> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => Task.FromResult>([]); + public Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => Task.FromResult>([]); + public Task> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => Task.FromResult>([]); + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + => Task.FromResult>([]); + public Task> GetPackageVersionsAsync(DirectoryInfo workingDirectory, string exactPackageId, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + => Task.FromResult>([]); } private sealed class TestFeatures : IFeatures @@ -46,7 +53,7 @@ public async Task GetChannelsAsync_WhenStagingChannelDisabled_DoesNotIncludeStag 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(); var configuration = new ConfigurationBuilder().Build(); var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); @@ -64,10 +71,10 @@ public async Task GetChannelsAsync_WhenStagingChannelDisabled_DoesNotIncludeStag // Verify that non-staging channels have ConfigureGlobalPackagesFolder = false var defaultChannel = channels.First(c => c.Name == "default"); Assert.False(defaultChannel.ConfigureGlobalPackagesFolder); - + var stableChannel = channels.First(c => c.Name == "stable"); Assert.False(stableChannel.ConfigureGlobalPackagesFolder); - + var dailyChannel = channels.First(c => c.Name == "daily"); Assert.False(dailyChannel.ConfigureGlobalPackagesFolder); } @@ -81,10 +88,10 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabled_IncludesStagingChan 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 testFeedUrl = "https://example.com/nuget/v3/index.json"; var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -101,16 +108,16 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabled_IncludesStagingChan // Assert var channelNames = channels.Select(c => c.Name).ToList(); Assert.Contains("staging", channelNames); - + var stagingChannel = channels.First(c => c.Name == "staging"); Assert.Equal(PackageChannelQuality.Stable, stagingChannel.Quality); Assert.True(stagingChannel.ConfigureGlobalPackagesFolder); Assert.NotNull(stagingChannel.Mappings); - + var aspireMapping = stagingChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "Aspire*"); Assert.NotNull(aspireMapping); Assert.Equal(testFeedUrl, aspireMapping.Source); - + var nugetMapping = stagingChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "*"); Assert.NotNull(nugetMapping); Assert.Equal("https://api.nuget.org/v3/index.json", nugetMapping.Source); @@ -125,10 +132,10 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithOverrideFeed_Use 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 customFeedUrl = "https://custom-feed.example.com/v3/index.json"; var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -158,10 +165,10 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithAzureDevOpsFeedO 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 azureDevOpsFeedUrl = "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-dotnet-aspire-abcd1234/nuget/v3/index.json"; var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -191,10 +198,10 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithInvalidOverrideF 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 invalidFeedUrl = "not-a-valid-url"; var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -223,10 +230,10 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithQualityOverride_ 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 { @@ -254,10 +261,10 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithQualityBoth_Uses 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 { @@ -285,10 +292,10 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithInvalidQuality_D 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 { @@ -316,10 +323,10 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithoutQualityOverri 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 { @@ -343,10 +350,10 @@ public async Task NuGetConfigMerger_WhenChannelRequiresGlobalPackagesFolder_Adds // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); var tempDir = workspace.WorkspaceRoot; - + var features = new TestFeatures(); features.SetFeature(KnownFeatures.StagingChannelEnabled, true); - + var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { @@ -355,9 +362,9 @@ public async Task NuGetConfigMerger_WhenChannelRequiresGlobalPackagesFolder_Adds .Build(); var packagingService = new PackagingService( - new CliExecutionContext(tempDir, tempDir, tempDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"), - new FakeNuGetPackageCache(), - features, + new CliExecutionContext(tempDir, tempDir, tempDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"), + new FakeNuGetPackageCache(), + features, configuration); var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); @@ -369,7 +376,7 @@ public async Task NuGetConfigMerger_WhenChannelRequiresGlobalPackagesFolder_Adds // Assert var nugetConfigPath = Path.Combine(tempDir.FullName, "nuget.config"); Assert.True(File.Exists(nugetConfigPath)); - + var configContent = await File.ReadAllTextAsync(nugetConfigPath); Assert.Contains("globalPackagesFolder", configContent); Assert.Contains(".nugetpackages", configContent); @@ -378,7 +385,7 @@ public async Task NuGetConfigMerger_WhenChannelRequiresGlobalPackagesFolder_Adds var doc = XDocument.Load(nugetConfigPath); var configSection = doc.Root?.Element("config"); Assert.NotNull(configSection); - + var globalPackagesFolderAdd = configSection.Elements("add") .FirstOrDefault(add => string.Equals((string?)add.Attribute("key"), "globalPackagesFolder", StringComparison.OrdinalIgnoreCase)); Assert.NotNull(globalPackagesFolderAdd); @@ -393,17 +400,17 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabled_StagingAppearsAfter var tempDir = workspace.WorkspaceRoot; var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); - + // Create some PR hives to ensure staging appears before them hivesDir.Create(); Directory.CreateDirectory(Path.Combine(hivesDir.FullName, "pr-10167")); Directory.CreateDirectory(Path.Combine(hivesDir.FullName, "pr-11832")); - + 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 { @@ -418,7 +425,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabled_StagingAppearsAfter // Assert var channelNames = channels.Select(c => c.Name).ToList(); - + // Verify all expected channels are present Assert.Contains("default", channelNames); Assert.Contains("stable", channelNames); @@ -426,7 +433,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabled_StagingAppearsAfter Assert.Contains("daily", channelNames); Assert.Contains("pr-10167", channelNames); Assert.Contains("pr-11832", channelNames); - + // Verify the order: default, stable, staging, daily, pr-* var defaultIndex = channelNames.IndexOf("default"); var stableIndex = channelNames.IndexOf("stable"); @@ -434,7 +441,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabled_StagingAppearsAfter var dailyIndex = channelNames.IndexOf("daily"); var pr10167Index = channelNames.IndexOf("pr-10167"); var pr11832Index = channelNames.IndexOf("pr-11832"); - + Assert.True(defaultIndex < stableIndex, $"default should come before stable (default: {defaultIndex}, stable: {stableIndex})"); Assert.True(stableIndex < stagingIndex, $"stable should come before staging (stable: {stableIndex}, staging: {stagingIndex})"); Assert.True(stagingIndex < dailyIndex, $"staging should come before daily (staging: {stagingIndex}, daily: {dailyIndex})"); @@ -450,13 +457,13 @@ public async Task GetChannelsAsync_WhenStagingChannelDisabled_OrderIsDefaultStab var tempDir = workspace.WorkspaceRoot; var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); - + // Create some PR hives hivesDir.Create(); Directory.CreateDirectory(Path.Combine(hivesDir.FullName, "pr-12345")); - + 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(); // Staging disabled by default var configuration = new ConfigurationBuilder().Build(); @@ -468,16 +475,16 @@ public async Task GetChannelsAsync_WhenStagingChannelDisabled_OrderIsDefaultStab // Assert var channelNames = channels.Select(c => c.Name).ToList(); - + // Verify staging is not present Assert.DoesNotContain("staging", channelNames); - + // Verify the order: default, stable, daily, pr-* var defaultIndex = channelNames.IndexOf("default"); var stableIndex = channelNames.IndexOf("stable"); var dailyIndex = channelNames.IndexOf("daily"); var pr12345Index = channelNames.IndexOf("pr-12345"); - + Assert.True(defaultIndex < stableIndex, "default should come before stable"); Assert.True(stableIndex < dailyIndex, "stable should come before daily"); Assert.True(dailyIndex < pr12345Index, "daily should come before pr-12345"); @@ -492,10 +499,10 @@ public async Task GetChannelsAsync_WhenStagingQualityPrerelease_AndNoFeedOverrid 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); - + // Set quality to Prerelease but do NOT set overrideStagingFeed var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -513,7 +520,7 @@ public async Task GetChannelsAsync_WhenStagingQualityPrerelease_AndNoFeedOverrid var stagingChannel = channels.First(c => c.Name == "staging"); Assert.Equal(PackageChannelQuality.Prerelease, stagingChannel.Quality); Assert.False(stagingChannel.ConfigureGlobalPackagesFolder); - + var aspireMapping = stagingChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "Aspire*"); Assert.NotNull(aspireMapping); Assert.Equal("https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json", aspireMapping.Source); @@ -528,10 +535,10 @@ public async Task GetChannelsAsync_WhenStagingQualityBoth_AndNoFeedOverride_Uses 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); - + // Set quality to Both but do NOT set overrideStagingFeed var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -549,7 +556,7 @@ public async Task GetChannelsAsync_WhenStagingQualityBoth_AndNoFeedOverride_Uses var stagingChannel = channels.First(c => c.Name == "staging"); Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality); Assert.False(stagingChannel.ConfigureGlobalPackagesFolder); - + var aspireMapping = stagingChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "Aspire*"); Assert.NotNull(aspireMapping); Assert.Equal("https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json", aspireMapping.Source); @@ -564,10 +571,10 @@ public async Task GetChannelsAsync_WhenStagingQualityPrerelease_WithFeedOverride 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); - + // Set both quality override AND feed override — feed override should win var customFeed = "https://custom-feed.example.com/v3/index.json"; var configuration = new ConfigurationBuilder() @@ -588,7 +595,7 @@ public async Task GetChannelsAsync_WhenStagingQualityPrerelease_WithFeedOverride Assert.Equal(PackageChannelQuality.Prerelease, stagingChannel.Quality); // When an explicit feed override is provided, globalPackagesFolder stays enabled Assert.True(stagingChannel.ConfigureGlobalPackagesFolder); - + var aspireMapping = stagingChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "Aspire*"); Assert.NotNull(aspireMapping); Assert.Equal(customFeed, aspireMapping.Source); @@ -600,10 +607,10 @@ public async Task NuGetConfigMerger_WhenStagingUsesSharedFeed_DoesNotAddGlobalPa // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); var tempDir = workspace.WorkspaceRoot; - + var features = new TestFeatures(); features.SetFeature(KnownFeatures.StagingChannelEnabled, true); - + // Quality=Prerelease with no feed override → shared feed mode var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -613,9 +620,9 @@ public async Task NuGetConfigMerger_WhenStagingUsesSharedFeed_DoesNotAddGlobalPa .Build(); var packagingService = new PackagingService( - new CliExecutionContext(tempDir, tempDir, tempDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"), - new FakeNuGetPackageCache(), - features, + new CliExecutionContext(tempDir, tempDir, tempDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"), + new FakeNuGetPackageCache(), + features, configuration); var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); @@ -627,11 +634,11 @@ public async Task NuGetConfigMerger_WhenStagingUsesSharedFeed_DoesNotAddGlobalPa // Assert var nugetConfigPath = Path.Combine(tempDir.FullName, "nuget.config"); Assert.True(File.Exists(nugetConfigPath)); - + var configContent = await File.ReadAllTextAsync(nugetConfigPath); Assert.DoesNotContain("globalPackagesFolder", configContent); Assert.DoesNotContain(".nugetpackages", configContent); - + // Verify it still has the shared feed URL Assert.Contains("dotnet9", configContent); } @@ -645,10 +652,10 @@ public async Task GetChannelsAsync_WhenStagingPinToCliVersionSet_ChannelHasPinne 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 { @@ -678,10 +685,10 @@ public async Task GetChannelsAsync_WhenStagingPinToCliVersionNotSet_ChannelHasNo 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 { @@ -709,10 +716,10 @@ public async Task GetChannelsAsync_WhenStagingPinToCliVersionSetButNotSharedFeed 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 { @@ -902,6 +909,13 @@ private sealed class FakeNuGetPackageCacheWithPackages(List !Semver.SemVersion.Parse(p.Version).IsPrerelease); return Task.FromResult>(filtered.ToList()); } + public Task> GetPackageVersionsAsync(DirectoryInfo workingDirectory, string exactPackageId, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + { + 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); diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs index 2deebbb0aca..294080529c6 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs @@ -248,7 +248,7 @@ await File.WriteAllTextAsync(settingsJson, """ prNewHive.FullName); var runner = new TestDotNetCliRunner(); - + // Use a real logger to capture debug output for diagnostics using var loggerFactory = LoggerFactory.Create(builder => { @@ -354,18 +354,21 @@ public Task> GetCliPackagesAsync(DirectoryInfo work public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) => Task.FromResult>([]); + + public Task> GetPackageVersionsAsync(DirectoryInfo workingDirectory, string exactPackageId, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + => Task.FromResult>([]); } private static void DumpDirectoryTree(string path, ITestOutputHelper output, string indent = "") { var dirInfo = new DirectoryInfo(path); output.WriteLine($"{indent}{dirInfo.Name}/"); - + foreach (var file in dirInfo.GetFiles()) { output.WriteLine($"{indent} {file.Name}"); } - + foreach (var dir in dirInfo.GetDirectories()) { DumpDirectoryTree(dir.FullName, output, indent + " "); diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs index 4aef9e1b59e..b942606268c 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 3bf653368cc..6a6062efe3f 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -49,6 +49,10 @@ private sealed class FakeNuGetPackageCache : INuGetPackageCache { _ = workingDirectory; _ = packageId; _ = filter; _ = prerelease; _ = nugetConfigFile; _ = useCache; _ = cancellationToken; return Task.FromResult>([]); } + public Task> GetPackageVersionsAsync(DirectoryInfo workingDirectory, string exactPackageId, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + { + _ = workingDirectory; _ = exactPackageId; _ = prerelease; _ = nugetConfigFile; _ = useCache; _ = cancellationToken; return Task.FromResult>([]); + } } private static PackageChannel CreateExplicitChannel(PackageMapping[] mappings) => @@ -522,7 +526,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 exactMatch, 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 b628d68ae9e..bd50a77319a 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeNuGetPackageCache.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeNuGetPackageCache.cs @@ -19,4 +19,7 @@ public Task> GetCliPackagesAsync(DirectoryInfo working public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) => Task.FromResult>([]); + + public Task> GetPackageVersionsAsync(DirectoryInfo workingDirectory, string exactPackageId, 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 32101c9ae3a..1a02b9480ef 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs @@ -21,7 +21,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; } @@ -68,7 +68,7 @@ public Task RestoreAsync(FileInfo projectFilePath, DotNetCliRunnerInvocatio ? Task.FromResult(GetNuGetConfigPathsAsyncCallback(workingDirectory, options, cancellationToken)) : Task.FromResult((0, GetGlobalNuGetPaths())); // If not overridden, return success with no config paths which will blow up. } - + private static string[] GetGlobalNuGetPaths() { return Environment.OSVersion.Platform switch @@ -106,10 +106,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 exactMatch, 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)) + ? Task.FromResult(SearchPackagesAsyncCallback(workingDirectory, query, exactMatch, 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 081dc306dbc..e3933d6c8bc 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs @@ -312,4 +312,9 @@ public Task> GetPackagesAsync(DirectoryInfo workingDir { return Task.FromResult(Enumerable.Empty()); } + + public Task> GetPackageVersionsAsync(DirectoryInfo workingDirectory, string exactPackageId, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + { + return Task.FromResult(Enumerable.Empty()); + } } From 97d2356240101c28e7322907edf9b63d76fe6613 Mon Sep 17 00:00:00 2001 From: Muckenbatscher Date: Tue, 24 Mar 2026 11:08:17 +0100 Subject: [PATCH 3/4] Added unit tests for the AddCommand When supplying a version parameter through the CLI handle the cases: * supplied version is not the latest version found in any channel * supplied version does not exist in any of the channels --- src/Aspire.Cli/Commands/AddCommand.cs | 8 +- .../Commands/AddCommandTests.cs | 205 ++++++++++++++++++ 2 files changed, 208 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index ea20c3b5e55..e735f096efa 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -199,11 +199,9 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => var selectedNuGetPackage = filteredPackagesWithShortName.Count() switch { 0 => await GetPackageByInteractiveFlowWithNoMatchesMessage(effectiveAppHostProjectFile.Directory!, packagesWithShortName, integrationName, cancellationToken), - 1 => filteredPackagesWithShortName.First().Package.Version == version - ? filteredPackagesWithShortName.First() - : await GetPackageByInteractiveFlow(effectiveAppHostProjectFile.Directory!, filteredPackagesWithShortName, null, cancellationToken), - > 1 => await GetPackageByInteractiveFlow(effectiveAppHostProjectFile.Directory!, filteredPackagesWithShortName, version, cancellationToken), - _ => throw new InvalidOperationException(AddCommandStrings.UnexpectedNumberOfPackagesFound) + 1 when filteredPackagesWithShortName.First().Package.Version == version + => filteredPackagesWithShortName.First(), + _ => await GetPackageByInteractiveFlow(effectiveAppHostProjectFile.Directory!, filteredPackagesWithShortName, version, cancellationToken) }; var source = parseResult.GetValue(s_sourceOption); diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index c30062ad98b..f6f71261505 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -254,6 +254,211 @@ public async Task AddCommandDoesNotPromptForVersionIfSpecifiedOnCommandLine() Assert.False(promptedForVersion); } + [Fact] + public async Task AddCommandDoesNotPromptForVersionIfOlderVersionSpecifiedOnCommandLine() + { + var promptedForIntegrationPackages = false; + var promptedForVersion = false; + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.AddCommandPrompterFactory = (sp) => + { + var interactionService = sp.GetRequiredService(); + var prompter = new TestAddCommandPrompter(interactionService); + + prompter.PromptForIntegrationCallback = (packages) => + { + promptedForIntegrationPackages = true; + throw new InvalidOperationException("Should not have been prompted for integration packages."); + }; + + prompter.PromptForIntegrationVersionCallback = (packages) => + { + promptedForVersion = true; + throw new InvalidOperationException("Should not have been prompted for integration version."); + }; + + return prompter; + }; + + options.ProjectLocatorFactory = _ => new TestProjectLocator(); + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner(); + runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + { + var dockerLatestPackage = new NuGetPackage() + { + Id = "Aspire.Hosting.Docker", + Source = "nuget", + Version = "13.2.0" + }; + var dockerOlderPackage = new NuGetPackage() + { + Id = "Aspire.Hosting.Docker", + Source = "nuget", + Version = "9.2.0" + }; + + var redisPackage = new NuGetPackage() + { + Id = "Aspire.Hosting.Redis", + Source = "nuget", + Version = "13.2.0" + }; + + var azureRedisPackage = new NuGetPackage() + { + Id = "Aspire.Hosting.Azure.Redis", + Source = "nuget", + Version = "13.2.0" + }; + + if (!exactMatch) // package search returns only latest version + { + return ( + 0, // Exit code. + new NuGetPackage[] { dockerLatestPackage, redisPackage, azureRedisPackage } + ); + } + else // exact match gets all previous versions of a specific package + { + return ( + 0, // Exit code. + new NuGetPackage[] { dockerLatestPackage, dockerOlderPackage } + ); + } + }; + + runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) => + { + // Simulate adding the package. + return 0; // Success. + }; + + return runner; + }; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("add docker --version 9.2.0"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, exitCode); + Assert.False(promptedForIntegrationPackages); + Assert.False(promptedForVersion); + } + + [Fact] + public async Task AddCommandPromptsForLatestVersionIfVersionSpecifiedOnCommandLineDoesNotExist() + { + IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)>? promptedPackageVersions = null; + var promptedForIntegrationPackages = false; + var promptedForVersion = false; + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment(); + + options.AddCommandPrompterFactory = (sp) => + { + var interactionService = sp.GetRequiredService(); + var prompter = new TestAddCommandPrompter(interactionService); + + prompter.PromptForIntegrationCallback = (packages) => + { + promptedForIntegrationPackages = true; + throw new InvalidOperationException("Should not have been prompted for integration packages."); + }; + + prompter.PromptForIntegrationVersionCallback = (packages) => + { + promptedForVersion = true; + promptedPackageVersions = packages; + return packages.First(); + }; + + return prompter; + }; + + options.ProjectLocatorFactory = _ => new TestProjectLocator(); + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner(); + runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + { + var dockerLatestPackage = new NuGetPackage() + { + Id = "Aspire.Hosting.Docker", + Source = "nuget", + Version = "13.2.0" + }; + var dockerOlderPackage = new NuGetPackage() + { + Id = "Aspire.Hosting.Docker", + Source = "nuget", + Version = "9.2.0" + }; + + var redisPackage = new NuGetPackage() + { + Id = "Aspire.Hosting.Redis", + Source = "nuget", + Version = "13.2.0" + }; + + var azureRedisPackage = new NuGetPackage() + { + Id = "Aspire.Hosting.Azure.Redis", + Source = "nuget", + Version = "13.2.0" + }; + + if (!exactMatch) // package search returns only latest version + { + return ( + 0, // Exit code. + new NuGetPackage[] { dockerLatestPackage, redisPackage, azureRedisPackage } + ); + } + else // exact match gets all previous versions of a specific package + { + return ( + 0, // Exit code. + new NuGetPackage[] { dockerLatestPackage, dockerOlderPackage } + ); + } + }; + + runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) => + { + // Simulate adding the package. + return 0; // Success. + }; + + return runner; + }; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("add docker --version 11.2.0"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, exitCode); + Assert.False(promptedForIntegrationPackages); + Assert.True(promptedForVersion); + var promptedPackage = Assert.Single(promptedPackageVersions!); + Assert.Equal("Aspire.Hosting.Docker", promptedPackage.Package.Id); + Assert.Equal("13.2.0", promptedPackage.Package.Version); + } + [Fact] public async Task AddCommandPromptsForDisambiguation() { From 4265b17912717391af8cc9cb8ac2ac4a975a978b Mon Sep 17 00:00:00 2001 From: Muckenbatscher Date: Tue, 24 Mar 2026 14:21:07 +0100 Subject: [PATCH 4/4] remove the workaround from PR #15396 `aspire add redis --version 13.1.2` will not prompt for a version in this E2E CLI test --- .../CentralPackageManagementTests.cs | 27 +------------------ 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs index 099501c4689..26a2573d9bf 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs @@ -170,33 +170,8 @@ public async Task AspireAddPackageVersionToDirectoryPackagesProps() """); - var waitingForVersionSelection = new CellPatternSearcher() - .Find("Select a version of"); - var versionSelectionShown = false; - - await auto.TypeAsync("aspire add Aspire.Hosting.Redis"); + await auto.TypeAsync("aspire add Aspire.Hosting.Redis --version 13.1.2"); await auto.EnterAsync(); - await auto.WaitUntilAsync(s => - { - if (waitingForVersionSelection.Search(s).Count > 0) - { - versionSelectionShown = true; - return true; - } - - var successPromptSearcher = new CellPatternSearcher() - .FindPattern(counter.Value.ToString()) - .RightText(" OK] $ "); - - return successPromptSearcher.Search(s).Count > 0; - }, timeout: TimeSpan.FromSeconds(180), description: "version selection prompt or success prompt"); - - if (versionSelectionShown) - { - // PR hives can surface multiple channels in CI. Accept the default implicit-channel version - // so this test validates CPM behavior without pinning a specific package version. - await auto.EnterAsync(); - } await auto.WaitForSuccessPromptAsync(counter);