diff --git a/src/Aspire.Cli/Commands/IntegrationPackageSearchService.cs b/src/Aspire.Cli/Commands/IntegrationPackageSearchService.cs index 8b0cb36003a..17646b8a434 100644 --- a/src/Aspire.Cli/Commands/IntegrationPackageSearchService.cs +++ b/src/Aspire.Cli/Commands/IntegrationPackageSearchService.cs @@ -23,7 +23,7 @@ internal sealed class IntegrationPackageSearchService( public async Task> GetIntegrationPackagesWithChannelsAsync(DirectoryInfo workingDirectory, string? configuredChannel, CancellationToken cancellationToken) { - var allChannels = await packagingService.GetChannelsAsync(cancellationToken); + var allChannels = await packagingService.GetChannelsAsync(cancellationToken, configuredChannel); if (!string.IsNullOrEmpty(configuredChannel)) { diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index 3fc4e9c52cc..3be12832451 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -100,7 +100,8 @@ public NewCommand( Options.Add(s_suppressAgentInitOption); // Customize description based on whether staging channel is enabled - var isStagingEnabled = KnownFeatures.IsStagingChannelEnabled(_features, configuration); + var isStagingEnabled = KnownFeatures.IsStagingChannelEnabled(_features, configuration) + || string.Equals(ExecutionContext.IdentityChannel, PackageChannelNames.Staging, StringComparison.OrdinalIgnoreCase); _channelOption = new Option("--channel") { Description = isStagingEnabled @@ -309,9 +310,8 @@ private async Task ResolveCliTemplateVersionAsync( NewCommandStrings.ResolvingTemplateVersion, async () => { - var channels = await _packagingService.GetChannelsAsync(cancellationToken); - var configuredChannelName = parseResult.GetValue(_channelOption); + var channels = await _packagingService.GetChannelsAsync(cancellationToken, configuredChannelName); // When no --channel was passed, prefer the channel whose name matches the running // CLI's identity (CliExecutionContext.IdentityChannel — stable, staging, daily, diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index 625a453e571..172b1f79ca7 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -83,7 +83,7 @@ public UpdateCommand( AddNonInteractiveRequiresYesValidator(this, s_yesOption); // Customize description based on whether staging channel is enabled - var isStagingEnabled = KnownFeatures.IsStagingChannelEnabled(_features, _configuration); + var isStagingEnabled = IsStagingChannelAvailable(); _channelOption = new Option("--channel") { @@ -203,7 +203,7 @@ protected override async Task ExecuteAsync(ParseResult parseResul var allChannels = await InteractionService.ShowStatusAsync( UpdateCommandStrings.CheckingForUpdates, - async () => await _packagingService.GetChannelsAsync(cancellationToken)); + async () => await _packagingService.GetChannelsAsync(cancellationToken, channelName)); if (!string.IsNullOrWhiteSpace(channelName)) { @@ -359,6 +359,12 @@ protected override async Task ExecuteAsync(ParseResult parseResul return CommandResult.FromExitCode(0); } + private bool IsStagingChannelAvailable() + { + return KnownFeatures.IsStagingChannelEnabled(_features, _configuration) + || string.Equals(ExecutionContext.IdentityChannel, PackageChannelNames.Staging, StringComparison.OrdinalIgnoreCase); + } + private async Task ExecuteSelfUpdateAsync(ParseResult parseResult, CancellationToken cancellationToken, string? selectedChannel = null) { var channel = selectedChannel ?? parseResult.GetValue(_channelOption) ?? parseResult.GetValue(_qualityOption); @@ -369,7 +375,7 @@ private async Task ExecuteSelfUpdateAsync(ParseResult parseResult // aspire.config.json, not from any global setting. if (string.IsNullOrEmpty(channel)) { - var isStagingEnabled = KnownFeatures.IsStagingChannelEnabled(_features, _configuration); + var isStagingEnabled = IsStagingChannelAvailable(); var channels = isStagingEnabled ? new[] { PackageChannelNames.Stable, PackageChannelNames.Staging, PackageChannelNames.Daily } : new[] { PackageChannelNames.Stable, PackageChannelNames.Daily }; diff --git a/src/Aspire.Cli/KnownFeatures.cs b/src/Aspire.Cli/KnownFeatures.cs index 852b645f8a4..030a6298441 100644 --- a/src/Aspire.Cli/KnownFeatures.cs +++ b/src/Aspire.Cli/KnownFeatures.cs @@ -120,13 +120,9 @@ public static IEnumerable GetAllFeatureNames() /// Note that the channel check reads configuration["channel"] (the layered .NET /// configuration — environment variables, command-line, global / per-project /// aspire.config.json#channel), NOT - /// . The staging channel is an - /// opt-in feature: a CLI baked with AspireCliChannel=staging does NOT - /// auto-enable staging in the packaging service unless the user has also set the - /// configuration value (for example via aspire config set channel staging or - /// --channel staging). This is by design — the identity baked into the binary - /// is reserved for selecting the correct hive directory, while feature gating goes - /// through user-visible configuration. + /// . Callers that also need to expose + /// staging for a CLI baked with AspireCliChannel=staging should combine this + /// helper with an identity-channel check. /// public static bool IsStagingChannelEnabled(IFeatures features, IConfiguration configuration) { diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index f3121672647..822fd216a35 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -13,12 +13,12 @@ namespace Aspire.Cli.Packaging; internal interface IPackagingService { - public Task> GetChannelsAsync(CancellationToken cancellationToken = default); + public Task> GetChannelsAsync(CancellationToken cancellationToken = default, string? requestedChannelName = null); } internal class PackagingService(CliExecutionContext executionContext, INuGetPackageCache nuGetPackageCache, IFeatures features, IConfiguration configuration, ILogger logger) : IPackagingService { - public Task> GetChannelsAsync(CancellationToken cancellationToken = default) + public Task> GetChannelsAsync(CancellationToken cancellationToken = default, string? requestedChannelName = null) { var defaultChannel = PackageChannel.CreateImplicitChannel(nuGetPackageCache, logger); @@ -62,10 +62,18 @@ public Task> GetChannelsAsync(CancellationToken canc var channels = new List([defaultChannel, stableChannel]); - // Add staging channel if feature is enabled (after stable, before daily) - if (KnownFeatures.IsStagingChannelEnabled(features, configuration)) + // Add staging channel after stable and before daily. Staging CLI builds should + // dogfood staging packages even before a project-level channel pin exists, and + // callers that already resolved a staging channel from another project directory + // need the channel materialized before they can match it below. + var stagingChannelConfigured = string.Equals(configuration["channel"], PackageChannelNames.Staging, StringComparison.OrdinalIgnoreCase); + var stagingChannelRequested = string.Equals(requestedChannelName, PackageChannelNames.Staging, StringComparison.OrdinalIgnoreCase); + var stagingIdentityChannel = string.Equals(executionContext.IdentityChannel, PackageChannelNames.Staging, StringComparison.OrdinalIgnoreCase); + var stagingFeatureEnabled = features.IsFeatureEnabled(KnownFeatures.StagingChannelEnabled, false); + if (stagingFeatureEnabled || stagingChannelConfigured || stagingChannelRequested || stagingIdentityChannel) { - var stagingChannel = CreateStagingChannel(); + var defaultQuality = stagingChannelConfigured || stagingChannelRequested || stagingIdentityChannel ? PackageChannelQuality.Both : PackageChannelQuality.Stable; + var stagingChannel = CreateStagingChannel(defaultQuality); if (stagingChannel is not null) { channels.Add(stagingChannel); @@ -79,9 +87,9 @@ public Task> GetChannelsAsync(CancellationToken canc return Task.FromResult>(channels); } - private PackageChannel? CreateStagingChannel() + private PackageChannel? CreateStagingChannel(PackageChannelQuality defaultQuality) { - var stagingQuality = GetStagingQuality(); + var stagingQuality = GetStagingQuality(defaultQuality); var hasExplicitFeedOverride = !string.IsNullOrEmpty(configuration["overrideStagingFeed"]); // When quality is Prerelease or Both and no explicit feed override is set, @@ -153,7 +161,7 @@ public Task> GetChannelsAsync(CancellationToken canc return $"https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-{truncatedHash}/nuget/v3/index.json"; } - private PackageChannelQuality GetStagingQuality() + private PackageChannelQuality GetStagingQuality(PackageChannelQuality defaultQuality) { // Check for configuration override var overrideQuality = configuration["overrideStagingQuality"]; @@ -166,8 +174,9 @@ private PackageChannelQuality GetStagingQuality() } } - // Default to Stable if not specified or invalid - return PackageChannelQuality.Stable; + // Preserve the historical safe fallback for invalid override values while allowing + // different staging entry points to choose a better default when no override is set. + return string.IsNullOrEmpty(overrideQuality) ? defaultQuality : PackageChannelQuality.Stable; } private string? GetStagingPinnedVersion(bool useSharedFeed) diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index 4d648b6ddaa..3541614949f 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -325,9 +325,9 @@ private XDocument CreateProjectFile(IEnumerable integratio File.Copy(userNugetConfig, nugetConfigPath, overwrite: true); } - var channels = await _packagingService.GetChannelsAsync(cancellationToken); var configuredChannelName = AspireConfigFile.Load(_appPath)?.Channel ?? AspireJsonConfiguration.Load(_appPath)?.Channel; + var channels = await _packagingService.GetChannelsAsync(cancellationToken, configuredChannelName); // Resolve channel sources and add them via RestoreAdditionalProjectSources // This is additive — it preserves the user's nuget.config and adds channel-specific sources diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 4a7b033debd..040097bf28b 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -1192,10 +1192,19 @@ public async Task UpdatePackagesAsync(UpdatePackagesContex return packageUpdates; }); + var explicitChannelName = context.Channel.Type == Packaging.PackageChannelType.Explicit ? context.Channel.Name : null; + var explicitChannelChanged = explicitChannelName is not null && !string.Equals(config.Channel, explicitChannelName, StringComparisons.CliInputOrOutput); + if (updates.Count == 0 && newSdkVersion is null) { + if (explicitChannelChanged) + { + config.Channel = explicitChannelName; + SaveConfiguration(config, directory); + } + _interactionService.DisplayMessage(KnownEmojis.CheckMarkButton, UpdateCommandStrings.ProjectUpToDateMessage); - return new UpdatePackagesResult { UpdatesApplied = false }; + return new UpdatePackagesResult { UpdatesApplied = explicitChannelChanged }; } // Display pending updates @@ -1221,17 +1230,14 @@ public async Task UpdatePackagesAsync(UpdatePackagesContex { config.SdkVersion = newSdkVersion; } - // Persist the channel only when the update resolved an explicit channel (--channel, - // per-project config, or prompt selection). When the resolved channel is Implicit - // — i.e. the user hasn't pinned a channel — leave the project's existing setting - // untouched rather than silently pinning the running CLI's identity, which would - // propagate dev/PR-build identities into the project file. The scaffolding / - // build-time paths intentionally do auto-pin identity (see GuestAppHostProject.cs:354 - // and ScaffoldingService.cs:208) — but `aspire update` is a no-pin path: the user - // is updating, not initialising, and we should not change the channel pin state. - if (context.Channel.Type == Packaging.PackageChannelType.Explicit) + // Persist the channel when update resolved an explicit channel. That can come from + // --channel, per-project/global config, prompt selection, or the UpdateCommand + // identity-channel fallback for non-project-reference AppHosts. When the resolved + // channel is Implicit — i.e. no explicit channel source matched — leave the project's + // existing setting untouched rather than pinning the implicit/default channel. + if (explicitChannelName is not null) { - config.Channel = context.Channel.Name; + config.Channel = explicitChannelName; } foreach (var (packageId, _, newVersion) in updates) { diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 29488f14825..a00a7aa8605 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -467,7 +467,7 @@ internal static string GenerateIntegrationProjectFile( try { - var channels = await _packagingService.GetChannelsAsync(cancellationToken); + var channels = await _packagingService.GetChannelsAsync(cancellationToken, requestedChannel); IEnumerable explicitChannels; if (!string.IsNullOrEmpty(requestedChannel)) @@ -511,7 +511,7 @@ internal static string GenerateIntegrationProjectFile( return null; } - var channels = await _packagingService.GetChannelsAsync(cancellationToken); + var channels = await _packagingService.GetChannelsAsync(cancellationToken, requestedChannel); var channel = channels.FirstOrDefault(c => c.Type == PackageChannelType.Explicit && c.Mappings is { Length: > 0 } && diff --git a/src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs b/src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs index a57efffce92..580d37c0344 100644 --- a/src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs +++ b/src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs @@ -85,7 +85,7 @@ public async Task PromptToCreateOrUpdateNuGetConfigAsync(string? channelName, st return; } - var channels = await packagingService.GetChannelsAsync(cancellationToken); + var channels = await packagingService.GetChannelsAsync(cancellationToken, channelName); var matchingChannel = channels.FirstOrDefault(c => string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)); @@ -123,7 +123,7 @@ public async Task CreateOrUpdateNuGetConfigWithoutPromptAsync(string? chan return false; } - var channels = await packagingService.GetChannelsAsync(cancellationToken); + var channels = await packagingService.GetChannelsAsync(cancellationToken, channelName); var matchingChannel = channels.FirstOrDefault(c => string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)); @@ -155,7 +155,7 @@ public async Task CreateOrUpdateNuGetConfigWithoutPromptAsync(string? chan /// Thrown when no template package versions are available across the considered channels. public async Task ResolveTemplatePackageAsync(TemplatePackageQuery query, CancellationToken cancellationToken) { - var allChannels = await packagingService.GetChannelsAsync(cancellationToken); + var allChannels = await packagingService.GetChannelsAsync(cancellationToken, query.RequestedChannel); // Honor PR hives only when the caller opts in. Init suppresses this so a developer // with stale ~/.aspire/hives/* doesn't get a different template than on a clean machine. diff --git a/src/Aspire.Cli/Utils/CliDownloader.cs b/src/Aspire.Cli/Utils/CliDownloader.cs index 8ae84a0a130..d18163494b0 100644 --- a/src/Aspire.Cli/Utils/CliDownloader.cs +++ b/src/Aspire.Cli/Utils/CliDownloader.cs @@ -31,7 +31,7 @@ internal class CliDownloader( public async Task DownloadLatestCliAsync(string channelName, CancellationToken cancellationToken) { // Get the channel information from PackagingService - var channels = await packagingService.GetChannelsAsync(cancellationToken); + var channels = await packagingService.GetChannelsAsync(cancellationToken, channelName); var channel = channels.FirstOrDefault(c => c.Name.Equals(channelName, StringComparison.OrdinalIgnoreCase)); if (channel is null) diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index a9918521d42..77d50cd9ec2 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -406,6 +406,154 @@ public async Task IntegrationSearchCommandFormatJsonWithAppHostUsesConfiguredCha Assert.Equal("2.0.0", integration.Version); } + [Fact] + public async Task IntegrationSearchCommandFormatJsonWithAppHostUsesConfiguredStagingChannelUnderStableCli() + { + var rawJson = string.Empty; + var testInteractionService = new TestInteractionService + { + DisplayRawTextCallback = text => rawJson = text + }; + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts")); + File.WriteAllText(appHostFile.FullName, string.Empty); + File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName), """ + { + "channel": "staging" + } + """); + + var implicitCache = new FakeNuGetPackageCache + { + GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult>([CreatePackage("Aspire.Hosting.Redis", "1.0.0")]) + }; + var stagingCache = new FakeNuGetPackageCache + { + GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult>([CreatePackage("Aspire.Hosting.Redis", "2.0.0")]) + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliExecutionContextFactory = _ => CreateExecutionContext(workspace, PackageChannelNames.Stable); + options.InteractionServiceFactory = _ => testInteractionService; + options.PackagingServiceFactory = _ => new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>([ + PackageChannel.CreateImplicitChannel(implicitCache), + PackageChannel.CreateExplicitChannel(PackageChannelNames.Staging, PackageChannelQuality.Both, [new PackageMapping("Aspire*", "staging")], stagingCache) + ]) + }; + }); + services.AddSingleton(new TestTypeScriptStarterProjectFactory((_, _) => Task.FromResult(true))); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"integration search redis --apphost \"{appHostFile.FullName}\" --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + + var integration = Assert.Single(ReadIntegrationResults(rawJson)); + Assert.Equal("Aspire.Hosting.Redis", integration.Package); + Assert.Equal("2.0.0", integration.Version); + } + + [Fact] + public async Task IntegrationSearchCommandFormatJsonWithAppHostOutsideLaunchDirectoryUsesConfiguredStagingChannelWithRealPackagingService() + { + var rawJson = string.Empty; + var testInteractionService = new TestInteractionService + { + DisplayRawTextCallback = text => rawJson = text + }; + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var projectDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "elsewhere")); + var appHostFile = new FileInfo(Path.Combine(projectDirectory.FullName, "apphost.ts")); + File.WriteAllText(appHostFile.FullName, string.Empty); + File.WriteAllText(Path.Combine(projectDirectory.FullName, AspireConfigFile.FileName), """ + { + "channel": "staging" + } + """); + + var cache = new FakeNuGetPackageCache + { + GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult>([CreatePackage("Aspire.Hosting.Redis", "2.0.0")]) + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliExecutionContextFactory = _ => CreateExecutionContext(workspace, PackageChannelNames.Stable); + options.InteractionServiceFactory = _ => testInteractionService; + options.NuGetPackageCacheFactory = _ => cache; + }); + services.AddSingleton(new TestTypeScriptStarterProjectFactory((_, _) => Task.FromResult(true))); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"integration search redis --apphost \"{appHostFile.FullName}\" --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + + var integration = Assert.Single(ReadIntegrationResults(rawJson)); + Assert.Equal("Aspire.Hosting.Redis", integration.Package); + Assert.Equal("2.0.0", integration.Version); + } + + [Fact] + public async Task IntegrationSearchCommandFormatJsonWithUnpinnedAppHostUsesImplicitChannelUnderStagingCli() + { + var rawJson = string.Empty; + var testInteractionService = new TestInteractionService + { + DisplayRawTextCallback = text => rawJson = text + }; + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts")); + File.WriteAllText(appHostFile.FullName, string.Empty); + + var implicitCache = new FakeNuGetPackageCache + { + GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult>([CreatePackage("Aspire.Hosting.Redis", "1.0.0")]) + }; + var stagingCache = new FakeNuGetPackageCache + { + GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult>([CreatePackage("Aspire.Hosting.Redis", "2.0.0")]) + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliExecutionContextFactory = _ => CreateExecutionContext(workspace, PackageChannelNames.Staging); + options.InteractionServiceFactory = _ => testInteractionService; + options.PackagingServiceFactory = _ => new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>([ + PackageChannel.CreateImplicitChannel(implicitCache), + PackageChannel.CreateExplicitChannel(PackageChannelNames.Staging, PackageChannelQuality.Both, [new PackageMapping("Aspire*", "staging")], stagingCache) + ]) + }; + }); + services.AddSingleton(new TestTypeScriptStarterProjectFactory((_, _) => Task.FromResult(true))); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"integration search redis --apphost \"{appHostFile.FullName}\" --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + + var integration = Assert.Single(ReadIntegrationResults(rawJson)); + Assert.Equal("Aspire.Hosting.Redis", integration.Package); + Assert.Equal("1.0.0", integration.Version); + } + [Fact] public async Task IntegrationListCommandFormatJsonPrefersImplicitChannelWhenMultipleChannelsContainSameIntegration() { @@ -2069,6 +2217,24 @@ private static (string? Name, string? Package, string? Version)[] ReadIntegratio Version: element.GetProperty("version").GetString())) .ToArray(); } + + private static CliExecutionContext CreateExecutionContext(TemporaryWorkspace workspace, string identityChannel) + { + var aspireDirectory = workspace.CreateDirectory(".aspire"); + var hivesDirectory = new DirectoryInfo(Path.Combine(aspireDirectory.FullName, "hives")); + var cacheDirectory = new DirectoryInfo(Path.Combine(aspireDirectory.FullName, "cache")); + var sdksDirectory = new DirectoryInfo(Path.Combine(aspireDirectory.FullName, "sdks")); + var logsDirectory = new DirectoryInfo(Path.Combine(aspireDirectory.FullName, "logs")); + + return new CliExecutionContext( + workspace.WorkspaceRoot, + hivesDirectory, + cacheDirectory, + sdksDirectory, + logsDirectory, + Path.Combine(logsDirectory.FullName, "test.log"), + identityChannel: identityChannel); + } } internal sealed class TestAddCommandPrompter(IInteractionService interactionService) : AddCommandPrompter(interactionService) diff --git a/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs index 51648429df1..607cb60cbdb 100644 --- a/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs @@ -4,6 +4,7 @@ using Aspire.Cli.Configuration; using Aspire.Cli.Documentation.ApiDocs; using Aspire.Cli.Documentation.Docs; +using Aspire.Cli.Packaging; using Aspire.Cli.Resources; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.DependencyInjection; @@ -847,6 +848,38 @@ public async Task ConfigSetCommand_GlobalAppHostPath_ReturnsInvalidCommand(strin } } + [Fact] + public async Task ConfigSetCommand_GlobalOverrideStagingFeed_IsReadByPackagingService() + { + const string stagingFeed = "https://example.com/staging/v3/index.json"; + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using (var provider = services.BuildServiceProvider()) + { + var command = provider.GetRequiredService(); + var result = command.Parse($"config set -g overrideStagingFeed {stagingFeed}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + + var settingsPath = provider.GetRequiredService().GetSettingsFilePath(isGlobal: true); + var json = await File.ReadAllTextAsync(settingsPath); + Assert.Contains(stagingFeed, json); + } + + var reloadedServices = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var reloadedProvider = reloadedServices.BuildServiceProvider(); + var packagingService = reloadedProvider.GetRequiredService(); + + var channels = await packagingService.GetChannelsAsync(requestedChannelName: PackageChannelNames.Staging).DefaultTimeout(); + + var stagingChannel = Assert.Single(channels, c => c.Name == PackageChannelNames.Staging); + Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality); + Assert.Contains(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*" && m.Source == stagingFeed); + } + private static string GetErrorString(string resourceName) { return resourceName switch diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs index c3f7a636184..7e50f3bed43 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs @@ -124,7 +124,6 @@ public async Task NewCommand_DoesNotConsultGlobalConfigurationServiceForChannelK /// [Theory] [InlineData(PackageChannelNames.Daily, "13.4.0-preview.1.99999.1")] - [InlineData("staging", "13.4.0-rc.1.99999.1")] [InlineData(PackageChannelNames.Stable, "13.5.0")] public async Task NewCommand_NoChannelArg_ResolvesTemplateFromIdentityChannel(string identityChannel, string identityChannelVersion) { @@ -178,24 +177,21 @@ public async Task NewCommand_NoChannelArg_IdentityChannelNotRegistered_FallsBack } /// - /// Production staging-channel registration gap: a staging-identity CLI does NOT - /// auto-register a staging channel in PackagingService.GetChannelsAsync — that - /// gate is controlled by KnownFeatures.IsStagingChannelEnabled (feature flag or - /// configuration["channel"] == "staging"). So a default staging CLI sees only - /// { Implicit, Stable, Daily } and must fall back to Implicit just like an - /// unregistered identity. This pins that contract so a future change to either - /// or PackagingService doesn't silently flip it. + /// Issue #17121 regression guard: a staging-identity CLI should have a registered + /// staging channel from PackagingService.GetChannelsAsync, so aspire new + /// resolves templates from staging instead of falling back to the Implicit NuGet.org + /// channel. /// [Fact] - public async Task NewCommand_NoChannelArg_StagingIdentityWithoutStagingChannelRegistered_FallsBackToImplicit() + public async Task NewCommand_NoChannelArg_StagingIdentityWithStagingChannelRegistered_ResolvesTemplateFromStaging() { var captured = await CaptureTemplateInputsAsync( - identityChannel: "staging", + identityChannel: PackageChannelNames.Staging, channelOptionArg: null, - identityChannelVersion: null); + identityChannelVersion: "13.4.0-rc.1.99999.1"); - Assert.Equal("13.3.0", captured.Version); // value from Implicit channel - Assert.Null(captured.Channel); // Implicit channels never persist a channel pin + Assert.Equal("13.4.0-rc.1.99999.1", captured.Version); + Assert.Equal(PackageChannelNames.Staging, captured.Channel); } /// @@ -391,4 +387,3 @@ public Task> GetInitTemplatesAsync(CancellationToken canc Task.FromResult>([template]); } } - diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTemplateConfigPersistenceTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTemplateConfigPersistenceTests.cs index 4ec4700fe88..c154267bdfe 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTemplateConfigPersistenceTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTemplateConfigPersistenceTests.cs @@ -142,25 +142,23 @@ public async Task ChannelPinningTemplate_ExplicitChannelArg_OverridesIdentityAnd } /// - /// Bug-class peer of the daily / unregistered-identity case. PackagingService - /// only registers the staging channel when KnownFeatures.IsStagingChannelEnabled - /// is true — so a default staging-identity CLI sees only { Implicit, Stable, Daily }, - /// the identity doesn't match any registered channel, and the resolution must fall back - /// to Implicit (no pin). Tracked separately in #17121; pinned here so the staging- - /// without-flag path can't silently regress for the channel-pinning templates. + /// Issue #17121 regression guard: PackagingService now registers the + /// staging channel for a staging-identity CLI. Once + /// resolves that registered identity channel, channel-pinning templates must persist + /// channel: staging so later add/restore operations keep using staging. /// [Theory] [InlineData(KnownTemplateId.TypeScriptEmptyAppHost)] [InlineData(KnownTemplateId.TypeScriptStarter)] - public async Task ChannelPinningTemplate_StagingIdentityWithoutStagingRegistered_DoesNotPinChannel(string templateId) + public async Task ChannelPinningTemplate_StagingIdentityWithRegisteredChannel_PinsStagingChannel(string templateId) { var persisted = await ScaffoldAndReadPersistedChannelAsync( templateId: templateId, identityChannel: PackageChannelNames.Staging, - registerIdentityChannel: false, + registerIdentityChannel: true, explicitChannelArg: null); - Assert.Null(persisted); + Assert.Equal(PackageChannelNames.Staging, persisted); } /// diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 069f90316d3..7ae92a31842 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -79,6 +79,81 @@ public void NewCommandWithPolyglotDisabled_ExposesTemplateSubcommands() Assert.DoesNotContain(command.Options, option => option.Aliases.Contains("--language", StringComparer.OrdinalIgnoreCase)); } + [Fact] + public void NewCommand_WhenIdentityChannelIsStaging_DescribesStagingChannelOption() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CreateServiceCollection(workspace, options => + { + options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: PackageChannelNames.Staging); + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + + var channelOption = command.Options.Single(option => option.Name == "--channel"); + Assert.Equal(NewCommandStrings.ChannelOptionDescriptionWithStaging, channelOption.Description); + } + + [Fact] + public async Task NewCommand_CSharpEmptyTemplateUnderStagingIdentity_WritesStagingNuGetConfig() + { + const string stagingFeed = "https://example.com/staging/v3/index.json"; + + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var configServices = CreateServiceCollection(workspace); + using (var configProvider = configServices.BuildServiceProvider()) + { + var configCommand = configProvider.GetRequiredService(); + var configResult = configCommand.Parse($"config set -g overrideStagingFeed {stagingFeed}"); + + var configExitCode = await configResult.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, configExitCode); + } + + var cache = new FakeNuGetPackageCache + { + GetTemplatePackagesAsyncCallback = (_, _, _, _) => + Task.FromResult>( + [ + new NuGetPackage + { + Id = TemplateNuGetConfigService.TemplatesPackageName, + Source = stagingFeed, + Version = "13.4.0-preview.1.12345" + } + ]) + }; + + var services = CreateServiceCollection(workspace, options => + { + options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: PackageChannelNames.Staging); + options.NuGetPackageCacheFactory = _ => cache; + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"new {KnownTemplateId.CSharpEmptyAppHost} --language csharp --name TemplateOut --output ./TemplateOut --localhost-tld false"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + + var outputDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "TemplateOut"); + var nugetConfigPath = Path.Combine(outputDirectory, "nuget.config"); + Assert.True(File.Exists(nugetConfigPath)); + + var nugetConfig = await File.ReadAllTextAsync(nugetConfigPath); + Assert.Contains(stagingFeed, nugetConfig); + Assert.Contains("Aspire*", nugetConfig); + + var config = AspireConfigFile.Load(outputDirectory); + Assert.NotNull(config); + Assert.Null(config.Channel); + } + [Fact] public async Task NewCommandInteractiveFlowSmokeTest() { diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index 6f14f312ab5..457bcd480ca 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -36,6 +36,22 @@ public async Task UpdateCommandWithHelpArgumentReturnsZero() Assert.Equal(CliExitCodes.Success, exitCode); } + [Fact] + public void UpdateCommand_WhenIdentityChannelIsStaging_DescribesStagingChannelOption() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: PackageChannelNames.Staging); + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + + var channelOption = command.Options.Single(option => option.Name == "--channel"); + Assert.Equal(UpdateCommandStrings.ChannelOptionDescriptionWithStaging, channelOption.Description); + } + [Theory] [InlineData("update --non-interactive")] [InlineData("--non-interactive update")] @@ -1629,6 +1645,113 @@ public async Task UpdateCommand_WhenAppHostSdkVersionUnresolvable_UsesSettingsLo // logic; explicit `--channel` and per-project config still override // identity. // ------------------------------------------------------------------ + [Fact] + public async Task UpdateCommand_WhenStagingIdentityRegistersChannel_UsesStagingForUnpinnedProject() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var promptForSelectionInvoked = false; + var updatedWithChannel = string.Empty; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: PackageChannelNames.Staging); + + options.ProjectLocatorFactory = _ => new TestProjectLocator() + { + UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) => + { + return Task.FromResult(new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"))); + } + }; + + options.InteractionServiceFactory = _ => new TestInteractionService() + { + PromptForSelectionCallback = (prompt, choices, formatter, ct) => + { + promptForSelectionInvoked = true; + return choices.Cast().First(); + } + }; + + options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner(); + options.NuGetPackageCacheFactory = _ => new FakeNuGetPackageCache(); + + options.ProjectUpdaterFactory = _ => new TestProjectUpdater() + { + UpdateProjectAsyncCallback = (context, cancellationToken) => + { + updatedWithChannel = context.Channel.Name; + return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = false }); + } + }; + }); + + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("update"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.False(promptForSelectionInvoked, "Staging identity should resolve through the registered staging channel without prompting."); + Assert.Equal(PackageChannelNames.Staging, updatedWithChannel); + } + + [Fact] + public async Task UpdateCommand_WhenAppHostOutsideLaunchDirectoryConfiguresStaging_UsesStagingFromRealPackagingService() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var projectDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "elsewhere")); + var appHostFile = new FileInfo(Path.Combine(projectDirectory.FullName, "AppHost.csproj")); + File.WriteAllText(appHostFile.FullName, string.Empty); + File.WriteAllText( + Path.Combine(projectDirectory.FullName, AspireConfigFile.FileName), + """{ "channel": "staging" }"""); + + var promptForSelectionInvoked = false; + var updatedWithChannel = string.Empty; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: PackageChannelNames.Stable); + options.NuGetPackageCacheFactory = _ => new FakeNuGetPackageCache(); + options.ProjectLocatorFactory = _ => new TestProjectLocator() + { + UseOrFindAppHostProjectFileAsyncCallback = (_, _, _) => Task.FromResult(appHostFile) + }; + options.InteractionServiceFactory = _ => new TestInteractionService() + { + PromptForSelectionCallback = (prompt, choices, formatter, ct) => + { + promptForSelectionInvoked = true; + return choices.Cast().First(); + } + }; + options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner(); + options.ProjectUpdaterFactory = _ => new TestProjectUpdater() + { + UpdateProjectAsyncCallback = (context, cancellationToken) => + { + updatedWithChannel = context.Channel.Name; + return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = false }); + } + }; + }); + + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"update --apphost \"{appHostFile.FullName}\""); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.False(promptForSelectionInvoked, "Project-local staging config should resolve without falling through to channel selection."); + Assert.Equal(PackageChannelNames.Staging, updatedWithChannel); + } + [Theory] [InlineData("pr-12345", "pr-12345")] [InlineData("daily", "daily")] @@ -1637,10 +1760,9 @@ public async Task UpdateCommand_WhenIdentityChannelMatchesRegisteredChannel_Uses { using var workspace = TemporaryWorkspace.Create(outputHelper); - // Create a hive so the would-prompt branch is exercised — that is - // the path the identity-channel fallback covers. Without a hive, - // the implicit fallback fires first and the identity-channel - // logic never runs. + // Create a hive so pr-* identities have a registered channel to match and so + // the identity fallback proves it bypasses the prompt when a prompt would + // otherwise be available. var hivesDir = workspace.CreateDirectory(".aspire").CreateSubdirectory("hives"); hivesDir.CreateSubdirectory("pr-12345"); @@ -1977,6 +2099,51 @@ public async Task UpdateCommand_SelfUpdate_WhenStagingFeatureFlagEnabled_ShowsSt Assert.Contains(PackageChannelNames.Daily, channelList); } + [Fact] + public async Task UpdateCommand_SelfUpdate_WhenIdentityChannelIsStaging_ShowsStagingChannel() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + IEnumerable? capturedChoices = null; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: PackageChannelNames.Staging); + + options.InteractionServiceFactory = _ => new TestInteractionService() + { + PromptForSelectionCallback = (prompt, choices, formatter, ct) => + { + capturedChoices = choices; + return PackageChannelNames.Stable; + } + }; + + options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot) + { + DownloadLatestCliAsyncCallback = (channel, ct) => + { + var archivePath = Path.Combine(workspace.WorkspaceRoot.FullName, "test-cli.tar.gz"); + File.WriteAllText(archivePath, "fake archive"); + return Task.FromResult(archivePath); + } + }; + }); + + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("update --self"); + + await result.InvokeAsync().DefaultTimeout(); + + Assert.NotNull(capturedChoices); + var channelList = capturedChoices.Cast().ToList(); + Assert.Contains(PackageChannelNames.Staging, channelList); + Assert.Contains(PackageChannelNames.Stable, channelList); + Assert.Contains(PackageChannelNames.Daily, channelList); + } + [Fact] public async Task UpdateCommand_SelfOption_IsAvailableAndParseable() { diff --git a/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerTests.cs b/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerTests.cs index 9f2584fd639..d65fe5cd67c 100644 --- a/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerTests.cs @@ -163,6 +163,50 @@ await WriteConfigAsync(root, Assert.Equal("Lib.*", (string?)psm.Element("packageSource")!.Element("package")!.Attribute("pattern")); } + [Fact] + public async Task CreateOrUpdateAsync_RemapsAspirePackagesFromStagingToStableSource() + { + using var workspace = TemporaryWorkspace.Create(_outputHelper); + var root = workspace.WorkspaceRoot; + const string stagingSource = "https://pkgs.dev.azure.com/dnceng/public/_packaging/aspire-staging/nuget/v3/index.json"; + const string stableSource = "https://api.nuget.org/v3/index.json"; + + await WriteConfigAsync(root, + $$""" + + + + + + + + + + + + """); + + var mappings = new[] + { + new PackageMapping("Aspire.*", stableSource) + }; + + var channel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Stable, PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache()); + await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); + + var xml = XDocument.Load(Path.Combine(root.FullName, "nuget.config")); + var packageSources = xml.Root!.Element("packageSources")!; + Assert.DoesNotContain(packageSources.Elements("add"), e => (string?)e.Attribute("value") == stagingSource); + Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("value") == stableSource); + + var packageSourceMapping = xml.Root!.Element("packageSourceMapping")!; + Assert.DoesNotContain(packageSourceMapping.Elements("packageSource"), e => (string?)e.Attribute("key") == "aspire-staging"); + + var stableMapping = Assert.Single(packageSourceMapping.Elements("packageSource")); + Assert.Equal(stableSource, (string?)stableMapping.Attribute("key")); + Assert.Equal("Aspire.*", (string?)stableMapping.Element("package")!.Attribute("pattern")); + } + [Fact] public async Task CreateOrUpdateAsync_CreatesPackageSourceMapping_WhenAbsent() { diff --git a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs index dbec49678bc..3fdb1876498 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs @@ -50,6 +50,82 @@ public async Task GetChannelsAsync_WhenStagingChannelDisabled_DoesNotIncludeStag Assert.False(dailyChannel.ConfigureGlobalPackagesFolder); } + [Fact] + public async Task GetChannelsAsync_WhenIdentityChannelIsStaging_IncludesStagingChannel() + { + 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", identityChannel: PackageChannelNames.Staging); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingFeed"] = "https://example.com/nuget/v3/index.json" + }) + .Build(); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger.Instance); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + var channelNames = channels.Select(c => c.Name).ToList(); + Assert.Contains(PackageChannelNames.Staging, channelNames); + Assert.Equal( + channelNames.IndexOf(PackageChannelNames.Stable), + channelNames.IndexOf(PackageChannelNames.Staging) - 1); + Assert.Equal( + channelNames.IndexOf(PackageChannelNames.Daily), + channelNames.IndexOf(PackageChannelNames.Staging) + 1); + + var stagingChannel = channels.First(c => c.Name == PackageChannelNames.Staging); + Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality); + } + + [Fact] + public async Task GetChannelsAsync_WhenRequestedChannelIsStaging_IncludesStagingChannel() + { + 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", identityChannel: PackageChannelNames.Stable); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), new ConfigurationBuilder().Build(), NullLogger.Instance); + + var channels = await packagingService.GetChannelsAsync(requestedChannelName: PackageChannelNames.Staging).DefaultTimeout(); + + var stagingChannel = Assert.Single(channels, c => c.Name == PackageChannelNames.Staging); + Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality); + } + + [Fact] + public async Task GetChannelsAsync_WhenConfigurationChannelIsStagingWithoutQualityOverride_DefaultsToBothAndSharedFeed() + { + 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 configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["channel"] = PackageChannelNames.Staging + }) + .Build(); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger.Instance); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + var stagingChannel = channels.First(c => c.Name == PackageChannelNames.Staging); + Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality); + Assert.False(stagingChannel.ConfigureGlobalPackagesFolder); + Assert.Contains(stagingChannel.Mappings!, m => + m.PackageFilter == "Aspire*" && + m.Source == "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json"); + } + /// /// Locks in the structural invariant that aspire init and aspire new depend /// on: the stable channel is always with a diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index 9a49edb107a..e8b3f73c711 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs @@ -616,6 +616,165 @@ await Assert.ThrowsAnyAsync( Assert.Null(reloaded.Channel); } + [Fact] + public async Task UpdatePackagesAsync_ExplicitStableChannel_PersistsStableChannel() + { + var configPath = Path.Combine(_workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(configPath, """ + { + "sdk": { "version": "1.0.0" }, + "channel": "staging", + "packages": { "Aspire.Hosting": "1.0.0" } + } + """); + + var appHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "apphost.ts"); + await File.WriteAllTextAsync(appHostPath, "// test apphost"); + + var stableCache = new FakeNuGetPackageCache + { + GetPackagesAsyncCallback = (_, packageId, _, _, _, _, _) => + Task.FromResult>( + [ + new Aspire.Shared.NuGetPackageCli { Id = packageId, Version = "2.0.0", Source = "stable" } + ]) + }; + + var stableChannel = PackageChannel.CreateExplicitChannel( + PackageChannelNames.Stable, + PackageChannelQuality.Both, + [new PackageMapping("Aspire.*", "stable")], + stableCache); + + var interactionService = new TestInteractionService + { + ConfirmCallback = (_, _) => true + }; + + var project = CreateGuestAppHostProject(interactionService: interactionService); + + var context = new UpdatePackagesContext + { + AppHostFile = new FileInfo(appHostPath), + Channel = stableChannel, + ConfirmBinding = PromptBinding.CreateDefault(false), + NuGetConfigDirBinding = PromptBinding.CreateDefault(null), + }; + + await Assert.ThrowsAnyAsync( + () => project.UpdatePackagesAsync(context, CancellationToken.None)); + + var reloaded = AspireConfigFile.Load(_workspace.WorkspaceRoot.FullName); + Assert.NotNull(reloaded); + Assert.Equal(PackageChannelNames.Stable, reloaded.Channel); + Assert.Equal("2.0.0", reloaded.SdkVersion); + Assert.Equal("2.0.0", reloaded.Packages?["Aspire.Hosting"]); + } + + [Fact] + public async Task UpdatePackagesAsync_ExplicitStagingChannel_PersistsStagingChannelWhenProjectIsUnpinned() + { + var configPath = Path.Combine(_workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(configPath, """ + { + "sdk": { "version": "1.0.0" }, + "packages": { "Aspire.Hosting": "1.0.0" } + } + """); + + var appHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "apphost.ts"); + await File.WriteAllTextAsync(appHostPath, "// test apphost"); + + var stagingCache = new FakeNuGetPackageCache + { + GetPackagesAsyncCallback = (_, packageId, _, _, _, _, _) => + Task.FromResult>( + [ + new Aspire.Shared.NuGetPackageCli { Id = packageId, Version = "2.0.0", Source = "staging" } + ]) + }; + + var stagingChannel = PackageChannel.CreateExplicitChannel( + PackageChannelNames.Staging, + PackageChannelQuality.Both, + [new PackageMapping("Aspire*", "staging")], + stagingCache); + + var interactionService = new TestInteractionService + { + ConfirmCallback = (_, _) => true + }; + + var project = CreateGuestAppHostProject(interactionService: interactionService); + + var context = new UpdatePackagesContext + { + AppHostFile = new FileInfo(appHostPath), + Channel = stagingChannel, + ConfirmBinding = PromptBinding.CreateDefault(false), + NuGetConfigDirBinding = PromptBinding.CreateDefault(null), + }; + + await Assert.ThrowsAnyAsync( + () => project.UpdatePackagesAsync(context, CancellationToken.None)); + + var reloaded = AspireConfigFile.Load(_workspace.WorkspaceRoot.FullName); + Assert.NotNull(reloaded); + Assert.Equal(PackageChannelNames.Staging, reloaded.Channel); + Assert.Equal("2.0.0", reloaded.SdkVersion); + Assert.Equal("2.0.0", reloaded.Packages?["Aspire.Hosting"]); + } + + [Fact] + public async Task UpdatePackagesAsync_ExplicitStableChannel_PersistsStableChannelWhenProjectIsUpToDate() + { + var configPath = Path.Combine(_workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(configPath, """ + { + "sdk": { "version": "2.0.0" }, + "channel": "staging", + "packages": { "Aspire.Hosting": "2.0.0" } + } + """); + + var appHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "apphost.ts"); + await File.WriteAllTextAsync(appHostPath, "// test apphost"); + + var stableCache = new FakeNuGetPackageCache + { + GetPackagesAsyncCallback = (_, packageId, _, _, _, _, _) => + Task.FromResult>( + [ + new Aspire.Shared.NuGetPackageCli { Id = packageId, Version = "2.0.0", Source = "stable" } + ]) + }; + + var stableChannel = PackageChannel.CreateExplicitChannel( + PackageChannelNames.Stable, + PackageChannelQuality.Both, + [new PackageMapping("Aspire.*", "stable")], + stableCache); + + var project = CreateGuestAppHostProject(); + + var context = new UpdatePackagesContext + { + AppHostFile = new FileInfo(appHostPath), + Channel = stableChannel, + ConfirmBinding = PromptBinding.CreateDefault(false), + NuGetConfigDirBinding = PromptBinding.CreateDefault(null), + }; + + var result = await project.UpdatePackagesAsync(context, CancellationToken.None); + + Assert.True(result.UpdatesApplied); + var reloaded = AspireConfigFile.Load(_workspace.WorkspaceRoot.FullName); + Assert.NotNull(reloaded); + Assert.Equal(PackageChannelNames.Stable, reloaded.Channel); + Assert.Equal("2.0.0", reloaded.SdkVersion); + Assert.Equal("2.0.0", reloaded.Packages?["Aspire.Hosting"]); + } + /// /// Regression test for the v3 channel refactor: aspire run must be a pure read /// for aspire.config.json#channel. A no-op rewrite (same value) or a silent diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs index b9950dc9f97..406e708f0c6 100644 --- a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -590,6 +590,92 @@ public async Task PrepareAsync_WithPackageReferences_SetsOnlyPackageProbeManifes } } + [Fact] + public async Task PrepareAsync_WithStagingPinnedProjectOutsideLaunchDirectory_UsesStagingSourcesAndNuGetConfig() + { + const string stagingFeed = "https://example.com/staging/v3/index.json"; + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var projectDirectory = workspace.CreateDirectory("elsewhere"); + var config = AspireConfigFile.LoadOrCreate(projectDirectory.FullName); + config.Channel = PackageChannelNames.Staging; + config.Save(projectDirectory.FullName); + + var layout = CreateBundleLayout(workspace); + var executionContext = TestExecutionContextHelper.CreateExecutionContext( + workspace.WorkspaceRoot, + identityChannel: PackageChannelNames.Stable); + + string[]? restoreInvocation = null; + string? temporaryNuGetConfigContent = null; + var executionFactory = new TestProcessExecutionFactory + { + AssertionCallback = (args, _, _, _) => + { + if (args.Length > 1 && + args[0] == "nuget" && + args[1] == "restore") + { + restoreInvocation = args.ToArray(); + temporaryNuGetConfigContent = File.ReadAllText(GetArgumentValue(args, "--nuget-config")); + } + } + }; + + var nugetService = new BundleNuGetService( + new FixedLayoutDiscovery(layout), + new LayoutProcessRunner(executionFactory), + new TestFeatures(), + executionContext, + NullLogger.Instance); + + var stagingChannel = PackageChannel.CreateExplicitChannel( + PackageChannelNames.Staging, + PackageChannelQuality.Both, + [ + new PackageMapping("Aspire*", stagingFeed), + new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") + ], + new FakeNuGetPackageCache()); + var packagingService = new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>([stagingChannel]) + }; + + var server = new PrebuiltAppHostServer( + projectDirectory.FullName, + "test.sock", + layout, + nugetService, + new TestDotNetCliRunner(), + new TestDotNetSdkInstaller(), + packagingService, + executionContext, + NullLogger.Instance); + var workingDirectory = GetWorkingDirectory(server); + + try + { + var result = await server.PrepareAsync( + "13.2.0", + [IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.2.0")]); + + Assert.True(result.Success); + Assert.Equal(PackageChannelNames.Staging, result.ChannelName); + + Assert.NotNull(restoreInvocation); + Assert.Contains(stagingFeed, restoreInvocation!); + Assert.Contains(projectDirectory.FullName, restoreInvocation!); + Assert.NotNull(temporaryNuGetConfigContent); + Assert.Contains(stagingFeed, temporaryNuGetConfigContent!); + Assert.Contains("Aspire*", temporaryNuGetConfigContent!); + } + finally + { + DeleteWorkingDirectory(workingDirectory); + } + } + [Fact] public async Task PrepareAsync_WithOnlyProjectReferences_SetsOnlyProjectLayout() { @@ -1178,6 +1264,22 @@ private static string GetWorkingDirectory(PrebuiltAppHostServer server) .GetValue(server)); } + private static string GetArgumentValue(IReadOnlyList arguments, string optionName) + { + var optionIndex = -1; + for (var i = 0; i < arguments.Count; i++) + { + if (string.Equals(arguments[i], optionName, StringComparison.Ordinal)) + { + optionIndex = i; + break; + } + } + + Assert.True(optionIndex >= 0 && optionIndex < arguments.Count - 1, $"Option '{optionName}' was not found."); + return arguments[optionIndex + 1]; + } + [Fact] public void CreateStartInfo_SetsCliLogFilePathEnvironmentVariable() { diff --git a/tests/Aspire.Cli.Tests/TestServices/TestPackagingService.cs b/tests/Aspire.Cli.Tests/TestServices/TestPackagingService.cs index 3c58d7b4e45..c7d9ee625fc 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestPackagingService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestPackagingService.cs @@ -9,7 +9,7 @@ internal sealed class TestPackagingService : IPackagingService { public Func>>? GetChannelsAsyncCallback { get; set; } - public Task> GetChannelsAsync(CancellationToken cancellationToken = default) + public Task> GetChannelsAsync(CancellationToken cancellationToken = default, string? requestedChannelName = null) { if (GetChannelsAsyncCallback is not null) {