From 40d04b60777030c6b18ca3670ae7fce7c07d0879 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Wed, 29 Apr 2026 08:55:14 -0700 Subject: [PATCH 1/3] Normalize CLI yes/no prompts Use single-key confirmation prompts for CLI yes/no choices so y/n answers are accepted without arrow-key selections while preserving [Y/n] and [y/N] defaults. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Interaction/ConsoleInteractionService.cs | 16 ++-- src/Aspire.Cli/Interaction/PromptBinding.cs | 63 ++++++++++++--- .../Packaging/NuGetConfigPrompter.cs | 14 +--- .../CliTemplateFactory.EmptyTemplate.cs | 8 +- ...liTemplateFactory.PythonStarterTemplate.cs | 8 +- .../Templating/DotNetTemplateFactory.cs | 23 +++--- .../Helpers/KubernetesDeployTestHelpers.cs | 3 +- .../Commands/NewCommandTests.cs | 22 +++-- .../ConsoleInteractionServiceTests.cs | 40 ++++++++- tests/Shared/Hex1bAutomatorTestHelpers.cs | 81 +++++++++++++------ tests/Shared/Hex1bTestHelpers.cs | 9 ++- 11 files changed, 188 insertions(+), 99 deletions(-) diff --git a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs index 872158d28a9..4ddd2476f79 100644 --- a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs @@ -161,10 +161,10 @@ public async Task PromptForStringAsync(string promptText, Func PromptForSelectionAsync(string promptText, IEnumerable> PromptForSelectionsAsync(string promptTex { if (binding != null) { - if (defaultValue != null) + if (binding.NonInteractiveDefaultValue != null) { - return MatchChoicesOrThrow(defaultValue, binding, choicesList, choiceFormatter); + return MatchChoicesOrThrow(binding.NonInteractiveDefaultValue, binding, choicesList, choiceFormatter); } ThrowNonInteractiveError(binding.SymbolDisplayName); @@ -499,7 +499,7 @@ public async Task PromptConfirmAsync(string promptText, PromptBinding resolver, T? defaultValue, bool hasExplicitDefault) + : this(parseResult, symbolDisplayName, resolver, defaultValue, hasExplicitDefault, hasExplicitDefault ? defaultValue : default) + { + } + + internal PromptBinding( + ParseResult? parseResult, + string symbolDisplayName, + Func resolver, + T? defaultValue, + bool hasExplicitDefault, + T? nonInteractiveDefaultValue) { _parseResult = parseResult; SymbolDisplayName = symbolDisplayName; _resolver = resolver; DefaultValue = defaultValue; HasExplicitDefault = hasExplicitDefault; + NonInteractiveDefaultValue = nonInteractiveDefaultValue; } /// @@ -38,10 +49,15 @@ internal PromptBinding( public string SymbolDisplayName { get; } /// - /// Gets the default value to use when non-interactive and the symbol was not provided. + /// Gets the default value to use for interactive prompts when the symbol was not provided. /// public T? DefaultValue { get; } + /// + /// Gets the default value to use when non-interactive and the symbol was not provided. + /// + public T? NonInteractiveDefaultValue { get; } + /// /// Gets whether a default value was explicitly specified when this binding was created. /// When false, prompt methods should throw in non-interactive mode instead of @@ -67,7 +83,7 @@ internal PromptBinding( /// Creates a new with the same resolver but a different default value. /// public PromptBinding WithDefault(T? newDefault) => - new(_parseResult, SymbolDisplayName, _resolver, newDefault, hasExplicitDefault: true); + new(_parseResult, SymbolDisplayName, _resolver, newDefault, hasExplicitDefault: true, nonInteractiveDefaultValue: newDefault); } /// @@ -115,16 +131,39 @@ public static PromptBinding CreateInvertedBoolConfirm(ParseResult parseRes new(parseResult, FormatOptionName(option), BuildResolver(option, value => value != true), defaultValue, hasExplicitDefault: true); /// - /// Creates a for a bool? option that maps to Yes/No selection choices. - /// When the option is explicitly provided, resolves to or . - /// When not provided in non-interactive mode, defaults to . + /// Creates a for a bool? option that maps to a confirmation prompt. + /// When the option is explicitly provided, the binding resolves to true only when the option value is true. + /// When the option is not explicitly provided, is used as the confirmation default, + /// including as the interactive prompt default when the user accepts the prompt by pressing Enter. /// - public static PromptBinding CreateBoolAsSelection(ParseResult parseResult, Option option, string? trueValue = null, string? falseValue = null) - { - trueValue ??= TemplatingStrings.Yes; - falseValue ??= TemplatingStrings.No; - return new(parseResult, FormatOptionName(option), BuildResolver(option, value => value == true ? trueValue : falseValue), falseValue, hasExplicitDefault: true); - } + /// The parse result used to determine whether was explicitly provided. + /// The nullable Boolean option to bind to the confirmation prompt. + /// The default confirmation value to use when was not explicitly provided. + /// + /// A that resolves the explicitly provided bool? option to a , + /// where true maps to true and any other value maps to false, and otherwise exposes + /// as the prompt default. + /// + public static PromptBinding CreateBoolConfirm(ParseResult parseResult, Option option, bool defaultValue) => + CreateBoolConfirm(parseResult, option, interactiveDefault: defaultValue, nonInteractiveDefault: defaultValue); + + /// + /// Creates a for a bool? option that maps to a confirmation prompt. + /// When the option is explicitly provided, the binding resolves to true only when the option value is true. + /// When the option is not explicitly provided, is used as the confirmation prompt default, + /// and is used when interactive input is not available. + /// + /// The parse result used to determine whether was explicitly provided. + /// The nullable Boolean option to bind to the confirmation prompt. + /// The default confirmation value to use for the interactive prompt. + /// The default confirmation value to use when interactive input is not available. + /// + /// A that resolves the explicitly provided bool? option to a , + /// where true maps to true and any other value maps to false, and otherwise exposes + /// as the prompt default. + /// + public static PromptBinding CreateBoolConfirm(ParseResult parseResult, Option option, bool interactiveDefault, bool nonInteractiveDefault) => + new(parseResult, FormatOptionName(option), BuildResolver(option, value => value == true), interactiveDefault, hasExplicitDefault: true, nonInteractiveDefaultValue: nonInteractiveDefault); private static string FormatOptionName(Option option) => $"'{option.Name}'"; diff --git a/src/Aspire.Cli/Packaging/NuGetConfigPrompter.cs b/src/Aspire.Cli/Packaging/NuGetConfigPrompter.cs index 3a1d0569d32..f03a63de05d 100644 --- a/src/Aspire.Cli/Packaging/NuGetConfigPrompter.cs +++ b/src/Aspire.Cli/Packaging/NuGetConfigPrompter.cs @@ -47,13 +47,10 @@ public async Task PromptToCreateOrUpdateAsync(DirectoryInfo targetDirectory, Pac if (!hasConfigInTargetDir) { - var choice = await _interactionService.PromptForSelectionAsync( + var shouldCreate = await _interactionService.PromptConfirmAsync( TemplatingStrings.CreateNugetConfigConfirmation, - [TemplatingStrings.Yes, TemplatingStrings.No], - c => c, - binding: PromptBinding.CreateDefault(TemplatingStrings.Yes), + binding: PromptBinding.CreateDefault(true), cancellationToken: cancellationToken); - var shouldCreate = string.Equals(choice, TemplatingStrings.Yes, StringComparisons.CliInputOrOutput); if (shouldCreate) { @@ -63,13 +60,10 @@ public async Task PromptToCreateOrUpdateAsync(DirectoryInfo targetDirectory, Pac } else if (hasMissingSources) { - var updateChoice = await _interactionService.PromptForSelectionAsync( + var shouldUpdate = await _interactionService.PromptConfirmAsync( TemplatingStrings.UpdateNuGetConfigConfirmation, - [TemplatingStrings.Yes, TemplatingStrings.No], - c => c, - binding: PromptBinding.CreateDefault(TemplatingStrings.Yes), + binding: PromptBinding.CreateDefault(true), cancellationToken: cancellationToken); - var shouldUpdate = string.Equals(updateChoice, TemplatingStrings.Yes, StringComparisons.CliInputOrOutput); if (shouldUpdate) { diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs index 020287e3058..9b929aa3430 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs @@ -108,17 +108,13 @@ private async Task ApplyEmptyAppHostTemplateAsync(CallbackTempla private async Task ResolveUseLocalhostTldAsync(System.CommandLine.ParseResult parseResult, CancellationToken cancellationToken) { - var binding = PromptBinding.CreateBoolAsSelection(parseResult, _localhostTldOption); + var binding = PromptBinding.CreateBoolConfirm(parseResult, _localhostTldOption, defaultValue: false); - var selected = await _interactionService.PromptForSelectionAsync( + var useLocalhostTld = await _interactionService.PromptConfirmAsync( TemplatingStrings.UseLocalhostTld_Prompt, - [TemplatingStrings.No, TemplatingStrings.Yes], - choice => choice, binding: binding, cancellationToken: cancellationToken); - var useLocalhostTld = string.Equals(selected, TemplatingStrings.Yes, StringComparisons.CliInputOrOutput); - if (useLocalhostTld) { _interactionService.DisplayMessage(KnownEmojis.CheckMarkButton, TemplatingStrings.UseLocalhostTld_UsingLocalhostTld); diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs index f877d320893..5a386d1734f 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs @@ -121,17 +121,13 @@ string ApplyAllTokens(string content) => ConditionalBlockProcessor.Process( private async Task ResolveUseRedisCacheAsync(System.CommandLine.ParseResult parseResult, CancellationToken cancellationToken) { - var binding = PromptBinding.CreateBoolAsSelection(parseResult, _useRedisCacheOption); + var binding = PromptBinding.CreateBoolConfirm(parseResult, _useRedisCacheOption, interactiveDefault: true, nonInteractiveDefault: false); - var selected = await _interactionService.PromptForSelectionAsync( + var useRedisCache = await _interactionService.PromptConfirmAsync( TemplatingStrings.UseRedisCache_Prompt, - [TemplatingStrings.Yes, TemplatingStrings.No], - choice => choice, binding: binding, cancellationToken: cancellationToken); - var useRedisCache = string.Equals(selected, TemplatingStrings.Yes, StringComparisons.CliInputOrOutput); - if (useRedisCache) { _interactionService.DisplayMessage(KnownEmojis.CheckMarkButton, TemplatingStrings.UseRedisCache_UsingRedisCache); diff --git a/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs b/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs index 5b8f2166ffd..9941bdcb5ac 100644 --- a/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs +++ b/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs @@ -290,16 +290,14 @@ private async Task PromptForExtraAspireXUnitOptionsAsync(ParseResult r private async Task PromptForDevLocalhostTldOptionAsync(ParseResult result, List extraArgs, CancellationToken cancellationToken) { - var binding = PromptBinding.CreateBoolAsSelection(result, _localhostTldOption); + var binding = PromptBinding.CreateBoolConfirm(result, _localhostTldOption, defaultValue: false); - var selected = await interactionService.PromptForSelectionAsync( + var useLocalhostTld = await interactionService.PromptConfirmAsync( TemplatingStrings.UseLocalhostTld_Prompt, - [TemplatingStrings.No, TemplatingStrings.Yes], - choice => choice, binding: binding, cancellationToken: cancellationToken); - if (string.Equals(selected, TemplatingStrings.Yes, StringComparisons.CliInputOrOutput)) + if (useLocalhostTld) { interactionService.DisplayMessage(KnownEmojis.CheckMarkButton, TemplatingStrings.UseLocalhostTld_UsingLocalhostTld); extraArgs.Add("--localhost-tld"); @@ -308,16 +306,14 @@ private async Task PromptForDevLocalhostTldOptionAsync(ParseResult result, List< private async Task PromptForRedisCacheOptionAsync(ParseResult result, List extraArgs, CancellationToken cancellationToken) { - var binding = PromptBinding.CreateBoolAsSelection(result, _useRedisCacheOption); + var binding = PromptBinding.CreateBoolConfirm(result, _useRedisCacheOption, interactiveDefault: true, nonInteractiveDefault: false); - var selected = await interactionService.PromptForSelectionAsync( + var useRedisCache = await interactionService.PromptConfirmAsync( TemplatingStrings.UseRedisCache_Prompt, - [TemplatingStrings.Yes, TemplatingStrings.No], - choice => choice, binding: binding, cancellationToken: cancellationToken); - if (string.Equals(selected, TemplatingStrings.Yes, StringComparisons.CliInputOrOutput)) + if (useRedisCache) { interactionService.DisplayMessage(KnownEmojis.CheckMarkButton, TemplatingStrings.UseRedisCache_UsingRedisCache); extraArgs.Add("--use-redis-cache"); @@ -336,13 +332,12 @@ private async Task PromptForTestFrameworkOptionsAsync(ParseResult result, List choice, + binding: PromptBinding.CreateDefault(false), cancellationToken: cancellationToken); - if (string.Equals(createTestProject, TemplatingStrings.No, StringComparisons.CliInputOrOutput)) + if (!createTestProject) { return; } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs index 43d1fc325c7..33773f0bb9f 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs @@ -187,8 +187,7 @@ await auto.WaitUntilAsync( s => new CellPatternSearcher().Find("Use Redis Cache").Search(s).Count > 0, timeout: TimeSpan.FromSeconds(10), description: "Redis cache prompt"); - await auto.DownAsync(); // Navigate to "No" - await auto.EnterAsync(); + await auto.TypeAsync("n"); await auto.WaitUntilAsync( s => new CellPatternSearcher().Find("Do you want to create a test project?").Search(s).Count > 0, diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 45cd3da2c46..c877d3af328 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -913,7 +913,7 @@ public async Task NewCommandWithExplicitCSharpEmptyTemplateCreatesCSharpAppHost( } [Fact] - public async Task NewCommandWithEmptyTemplateAndCSharpPromptsForLocalhostTldAndUsesSelection() + public async Task NewCommandWithEmptyTemplateAndCSharpPromptsForLocalhostTldAndUsesConfirmation() { using var workspace = TemporaryWorkspace.Create(outputHelper); var localhostPrompted = false; @@ -924,17 +924,16 @@ public async Task NewCommandWithEmptyTemplateAndCSharpPromptsForLocalhostTldAndU options.InteractionServiceFactory = _ => new TestInteractionService { - ConfirmCallback = (_, _) => false, - PromptForSelectionCallback = (promptText, choices, choiceFormatter, cancellationToken) => + ConfirmCallback = (promptText, defaultValue) => { if (string.Equals(promptText, TemplatingStrings.UseLocalhostTld_Prompt, StringComparison.Ordinal)) { localhostPrompted = true; - return choices.Cast().Single(choice => - string.Equals(choiceFormatter(choice), TemplatingStrings.Yes, StringComparisons.CliInputOrOutput)); + Assert.False(defaultValue); + return true; } - return choices.Cast().First(); + return false; } }; options.NewCommandPrompterFactory = (sp) => @@ -1037,7 +1036,7 @@ public async Task NewCommandWithEmptyTemplateNormalizesDefaultOutputPath() } [Fact] - public async Task NewCommandWithEmptyTemplateAndTypeScriptPromptsForLocalhostTldAndUsesSelection() + public async Task NewCommandWithEmptyTemplateAndTypeScriptPromptsForLocalhostTldAndUsesConfirmation() { using var workspace = TemporaryWorkspace.Create(outputHelper); var scaffoldingInvoked = false; @@ -1049,17 +1048,16 @@ public async Task NewCommandWithEmptyTemplateAndTypeScriptPromptsForLocalhostTld options.InteractionServiceFactory = _ => new TestInteractionService { - ConfirmCallback = (_, _) => false, - PromptForSelectionCallback = (promptText, choices, choiceFormatter, cancellationToken) => + ConfirmCallback = (promptText, defaultValue) => { if (string.Equals(promptText, TemplatingStrings.UseLocalhostTld_Prompt, StringComparison.Ordinal)) { localhostPrompted = true; - return choices.Cast().Single(choice => - string.Equals(choiceFormatter(choice), TemplatingStrings.Yes, StringComparisons.CliInputOrOutput)); + Assert.False(defaultValue); + return true; } - return choices.Cast().First(); + return false; } }; options.NewCommandPrompterFactory = (sp) => diff --git a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs index da988bee0b7..da1e3b6c7c3 100644 --- a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs @@ -1008,6 +1008,41 @@ public async Task ConfirmAsync_NonInteractive_WithExplicitDefault_ReturnsDefault Assert.True(result); } + [Fact] + public async Task ConfirmAsync_NonInteractive_WithSeparateNonInteractiveDefault_ReturnsNonInteractiveDefault() + { + var output = new StringBuilder(); + var console = CreateInteractiveConsoleWithInput(output, ""); + var interactionService = CreateInteractionService(console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment()); + + var option = new System.CommandLine.Option("--confirm"); + var command = new System.CommandLine.RootCommand { option }; + var parseResult = command.Parse(""); + var binding = PromptBinding.CreateBoolConfirm(parseResult, option, interactiveDefault: true, nonInteractiveDefault: false); + + var result = await interactionService.PromptConfirmAsync("Proceed?", binding: binding, cancellationToken: CancellationToken.None); + + Assert.False(result); + } + + [Fact] + public async Task ConfirmAsync_Interactive_WithSeparateNonInteractiveDefault_UsesInteractiveDefault() + { + var output = new StringBuilder(); + var console = CreateInteractiveConsoleWithInput(output, "\n"); + var interactionService = CreateInteractionService(console); + + var option = new System.CommandLine.Option("--confirm"); + var command = new System.CommandLine.RootCommand { option }; + var parseResult = command.Parse(""); + var binding = PromptBinding.CreateBoolConfirm(parseResult, option, interactiveDefault: true, nonInteractiveDefault: false); + + var result = await interactionService.PromptConfirmAsync("Proceed?", binding: binding, cancellationToken: CancellationToken.None); + + Assert.True(result); + Assert.Contains("[Y/n]", output.ToString()); + } + [Fact] public void MatchChoices_WithDuplicateValues_ReturnsDeduplicated() { @@ -1242,13 +1277,13 @@ public void PromptBinding_InvertedBoolConfirm_SymbolDisplayName_IsCorrect() } [Fact] - public void PromptBinding_BoolAsSelection_SymbolDisplayName_IsCorrect() + public void PromptBinding_BoolConfirm_SymbolDisplayName_IsCorrect() { var option = new System.CommandLine.Option("--include"); var command = new System.CommandLine.RootCommand { option }; var parseResult = command.Parse("--include"); - var binding = PromptBinding.CreateBoolAsSelection(parseResult, option, "Yes", "No"); + var binding = PromptBinding.CreateBoolConfirm(parseResult, option, defaultValue: false); Assert.Equal("'--include'", binding.SymbolDisplayName); } @@ -1260,6 +1295,7 @@ public void PromptBinding_WithDefault_ChangesDefault() var updated = binding.WithDefault("new-value"); Assert.Equal("new-value", updated.DefaultValue); + Assert.Equal("new-value", updated.NonInteractiveDefaultValue); Assert.True(updated.HasExplicitDefault); } diff --git a/tests/Shared/Hex1bAutomatorTestHelpers.cs b/tests/Shared/Hex1bAutomatorTestHelpers.cs index 0773662f788..ece616d1e01 100644 --- a/tests/Shared/Hex1bAutomatorTestHelpers.cs +++ b/tests/Shared/Hex1bAutomatorTestHelpers.cs @@ -619,10 +619,12 @@ await auto.WaitUntilAsync( if (!useRedisCache) { - await auto.DownAsync(); // Default is "Yes", navigate to "No" + await auto.TypeAsync("n"); + } + else + { + await auto.EnterAsync(); } - - await auto.EnterAsync(); } // Step 7: Test project prompt (only Starter) @@ -655,32 +657,65 @@ internal static async Task AspireInitAsync( var waitingForInitComplete = new CellPatternSearcher() .Find("Aspire initialization complete"); + var waitingForAgentInitPrompt = new CellPatternSearcher() + .Find("configure AI agent environments"); + await auto.TypeAsync("aspire init --language csharp"); await auto.EnterAsync(); - // NuGet.config prompt may or may not appear depending on environment. - // Wait for either the NuGet.config prompt or the URLs prompt. - await auto.WaitUntilAsync( - s => waitingForNuGetConfigPrompt.Search(s).Count > 0 - || waitingForUrlsPrompt.Search(s).Count > 0, - timeout: TimeSpan.FromMinutes(2), - description: "NuGet.config prompt or URLs prompt"); - await auto.EnterAsync(); // Dismiss NuGet.config prompt if present + var handledNuGetConfigPrompt = false; + var handledUrlsPrompt = false; - // Wait for the URLs prompt (if NuGet.config appeared first) or init completion. - await auto.WaitUntilAsync( - s => waitingForUrlsPrompt.Search(s).Count > 0 - || waitingForInitComplete.Search(s).Count > 0, - timeout: TimeSpan.FromMinutes(2), - description: "URLs prompt or init completion"); - await auto.EnterAsync(); // Dismiss URLs prompt (accept default "No") + while (true) + { + var initState = "unknown"; + await auto.WaitUntilAsync(s => + { + if (!handledNuGetConfigPrompt && waitingForNuGetConfigPrompt.Search(s).Count > 0) + { + initState = "nuget-config"; + return true; + } - await auto.WaitUntilAsync( - s => waitingForInitComplete.Search(s).Count > 0, - timeout: TimeSpan.FromMinutes(2), - description: "aspire initialization complete"); + if (!handledUrlsPrompt && waitingForUrlsPrompt.Search(s).Count > 0) + { + initState = "urls"; + return true; + } - await auto.DeclineAgentInitPromptAsync(counter); + if (waitingForAgentInitPrompt.Search(s).Count > 0) + { + initState = "agent-init"; + return true; + } + + if (waitingForInitComplete.Search(s).Count > 0) + { + initState = "init-complete"; + return true; + } + + return false; + }, timeout: TimeSpan.FromMinutes(2), description: "NuGet.config prompt, URLs prompt, agent init prompt, or init completion"); + + if (initState is "nuget-config" or "urls") + { + if (initState == "nuget-config") + { + handledNuGetConfigPrompt = true; + } + else + { + handledUrlsPrompt = true; + } + + await auto.EnterAsync(); + continue; + } + + await auto.DeclineAgentInitPromptAsync(counter); + return; + } } /// diff --git a/tests/Shared/Hex1bTestHelpers.cs b/tests/Shared/Hex1bTestHelpers.cs index 81967e6a272..c30a5e02a35 100644 --- a/tests/Shared/Hex1bTestHelpers.cs +++ b/tests/Shared/Hex1bTestHelpers.cs @@ -494,11 +494,12 @@ internal static Hex1bTerminalInputSequenceBuilder AspireNew( if (!useRedisCache) { - // Default is "Yes", navigate to "No" - builder.Key(Hex1bKey.DownArrow); + builder.Type("n"); + } + else + { + builder.Enter(); } - - builder.Enter(); } // Step 7: Test project prompt (only Starter) From 682178778ffedf19ddbffc863b88a714b2658f05 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Wed, 29 Apr 2026 14:07:13 -0700 Subject: [PATCH 2/3] Prefer local hive versions in aspire add Treat the configured local hive as a local build channel so aspire add keeps generated AppHosts on the same CLI/SDK version and writes the local NuGet source when needed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/AddCommand.cs | 6 +- src/Aspire.Cli/Utils/VersionHelper.cs | 6 +- .../Commands/AddCommandTests.cs | 82 +++++++++++++++++++ .../Utils/VersionHelperTests.cs | 40 +++++++++ 4 files changed, 128 insertions(+), 6 deletions(-) diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index 52dab249f5d..882f37e7259 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -372,14 +372,14 @@ 1 when filteredPackagesWithShortName.First().Package.Version == version throw new EmptyChoicesException(string.Format(CultureInfo.CurrentCulture, AddCommandStrings.SpecifiedVersionNotFoundForPackage, selectedPackage.Package.Id, preferredVersion)); } - // When PR hives are present, prefer the package that exactly matches the installed + // When local build hives are present, prefer the package that exactly matches the installed // CLI/SDK version so template- and add-generated projects stay on the same build. - var prChannelPackageVersions = packageVersions + var localBuildChannelPackageVersions = packageVersions .Where(p => VersionHelper.IsLocalBuildChannel(p.Channel.Name)) .ToArray(); if (VersionHelper.TryGetCurrentCliVersionMatch( - prChannelPackageVersions, + localBuildChannelPackageVersions, p => p.Package.Version, out var cliVersionPackage, channelName: null, diff --git a/src/Aspire.Cli/Utils/VersionHelper.cs b/src/Aspire.Cli/Utils/VersionHelper.cs index 1be7449066d..1c5cb7e37f0 100644 --- a/src/Aspire.Cli/Utils/VersionHelper.cs +++ b/src/Aspire.Cli/Utils/VersionHelper.cs @@ -11,12 +11,13 @@ internal static class VersionHelper { /// /// Returns when identifies a - /// locally-built channel — either a PR hive (pr-*) or a workflow-run hive (run-*). + /// locally-built channel — the local hive (local), a PR hive (pr-*), or a workflow-run hive (run-*). /// public static bool IsLocalBuildChannel(string? channelName) { return channelName is not null && - (channelName.StartsWith("pr-", StringComparison.OrdinalIgnoreCase) || + (string.Equals(channelName, "local", StringComparison.OrdinalIgnoreCase) || + channelName.StartsWith("pr-", StringComparison.OrdinalIgnoreCase) || channelName.StartsWith("run-", StringComparison.OrdinalIgnoreCase)); } @@ -76,4 +77,3 @@ public static string GetDefaultSdkVersion() return version; } } - diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index 8f14487a33f..a4da29d66d2 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -4,6 +4,7 @@ using Aspire.Cli.Commands; using Aspire.Cli.Interaction; using Aspire.Cli.Packaging; +using Aspire.Cli.Projects; using Aspire.Cli.Resources; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; @@ -1494,6 +1495,87 @@ public async Task AddCommand_WithPrHive_PrefersCurrentCliVersion() Assert.False(promptedForVersion); Assert.Equal(cliVersion, selectedPackageVersion); } + + [Fact] + public async Task AddCommand_WithLocalHive_PrefersCurrentCliVersion() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var hivesDir = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives")); + hivesDir.Create(); + hivesDir.CreateSubdirectory("local").CreateSubdirectory("packages"); + + var appHostProjectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); + var cliVersion = VersionHelper.GetDefaultSdkVersion(); + var selectedPackageVersion = string.Empty; + var promptedForVersion = false; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.AddCommandPrompterFactory = (sp) => + { + var interactionService = sp.GetRequiredService(); + var prompter = new TestAddCommandPrompter(interactionService); + prompter.PromptForIntegrationVersionCallback = (packages) => + { + promptedForVersion = true; + throw new InvalidOperationException("Should not prompt when the current CLI version is available in the local hive."); + }; + + return prompter; + }; + + options.ProjectLocatorFactory = _ => new TestProjectLocator + { + UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) => + Task.FromResult(new AppHostProjectSearchResult(appHostProjectFile, [appHostProjectFile])) + }; + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner(); + runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) => + { + var implicitPackage = new NuGetPackage + { + Id = "Aspire.Hosting.Redis", + Source = "implicit", + Version = "13.2.2" + }; + + var localHivePackage = new NuGetPackage + { + Id = "Aspire.Hosting.Redis", + Source = "local-hive", + Version = cliVersion + }; + + return nugetSource is null + ? (0, new[] { implicitPackage }) + : (0, new[] { localHivePackage }); + }; + + runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, invocationOptions, cancellationToken) => + { + selectedPackageVersion = packageVersion; + return 0; + }; + + return runner; + }; + }); + + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("add redis"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(0, exitCode); + Assert.False(promptedForVersion); + Assert.Equal(cliVersion, selectedPackageVersion); + Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"))); + } } internal sealed class TestAddCommandPrompter(IInteractionService interactionService) : AddCommandPrompter(interactionService) diff --git a/tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs b/tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs index 0adb81c53d4..8fa568d7125 100644 --- a/tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs @@ -7,6 +7,25 @@ namespace Aspire.Cli.Tests.Utils; public class VersionHelperTests { + [Theory] + [InlineData("local")] + [InlineData("pr-123")] + [InlineData("run-123")] + public void IsLocalBuildChannel_WithLocalBuildChannels_ReturnsTrue(string channelName) + { + Assert.True(VersionHelper.IsLocalBuildChannel(channelName)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("daily")] + [InlineData("stable")] + public void IsLocalBuildChannel_WithNonLocalBuildChannels_ReturnsFalse(string? channelName) + { + Assert.False(VersionHelper.IsLocalBuildChannel(channelName)); + } + [Fact] public void TryGetCurrentCliVersionMatch_WithPrHivesAndNoChannel_ReturnsCurrentCliVersion() { @@ -27,4 +46,25 @@ public void TryGetCurrentCliVersionMatch_WithPrHivesAndNoChannel_ReturnsCurrentC Assert.True(result); Assert.Equal(cliVersion, match); } + + [Fact] + public void TryGetCurrentCliVersionMatch_WithLocalChannelAndNoPrHives_ReturnsCurrentCliVersion() + { + var cliVersion = VersionHelper.GetDefaultSdkVersion(); + var candidates = new[] + { + "99.0.0", + cliVersion, + }; + + var result = VersionHelper.TryGetCurrentCliVersionMatch( + candidates, + version => version, + out var match, + channelName: "local", + hasPrHives: false); + + Assert.True(result); + Assert.Equal(cliVersion, match); + } } From bbde5519d43bff04a65f0c8ae231bdd560528711 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Wed, 29 Apr 2026 14:36:51 -0700 Subject: [PATCH 3/3] Revert "Prefer local hive versions in aspire add" This reverts commit 682178778ffedf19ddbffc863b88a714b2658f05. --- src/Aspire.Cli/Commands/AddCommand.cs | 6 +- src/Aspire.Cli/Utils/VersionHelper.cs | 6 +- .../Commands/AddCommandTests.cs | 82 ------------------- .../Utils/VersionHelperTests.cs | 40 --------- 4 files changed, 6 insertions(+), 128 deletions(-) diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index 882f37e7259..52dab249f5d 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -372,14 +372,14 @@ 1 when filteredPackagesWithShortName.First().Package.Version == version throw new EmptyChoicesException(string.Format(CultureInfo.CurrentCulture, AddCommandStrings.SpecifiedVersionNotFoundForPackage, selectedPackage.Package.Id, preferredVersion)); } - // When local build hives are present, prefer the package that exactly matches the installed + // When PR hives are present, prefer the package that exactly matches the installed // CLI/SDK version so template- and add-generated projects stay on the same build. - var localBuildChannelPackageVersions = packageVersions + var prChannelPackageVersions = packageVersions .Where(p => VersionHelper.IsLocalBuildChannel(p.Channel.Name)) .ToArray(); if (VersionHelper.TryGetCurrentCliVersionMatch( - localBuildChannelPackageVersions, + prChannelPackageVersions, p => p.Package.Version, out var cliVersionPackage, channelName: null, diff --git a/src/Aspire.Cli/Utils/VersionHelper.cs b/src/Aspire.Cli/Utils/VersionHelper.cs index 1c5cb7e37f0..1be7449066d 100644 --- a/src/Aspire.Cli/Utils/VersionHelper.cs +++ b/src/Aspire.Cli/Utils/VersionHelper.cs @@ -11,13 +11,12 @@ internal static class VersionHelper { /// /// Returns when identifies a - /// locally-built channel — the local hive (local), a PR hive (pr-*), or a workflow-run hive (run-*). + /// locally-built channel — either a PR hive (pr-*) or a workflow-run hive (run-*). /// public static bool IsLocalBuildChannel(string? channelName) { return channelName is not null && - (string.Equals(channelName, "local", StringComparison.OrdinalIgnoreCase) || - channelName.StartsWith("pr-", StringComparison.OrdinalIgnoreCase) || + (channelName.StartsWith("pr-", StringComparison.OrdinalIgnoreCase) || channelName.StartsWith("run-", StringComparison.OrdinalIgnoreCase)); } @@ -77,3 +76,4 @@ public static string GetDefaultSdkVersion() return version; } } + diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index a4da29d66d2..8f14487a33f 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -4,7 +4,6 @@ using Aspire.Cli.Commands; using Aspire.Cli.Interaction; using Aspire.Cli.Packaging; -using Aspire.Cli.Projects; using Aspire.Cli.Resources; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; @@ -1495,87 +1494,6 @@ public async Task AddCommand_WithPrHive_PrefersCurrentCliVersion() Assert.False(promptedForVersion); Assert.Equal(cliVersion, selectedPackageVersion); } - - [Fact] - public async Task AddCommand_WithLocalHive_PrefersCurrentCliVersion() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - var hivesDir = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives")); - hivesDir.Create(); - hivesDir.CreateSubdirectory("local").CreateSubdirectory("packages"); - - var appHostProjectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); - var cliVersion = VersionHelper.GetDefaultSdkVersion(); - var selectedPackageVersion = string.Empty; - var promptedForVersion = false; - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.AddCommandPrompterFactory = (sp) => - { - var interactionService = sp.GetRequiredService(); - var prompter = new TestAddCommandPrompter(interactionService); - prompter.PromptForIntegrationVersionCallback = (packages) => - { - promptedForVersion = true; - throw new InvalidOperationException("Should not prompt when the current CLI version is available in the local hive."); - }; - - return prompter; - }; - - options.ProjectLocatorFactory = _ => new TestProjectLocator - { - UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) => - Task.FromResult(new AppHostProjectSearchResult(appHostProjectFile, [appHostProjectFile])) - }; - - options.DotNetCliRunnerFactory = (sp) => - { - var runner = new TestDotNetCliRunner(); - runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) => - { - var implicitPackage = new NuGetPackage - { - Id = "Aspire.Hosting.Redis", - Source = "implicit", - Version = "13.2.2" - }; - - var localHivePackage = new NuGetPackage - { - Id = "Aspire.Hosting.Redis", - Source = "local-hive", - Version = cliVersion - }; - - return nugetSource is null - ? (0, new[] { implicitPackage }) - : (0, new[] { localHivePackage }); - }; - - runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, invocationOptions, cancellationToken) => - { - selectedPackageVersion = packageVersion; - return 0; - }; - - return runner; - }; - }); - - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("add redis"); - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(0, exitCode); - Assert.False(promptedForVersion); - Assert.Equal(cliVersion, selectedPackageVersion); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"))); - } } internal sealed class TestAddCommandPrompter(IInteractionService interactionService) : AddCommandPrompter(interactionService) diff --git a/tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs b/tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs index 8fa568d7125..0adb81c53d4 100644 --- a/tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs @@ -7,25 +7,6 @@ namespace Aspire.Cli.Tests.Utils; public class VersionHelperTests { - [Theory] - [InlineData("local")] - [InlineData("pr-123")] - [InlineData("run-123")] - public void IsLocalBuildChannel_WithLocalBuildChannels_ReturnsTrue(string channelName) - { - Assert.True(VersionHelper.IsLocalBuildChannel(channelName)); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData("daily")] - [InlineData("stable")] - public void IsLocalBuildChannel_WithNonLocalBuildChannels_ReturnsFalse(string? channelName) - { - Assert.False(VersionHelper.IsLocalBuildChannel(channelName)); - } - [Fact] public void TryGetCurrentCliVersionMatch_WithPrHivesAndNoChannel_ReturnsCurrentCliVersion() { @@ -46,25 +27,4 @@ public void TryGetCurrentCliVersionMatch_WithPrHivesAndNoChannel_ReturnsCurrentC Assert.True(result); Assert.Equal(cliVersion, match); } - - [Fact] - public void TryGetCurrentCliVersionMatch_WithLocalChannelAndNoPrHives_ReturnsCurrentCliVersion() - { - var cliVersion = VersionHelper.GetDefaultSdkVersion(); - var candidates = new[] - { - "99.0.0", - cliVersion, - }; - - var result = VersionHelper.TryGetCurrentCliVersionMatch( - candidates, - version => version, - out var match, - channelName: "local", - hasPrHives: false); - - Assert.True(result); - Assert.Equal(cliVersion, match); - } }