diff --git a/extension/schemas/aspire-global-settings.schema.json b/extension/schemas/aspire-global-settings.schema.json index 8f7a8db0414..bf1ec2494b1 100644 --- a/extension/schemas/aspire-global-settings.schema.json +++ b/extension/schemas/aspire-global-settings.schema.json @@ -296,11 +296,11 @@ "type": "string" }, "overrideStagingFeed": { - "description": "Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.", + "description": "[Internal] Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.", "type": "string" }, "overrideStagingQuality": { - "description": "Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".", + "description": "[Internal] Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".", "type": "string", "enum": [ "Stable", @@ -308,9 +308,13 @@ "Both" ] }, - "stagingVersionPrefix": { - "description": "Filter staging channel packages to a specific Major.Minor version (e.g., \"13.2\"). When set, only packages matching this version prefix are shown, preventing newer daily versions from being selected.", - "type": "string" + "stagingPinToCliVersion": { + "description": "[Internal] When set to \"true\" and using the staging channel with Prerelease quality on the shared feed, all template and integration packages are pinned to the exact version of the installed CLI. This bypasses NuGet search entirely, ensuring version consistency.", + "type": "string", + "enum": [ + "true", + "false" + ] } }, "additionalProperties": false diff --git a/extension/schemas/aspire-settings.schema.json b/extension/schemas/aspire-settings.schema.json index c2da807d981..bd71628f932 100644 --- a/extension/schemas/aspire-settings.schema.json +++ b/extension/schemas/aspire-settings.schema.json @@ -300,11 +300,11 @@ "type": "string" }, "overrideStagingFeed": { - "description": "Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.", + "description": "[Internal] Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.", "type": "string" }, "overrideStagingQuality": { - "description": "Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".", + "description": "[Internal] Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".", "type": "string", "enum": [ "Stable", @@ -312,9 +312,13 @@ "Both" ] }, - "stagingVersionPrefix": { - "description": "Filter staging channel packages to a specific Major.Minor version (e.g., \"13.2\"). When set, only packages matching this version prefix are shown, preventing newer daily versions from being selected.", - "type": "string" + "stagingPinToCliVersion": { + "description": "[Internal] When set to \"true\" and using the staging channel with Prerelease quality on the shared feed, all template and integration packages are pinned to the exact version of the installed CLI. This bypasses NuGet search entirely, ensuring version consistency.", + "type": "string", + "enum": [ + "true", + "false" + ] } }, "additionalProperties": false diff --git a/src/Aspire.Cli/Packaging/PackageChannel.cs b/src/Aspire.Cli/Packaging/PackageChannel.cs index 8152e83ca9c..5bcd3bb2071 100644 --- a/src/Aspire.Cli/Packaging/PackageChannel.cs +++ b/src/Aspire.Cli/Packaging/PackageChannel.cs @@ -8,7 +8,7 @@ namespace Aspire.Cli.Packaging; -internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, SemVersion? versionPrefix = null) +internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, string? pinnedVersion = null) { public string Name { get; } = name; public PackageChannelQuality Quality { get; } = quality; @@ -16,20 +16,10 @@ internal class PackageChannel(string name, PackageChannelQuality quality, Packag public PackageChannelType Type { get; } = mappings is null ? PackageChannelType.Implicit : PackageChannelType.Explicit; public bool ConfigureGlobalPackagesFolder { get; } = configureGlobalPackagesFolder; public string? CliDownloadBaseUrl { get; } = cliDownloadBaseUrl; - public SemVersion? VersionPrefix { get; } = versionPrefix; + public string? PinnedVersion { get; } = pinnedVersion; public string SourceDetails { get; } = ComputeSourceDetails(mappings); - private bool MatchesVersionPrefix(SemVersion semVer) - { - if (VersionPrefix is null) - { - return true; - } - - return semVer.Major == VersionPrefix.Major && semVer.Minor == VersionPrefix.Minor; - } - private static string ComputeSourceDetails(PackageMapping[]? mappings) { if (mappings is null) @@ -52,6 +42,11 @@ private static string ComputeSourceDetails(PackageMapping[]? mappings) public async Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken) { + if (PinnedVersion is not null) + { + return [new NuGetPackage { Id = "Aspire.ProjectTemplates", Version = PinnedVersion, Source = SourceDetails }]; + } + var tasks = new List>>(); using var tempNuGetConfig = Type is PackageChannelType.Explicit ? await TemporaryNuGetConfig.CreateAsync(Mappings!) : null; @@ -80,7 +75,7 @@ public async Task> GetTemplatePackagesAsync(DirectoryI { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true, { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true, _ => false - }).Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); + }); return filteredPackages; } @@ -115,13 +110,25 @@ public async Task> GetIntegrationPackagesAsync(Directo { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true, { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true, _ => false - }).Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); + }); + + // When pinned to a specific version, override the version on each discovered package + // so the correct version gets installed regardless of what the feed reports as latest. + if (PinnedVersion is not null) + { + return filteredPackages.Select(p => new NuGetPackage { Id = p.Id, Version = PinnedVersion, Source = p.Source }); + } return filteredPackages; } public async Task> GetPackagesAsync(string packageId, DirectoryInfo workingDirectory, CancellationToken cancellationToken) { + if (PinnedVersion is not null) + { + return [new NuGetPackage { Id = packageId, Version = PinnedVersion, Source = SourceDetails }]; + } + var tasks = new List>>(); using var tempNuGetConfig = Type is PackageChannelType.Explicit ? await TemporaryNuGetConfig.CreateAsync(Mappings!) : null; @@ -170,7 +177,7 @@ public async Task> GetPackagesAsync(string packageId, useCache: true, // Enable caching for package channel resolution cancellationToken: cancellationToken); - return packages.Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); + return packages; } // When doing a `dotnet package search` the the results may include stable packages even when searching for @@ -181,14 +188,14 @@ public async Task> GetPackagesAsync(string packageId, { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true, { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true, _ => false - }).Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); + }); return filteredPackages; } - public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, SemVersion? versionPrefix = null) + public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, string? pinnedVersion = null) { - return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder, cliDownloadBaseUrl, versionPrefix); + return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder, cliDownloadBaseUrl, pinnedVersion); } public static PackageChannel CreateImplicitChannel(INuGetPackageCache nuGetPackageCache) diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index 7bde39025b0..d1e91bc06d4 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -4,7 +4,6 @@ using Aspire.Cli.Configuration; using Aspire.Cli.NuGet; using Microsoft.Extensions.Configuration; -using Semver; using System.Reflection; namespace Aspire.Cli.Packaging; @@ -92,13 +91,13 @@ public Task> GetChannelsAsync(CancellationToken canc return null; } - var versionPrefix = GetStagingVersionPrefix(); + var pinnedVersion = GetStagingPinnedVersion(useSharedFeed); var stagingChannel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Staging, stagingQuality, new[] { new PackageMapping("Aspire*", stagingFeedUrl), new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") - }, nuGetPackageCache, configureGlobalPackagesFolder: !useSharedFeed, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/rc/daily", versionPrefix: versionPrefix); + }, nuGetPackageCache, configureGlobalPackagesFolder: !useSharedFeed, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/rc/daily", pinnedVersion: pinnedVersion); return stagingChannel; } @@ -166,20 +165,18 @@ private PackageChannelQuality GetStagingQuality() return PackageChannelQuality.Stable; } - private SemVersion? GetStagingVersionPrefix() + private string? GetStagingPinnedVersion(bool useSharedFeed) { - var versionPrefixValue = configuration["stagingVersionPrefix"]; - if (string.IsNullOrEmpty(versionPrefixValue)) + // Only pin versions when using the shared feed and the config flag is set + var pinToCliVersion = configuration["stagingPinToCliVersion"]; + if (!useSharedFeed || !string.Equals(pinToCliVersion, "true", StringComparison.OrdinalIgnoreCase)) { return null; } - // Parse "Major.Minor" format (e.g., "13.2") as a SemVersion for comparison - if (SemVersion.TryParse($"{versionPrefixValue}.0", SemVersionStyles.Strict, out var semVersion)) - { - return semVersion; - } - - return null; + // Get the CLI's own version and strip build metadata (+hash) + var cliVersion = Utils.VersionHelper.GetDefaultTemplateVersion(); + var plusIndex = cliVersion.IndexOf('+'); + return plusIndex >= 0 ? cliVersion[..plusIndex] : cliVersion; } } diff --git a/src/Shared/PackageUpdateHelpers.cs b/src/Shared/PackageUpdateHelpers.cs index 99ff9181778..d5335b94750 100644 --- a/src/Shared/PackageUpdateHelpers.cs +++ b/src/Shared/PackageUpdateHelpers.cs @@ -144,7 +144,9 @@ public static List ParsePackageSearchResults(string stdout, string { var id = packageResult.GetProperty("id").GetString()!; - var version = packageResult.GetProperty("latestVersion").GetString()!; + var version = packageResult.TryGetProperty("latestVersion", out var latestVersionProp) + ? latestVersionProp.GetString()! + : packageResult.GetProperty("version").GetString()!; if (packageId == null || id == packageId) { diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs new file mode 100644 index 00000000000..14498306c9d --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs @@ -0,0 +1,172 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.EndToEnd.Tests.Helpers; +using Aspire.Cli.Tests.Utils; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// End-to-end tests for staging channel configuration and self-update channel switching. +/// Verifies that staging settings (overrideStagingQuality, stagingPinToCliVersion) are +/// correctly persisted and that aspire update --self saves the channel to global settings. +/// +public sealed class StagingChannelTests(ITestOutputHelper output) +{ + [Fact] + public async Task StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels() + { + var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels)); + + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (isCI) + { + sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireCliEnvironment(counter); + sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); + } + + // Step 1: Configure staging channel settings via aspire config set + // Enable the staging channel feature flag + sequenceBuilder + .Type("aspire config set features.stagingChannelEnabled true -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Set quality to Prerelease (triggers shared feed mode) + sequenceBuilder + .Type("aspire config set overrideStagingQuality Prerelease -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Enable pinned version mode + sequenceBuilder + .Type("aspire config set stagingPinToCliVersion true -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Set channel to staging + sequenceBuilder + .Type("aspire config set channel staging -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 2: Verify the settings were persisted in the global settings file + var settingsFilePattern = new CellPatternSearcher() + .Find("stagingPinToCliVersion"); + + sequenceBuilder + .ClearScreen(counter) + .Type("cat ~/.aspire/globalsettings.json") + .Enter() + .WaitUntil(s => settingsFilePattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .WaitForSuccessPrompt(counter); + + // Step 3: Verify aspire config get returns the correct values + var stagingChannelPattern = new CellPatternSearcher() + .Find("staging"); + + sequenceBuilder + .ClearScreen(counter) + .Type("aspire config get channel") + .Enter() + .WaitUntil(s => stagingChannelPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .WaitForSuccessPrompt(counter); + + // Step 4: Verify the CLI version is available (basic smoke test that the CLI works with these settings) + sequenceBuilder + .ClearScreen(counter) + .Type("aspire --version") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 5: Switch channel to stable via config set (simulating what update --self does) + sequenceBuilder + .Type("aspire config set channel stable -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 6: Verify channel was changed to stable + var stableChannelPattern = new CellPatternSearcher() + .Find("stable"); + + sequenceBuilder + .ClearScreen(counter) + .Type("aspire config get channel") + .Enter() + .WaitUntil(s => stableChannelPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .WaitForSuccessPrompt(counter); + + // Step 7: Switch back to staging + sequenceBuilder + .Type("aspire config set channel staging -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 8: Verify channel is staging again and staging settings are still present + sequenceBuilder + .ClearScreen(counter) + .Type("aspire config get channel") + .Enter() + .WaitUntil(s => stagingChannelPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .WaitForSuccessPrompt(counter); + + // Verify the staging-specific settings survived the channel switch + var prereleasePattern = new CellPatternSearcher() + .Find("Prerelease"); + + sequenceBuilder + .ClearScreen(counter) + .Type("aspire config get overrideStagingQuality") + .Enter() + .WaitUntil(s => prereleasePattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .WaitForSuccessPrompt(counter); + + // Clean up: remove staging settings to avoid polluting other tests + sequenceBuilder + .Type("aspire config delete features.stagingChannelEnabled -g") + .Enter() + .WaitForSuccessPrompt(counter) + .Type("aspire config delete overrideStagingQuality -g") + .Enter() + .WaitForSuccessPrompt(counter) + .Type("aspire config delete stagingPinToCliVersion -g") + .Enter() + .WaitForSuccessPrompt(counter) + .Type("aspire config delete channel -g") + .Enter() + .WaitForSuccessPrompt(counter); + + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + + await pendingRun; + } +} diff --git a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs index 74b71823ea7..51ce56b6907 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs @@ -637,7 +637,7 @@ public async Task NuGetConfigMerger_WhenStagingUsesSharedFeed_DoesNotAddGlobalPa } [Fact] - public async Task GetChannelsAsync_WhenStagingVersionPrefixSet_ChannelHasVersionPrefix() + public async Task GetChannelsAsync_WhenStagingPinToCliVersionSet_ChannelHasPinnedVersion() { // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -652,8 +652,8 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixSet_ChannelHasVersion var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["overrideStagingFeed"] = "https://example.com/nuget/v3/index.json", - ["stagingVersionPrefix"] = "13.2" + ["overrideStagingQuality"] = "Prerelease", + ["stagingPinToCliVersion"] = "true" }) .Build(); @@ -664,13 +664,13 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixSet_ChannelHasVersion // Assert var stagingChannel = channels.First(c => c.Name == "staging"); - Assert.NotNull(stagingChannel.VersionPrefix); - Assert.Equal(13, stagingChannel.VersionPrefix!.Major); - Assert.Equal(2, stagingChannel.VersionPrefix.Minor); + Assert.NotNull(stagingChannel.PinnedVersion); + // Should not contain build metadata (+hash) + Assert.DoesNotContain("+", stagingChannel.PinnedVersion); } [Fact] - public async Task GetChannelsAsync_WhenStagingVersionPrefixNotSet_ChannelHasNoVersionPrefix() + public async Task GetChannelsAsync_WhenStagingPinToCliVersionNotSet_ChannelHasNoPinnedVersion() { // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -685,7 +685,8 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixNotSet_ChannelHasNoVe var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["overrideStagingFeed"] = "https://example.com/nuget/v3/index.json" + ["overrideStagingQuality"] = "Prerelease" + // No stagingPinToCliVersion }) .Build(); @@ -696,13 +697,13 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixNotSet_ChannelHasNoVe // Assert var stagingChannel = channels.First(c => c.Name == "staging"); - Assert.Null(stagingChannel.VersionPrefix); + Assert.Null(stagingChannel.PinnedVersion); } [Fact] - public async Task GetChannelsAsync_WhenStagingVersionPrefixInvalid_ChannelHasNoVersionPrefix() + public async Task GetChannelsAsync_WhenStagingPinToCliVersionSetButNotSharedFeed_ChannelHasNoPinnedVersion() { - // Arrange + // Arrange - pin is set but explicit feed override means not using shared feed using var workspace = TemporaryWorkspace.Create(outputHelper); var tempDir = workspace.WorkspaceRoot; var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); @@ -716,7 +717,7 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixInvalid_ChannelHasNoV .AddInMemoryCollection(new Dictionary { ["overrideStagingFeed"] = "https://example.com/nuget/v3/index.json", - ["stagingVersionPrefix"] = "not-a-version" + ["stagingPinToCliVersion"] = "true" }) .Build(); @@ -727,6 +728,188 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixInvalid_ChannelHasNoV // Assert var stagingChannel = channels.First(c => c.Name == "staging"); - Assert.Null(stagingChannel.VersionPrefix); + // With explicit feed override, useSharedFeed is false, so pinning is not activated + Assert.Null(stagingChannel.PinnedVersion); + } + + /// + /// Verifies that when pinned to CLI version, GetTemplatePackagesAsync returns a synthetic result + /// with the pinned version, bypassing actual NuGet search. + /// + [Fact] + public async Task StagingChannel_WithPinnedVersion_ReturnsSyntheticTemplatePackage() + { + // Arrange - simulate a shared feed that has packages from both 13.2 and 13.3 version lines + var fakeCache = new FakeNuGetPackageCacheWithPackages( + [ + new() { Id = "Aspire.ProjectTemplates", Version = "13.3.0-preview.1.26201.1", Source = "dotnet9" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.3.0-preview.1.26200.5", Source = "dotnet9" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.2.0-preview.1.26111.6", Source = "dotnet9" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.2.0-preview.1.26110.3", Source = "dotnet9" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.1.0", Source = "dotnet9" }, + ]); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingQuality"] = "Prerelease", + ["stagingPinToCliVersion"] = "true" + }) + .Build(); + + var packagingService = new PackagingService(executionContext, fakeCache, features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + var stagingChannel = channels.First(c => c.Name == "staging"); + var templatePackages = await stagingChannel.GetTemplatePackagesAsync(tempDir, CancellationToken.None).DefaultTimeout(); + + // Assert - should return exactly one synthetic package with the CLI's pinned version + var packageList = templatePackages.ToList(); + outputHelper.WriteLine($"Template packages returned: {packageList.Count}"); + foreach (var p in packageList) + { + outputHelper.WriteLine($" {p.Id} {p.Version}"); + } + + Assert.Single(packageList); + Assert.Equal("Aspire.ProjectTemplates", packageList[0].Id); + Assert.Equal(stagingChannel.PinnedVersion, packageList[0].Version); + // Pinned version should not contain build metadata + Assert.DoesNotContain("+", packageList[0].Version!); + } + + /// + /// Verifies that when pinned to CLI version, GetIntegrationPackagesAsync discovers packages + /// from the feed but overrides their version to the pinned version. + /// + [Fact] + public async Task StagingChannel_WithPinnedVersion_OverridesIntegrationPackageVersions() + { + // Arrange - integration packages with various versions + var fakeCache = new FakeNuGetPackageCacheWithPackages( + [ + new() { Id = "Aspire.Hosting.Redis", Version = "13.3.0-preview.1.26201.1", Source = "dotnet9" }, + new() { Id = "Aspire.Hosting.PostgreSQL", Version = "13.3.0-preview.1.26201.1", Source = "dotnet9" }, + ]); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingQuality"] = "Prerelease", + ["stagingPinToCliVersion"] = "true" + }) + .Build(); + + var packagingService = new PackagingService(executionContext, fakeCache, features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + var stagingChannel = channels.First(c => c.Name == "staging"); + var integrationPackages = await stagingChannel.GetIntegrationPackagesAsync(tempDir, CancellationToken.None).DefaultTimeout(); + + // Assert - should discover both packages but with pinned version + var packageList = integrationPackages.ToList(); + outputHelper.WriteLine($"Integration packages returned: {packageList.Count}"); + foreach (var p in packageList) + { + outputHelper.WriteLine($" {p.Id} {p.Version}"); + } + + Assert.Equal(2, packageList.Count); + Assert.All(packageList, p => Assert.Equal(stagingChannel.PinnedVersion, p.Version)); + Assert.Contains(packageList, p => p.Id == "Aspire.Hosting.Redis"); + Assert.Contains(packageList, p => p.Id == "Aspire.Hosting.PostgreSQL"); + } + + /// + /// Verifies that without pinning, all prerelease packages from the feed are returned as-is. + /// + [Fact] + public async Task StagingChannel_WithoutPinnedVersion_ReturnsAllPrereleasePackages() + { + // Arrange + var fakeCache = new FakeNuGetPackageCacheWithPackages( + [ + new() { Id = "Aspire.ProjectTemplates", Version = "13.3.0-preview.1.26201.1", Source = "dotnet9" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.2.0-preview.1.26111.6", Source = "dotnet9" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.1.0", Source = "dotnet9" }, + ]); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingQuality"] = "Prerelease" + // No stagingPinToCliVersion — should return all prerelease + }) + .Build(); + + var packagingService = new PackagingService(executionContext, fakeCache, features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + var stagingChannel = channels.First(c => c.Name == "staging"); + var templatePackages = await stagingChannel.GetTemplatePackagesAsync(tempDir, CancellationToken.None).DefaultTimeout(); + + // Assert + var packageList = templatePackages.ToList(); + outputHelper.WriteLine($"Template packages returned: {packageList.Count}"); + foreach (var p in packageList) + { + outputHelper.WriteLine($" {p.Id} {p.Version}"); + } + + // Should return only the prerelease ones (quality filter), but both 13.3 and 13.2 + Assert.Equal(2, packageList.Count); + Assert.Contains(packageList, p => p.Version!.StartsWith("13.3")); + Assert.Contains(packageList, p => p.Version!.StartsWith("13.2")); + } + + private sealed class FakeNuGetPackageCacheWithPackages(List packages) : INuGetPackageCache + { + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + { + // Simulate what the real cache does: filter by prerelease flag + var filtered = prerelease + ? packages.Where(p => Semver.SemVersion.Parse(p.Version).IsPrerelease) + : packages.Where(p => !Semver.SemVersion.Parse(p.Version).IsPrerelease); + return Task.FromResult>(filtered.ToList()); + } + + public Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => GetTemplatePackagesAsync(workingDirectory, prerelease, nugetConfigFile, cancellationToken); + + public Task> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => Task.FromResult>([]); + + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + => GetTemplatePackagesAsync(workingDirectory, prerelease, nugetConfigFile, cancellationToken); } } diff --git a/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs b/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs index ec634093d30..77deca3237b 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs @@ -28,7 +28,7 @@ public async Task PrereleaseWillRecommendUpgradeToPrereleaseOnSameVersionFamily( { var cache = new TestNuGetPackageCache(); cache.SetMockCliPackages([ - // Should be ignored because its lower that current prerelease version. + // Should be ignored because it's lower than current prerelease version. new NuGetPackage { Id = "Aspire.Cli", Version = "9.3.1", Source = "nuget.org" }, // Should be selected because it is higher than 9.4.0-dev (dev and preview sort using alphabetical sort).