diff --git a/src/Aspire.Cli/Commands/AgentInitCommand.cs b/src/Aspire.Cli/Commands/AgentInitCommand.cs index ef86b025c0b..8e3a8307669 100644 --- a/src/Aspire.Cli/Commands/AgentInitCommand.cs +++ b/src/Aspire.Cli/Commands/AgentInitCommand.cs @@ -135,6 +135,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell promptText, sortedApplicators, applicator => applicator.Description, + optional: true, cancellationToken); selectedApplicators.AddRange(selected); diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index 614b7fb7653..7e88ab22cde 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -233,6 +233,7 @@ later as needed. "Select projects to add to the AppHost:", initContext.ExecutableProjects, project => Path.GetFileNameWithoutExtension(project.ProjectFile.Name).EscapeMarkup(), + optional: true, cancellationToken); initContext.ExecutableProjectsToAddToAppHost = selectedProjects; @@ -286,6 +287,7 @@ ServiceDefaults project contains helper code to make it easier "Select projects to add ServiceDefaults reference to:", initContext.ExecutableProjectsToAddToAppHost, project => Path.GetFileNameWithoutExtension(project.ProjectFile.Name).EscapeMarkup(), + optional: true, cancellationToken); break; case "none": diff --git a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs index fd89dcffe37..d9f2aefe395 100644 --- a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs @@ -190,7 +190,7 @@ public async Task PromptForSelectionAsync(string promptText, IEnumerable> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull + public async Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, bool optional = false, CancellationToken cancellationToken = default) where T : notnull { ArgumentNullException.ThrowIfNull(promptText, nameof(promptText)); ArgumentNullException.ThrowIfNull(choices, nameof(choices)); @@ -213,6 +213,11 @@ public async Task> PromptForSelectionsAsync(string promptTex .AddChoices(choices) .PageSize(10); + if (optional) + { + prompt.NotRequired(); + } + var result = await _outConsole.PromptAsync(prompt, cancellationToken); return result; } diff --git a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs index f15c9dc0f69..688b65e453a 100644 --- a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs @@ -263,7 +263,7 @@ await _extensionTaskChannel.Writer.WriteAsync(async () => } public async Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, - CancellationToken cancellationToken = default) where T : notnull + bool optional = false, CancellationToken cancellationToken = default) where T : notnull { if (_extensionPromptEnabled) { @@ -290,7 +290,7 @@ await _extensionTaskChannel.Writer.WriteAsync(async () => } else { - return await _consoleInteractionService.PromptForSelectionsAsync(promptText, choices, choiceFormatter, cancellationToken); + return await _consoleInteractionService.PromptForSelectionsAsync(promptText, choices, choiceFormatter, optional, cancellationToken); } } diff --git a/src/Aspire.Cli/Interaction/IInteractionService.cs b/src/Aspire.Cli/Interaction/IInteractionService.cs index fc55774caad..d7f5dbd9442 100644 --- a/src/Aspire.Cli/Interaction/IInteractionService.cs +++ b/src/Aspire.Cli/Interaction/IInteractionService.cs @@ -15,7 +15,7 @@ internal interface IInteractionService Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default); public Task ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default); Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull; - Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull; + Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, bool optional = false, CancellationToken cancellationToken = default) where T : notnull; int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion); void DisplayError(string errorMessage); void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false); diff --git a/tests/Aspire.Cli.Tests/Agents/ClaudeCodeAgentEnvironmentScannerTests.cs b/tests/Aspire.Cli.Tests/Agents/ClaudeCodeAgentEnvironmentScannerTests.cs index a67a5428925..4618ff5e481 100644 --- a/tests/Aspire.Cli.Tests/Agents/ClaudeCodeAgentEnvironmentScannerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/ClaudeCodeAgentEnvironmentScannerTests.cs @@ -127,7 +127,7 @@ private static PlaywrightCliInstaller CreatePlaywrightCliInstaller() new FakeNpmRunner(), new FakeNpmProvenanceChecker(), new FakePlaywrightCliRunner(), - new TestConsoleInteractionService(), + new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); } diff --git a/tests/Aspire.Cli.Tests/Agents/CopilotCliAgentEnvironmentScannerTests.cs b/tests/Aspire.Cli.Tests/Agents/CopilotCliAgentEnvironmentScannerTests.cs index 398305f509b..8573a43054c 100644 --- a/tests/Aspire.Cli.Tests/Agents/CopilotCliAgentEnvironmentScannerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/CopilotCliAgentEnvironmentScannerTests.cs @@ -329,7 +329,7 @@ private static PlaywrightCliInstaller CreatePlaywrightCliInstaller() new FakeNpmRunner(), new FakeNpmProvenanceChecker(), new FakePlaywrightCliRunner(), - new TestConsoleInteractionService(), + new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); } diff --git a/tests/Aspire.Cli.Tests/Agents/OpenCodeAgentEnvironmentScannerTests.cs b/tests/Aspire.Cli.Tests/Agents/OpenCodeAgentEnvironmentScannerTests.cs index 6ec5bcf55e6..e004a44438a 100644 --- a/tests/Aspire.Cli.Tests/Agents/OpenCodeAgentEnvironmentScannerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/OpenCodeAgentEnvironmentScannerTests.cs @@ -107,7 +107,7 @@ private static PlaywrightCliInstaller CreatePlaywrightCliInstaller() new FakeNpmRunner(), new FakeNpmProvenanceChecker(), new FakePlaywrightCliRunner(), - new TestConsoleInteractionService(), + new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); } diff --git a/tests/Aspire.Cli.Tests/Agents/PlaywrightCliInstallerTests.cs b/tests/Aspire.Cli.Tests/Agents/PlaywrightCliInstallerTests.cs index ee4f1f9f619..ada1ae8c633 100644 --- a/tests/Aspire.Cli.Tests/Agents/PlaywrightCliInstallerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/PlaywrightCliInstallerTests.cs @@ -33,7 +33,7 @@ public async Task InstallAsync_WhenNpmResolveReturnsNull_ReturnsFalse() ResolveResult = null }; var playwrightRunner = new TestPlaywrightCliRunner(); - var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); + var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None); @@ -53,7 +53,7 @@ public async Task InstallAsync_WhenAlreadyInstalledAtSameVersion_SkipsInstallAnd InstalledVersion = version, InstallSkillsResult = true }; - var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); + var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None); @@ -77,7 +77,7 @@ public async Task InstallAsync_WhenNewerVersionInstalled_SkipsInstallAndInstalls InstalledVersion = installedVersion, InstallSkillsResult = true }; - var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); + var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None); @@ -96,7 +96,7 @@ public async Task InstallAsync_WhenPackFails_ReturnsFalse() PackResult = null }; var playwrightRunner = new TestPlaywrightCliRunner(); - var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); + var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None); @@ -122,7 +122,7 @@ public async Task InstallAsync_WhenIntegrityCheckFails_ReturnsFalse() PackResult = tarballPath }; var playwrightRunner = new TestPlaywrightCliRunner(); - var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); + var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None); @@ -161,7 +161,7 @@ public async Task InstallAsync_WhenIntegrityCheckPasses_InstallsGlobally() { InstallSkillsResult = true }; - var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); + var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None); @@ -197,7 +197,7 @@ public async Task InstallAsync_WhenGlobalInstallFails_ReturnsFalse() InstallGlobalResult = false }; var playwrightRunner = new TestPlaywrightCliRunner(); - var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); + var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None); @@ -236,7 +236,7 @@ public async Task InstallAsync_WhenOlderVersionInstalled_PerformsUpgrade() InstalledVersion = installedVersion, InstallSkillsResult = true }; - var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); + var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None); @@ -313,7 +313,7 @@ public async Task InstallAsync_WhenProvenanceCheckFails_ReturnsFalse() }; var provenanceChecker = new TestNpmProvenanceChecker { ProvenanceOutcome = ProvenanceVerificationOutcome.SourceRepositoryMismatch }; var playwrightRunner = new TestPlaywrightCliRunner(); - var installer = new PlaywrightCliInstaller(npmRunner, provenanceChecker, playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); + var installer = new PlaywrightCliInstaller(npmRunner, provenanceChecker, playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None); @@ -347,7 +347,7 @@ public async Task InstallAsync_WhenValidationDisabled_SkipsAllValidationChecks() [PlaywrightCliInstaller.DisablePackageValidationKey] = "true" }) .Build(); - var installer = new PlaywrightCliInstaller(npmRunner, provenanceChecker, playwrightRunner, new TestConsoleInteractionService(), configuration, NullLogger.Instance); + var installer = new PlaywrightCliInstaller(npmRunner, provenanceChecker, playwrightRunner, new TestInteractionService(), configuration, NullLogger.Instance); var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None); @@ -377,7 +377,7 @@ public async Task InstallAsync_WhenVersionOverrideConfigured_UsesOverrideVersion [PlaywrightCliInstaller.VersionOverrideKey] = "0.2.0" }) .Build(); - var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), configuration, NullLogger.Instance); + var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestInteractionService(), configuration, NullLogger.Instance); await installer.InstallAsync(CreateTestContext(), CancellationToken.None); @@ -393,7 +393,7 @@ public async Task InstallAsync_WhenNoVersionOverride_UsesDefaultRange() ResolveResult = new NpmPackageInfo { Version = version, Integrity = "sha512-abc123" } }; var playwrightRunner = new TestPlaywrightCliRunner(); - var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); + var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); await installer.InstallAsync(CreateTestContext(), CancellationToken.None); @@ -428,7 +428,7 @@ public async Task InstallAsync_MirrorsSkillFilesToOtherAgentEnvironments() var installer = new PlaywrightCliInstaller( npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, - new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), + new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); var context = new AgentEnvironmentScanContext diff --git a/tests/Aspire.Cli.Tests/Agents/VsCodeAgentEnvironmentScannerTests.cs b/tests/Aspire.Cli.Tests/Agents/VsCodeAgentEnvironmentScannerTests.cs index 41dea5f7079..0e94c37ec93 100644 --- a/tests/Aspire.Cli.Tests/Agents/VsCodeAgentEnvironmentScannerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/VsCodeAgentEnvironmentScannerTests.cs @@ -367,7 +367,7 @@ private static PlaywrightCliInstaller CreatePlaywrightCliInstaller() new FakeNpmRunner(), new FakeNpmProvenanceChecker(), new FakePlaywrightCliRunner(), - new TestConsoleInteractionService(), + new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger.Instance); } diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index 465b26ef4e4..4b8605c56ed 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -408,18 +408,14 @@ public async Task AddCommandPreservesSourceArgumentInBothCommands() [Fact] public async Task AddCommand_EmptyPackageList_DisplaysErrorMessage() { - string? displayedErrorMessage = null; + TestInteractionService? testInteractionService = null; using var workspace = TemporaryWorkspace.Create(outputHelper); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { options.InteractionServiceFactory = (sp) => { - var testInteractionService = new TestConsoleInteractionService(); - testInteractionService.DisplayErrorCallback = (message) => - { - displayedErrorMessage = message; - }; + testInteractionService = new TestInteractionService(); return testInteractionService; }; @@ -443,7 +439,8 @@ public async Task AddCommand_EmptyPackageList_DisplaysErrorMessage() var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.FailedToAddPackage, exitCode); - Assert.Contains(AddCommandStrings.NoIntegrationPackagesFound, displayedErrorMessage); + Assert.NotNull(testInteractionService); + Assert.Contains(testInteractionService.DisplayedErrors, e => e.Contains(AddCommandStrings.NoIntegrationPackagesFound)); } [Fact] @@ -457,7 +454,7 @@ public async Task AddCommand_NoMatchingPackages_DisplaysNoMatchesMessage() { options.InteractionServiceFactory = (sp) => { - var testInteractionService = new TestConsoleInteractionService(); + var testInteractionService = new TestInteractionService(); testInteractionService.DisplaySubtleMessageCallback = (message) => { displayedSubtleMessage = message; @@ -551,7 +548,7 @@ public async Task AddCommandPrompter_FiltersToHighestVersionPerPackageId() { options.InteractionServiceFactory = (sp) => { - var mockInteraction = new TestConsoleInteractionService(); + var mockInteraction = new TestInteractionService(); mockInteraction.PromptForSelectionCallback = (message, choices, formatter, ct) => { // Capture what the prompter passes to the interaction service @@ -599,7 +596,7 @@ public async Task AddCommandPrompter_FiltersToHighestVersionPerChannel() { options.InteractionServiceFactory = (sp) => { - var mockInteraction = new TestConsoleInteractionService(); + var mockInteraction = new TestInteractionService(); mockInteraction.PromptForSelectionCallback = (message, choices, formatter, ct) => { // Capture what the prompter passes to the interaction service @@ -647,7 +644,7 @@ public async Task AddCommandPrompter_ShowsHighestVersionPerChannelWhenMultipleCh { options.InteractionServiceFactory = (sp) => { - var mockInteraction = new TestConsoleInteractionService(); + var mockInteraction = new TestInteractionService(); mockInteraction.PromptForSelectionCallback = (message, choices, formatter, ct) => { // Capture what the prompter passes to the interaction service @@ -700,7 +697,7 @@ public async Task AddCommand_WithoutHives_UsesImplicitChannelWithoutPrompting() { options.ProjectLocatorFactory = _ => new TestProjectLocator(); - options.InteractionServiceFactory = _ => new TestConsoleInteractionService() + options.InteractionServiceFactory = _ => new TestInteractionService() { PromptForSelectionCallback = (message, choices, formatter, ct) => { diff --git a/tests/Aspire.Cli.Tests/Commands/BaseCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/BaseCommandTests.cs index 74e3e8da90b..90c78eddd92 100644 --- a/tests/Aspire.Cli.Tests/Commands/BaseCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/BaseCommandTests.cs @@ -21,7 +21,7 @@ public class BaseCommandTests(ITestOutputHelper outputHelper) public async Task BaseCommand_FormatOption_SetsConsoleOutputCorrectly(string args, bool expectErrorConsole) { using var workspace = TemporaryWorkspace.Create(outputHelper); - var testInteractionService = new TestConsoleInteractionService(); + var testInteractionService = new TestInteractionService(); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { options.InteractionServiceFactory = _ => testInteractionService; diff --git a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs index 985ffa8bf4b..2e754fac94b 100644 --- a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs @@ -170,7 +170,7 @@ public async Task InitCommand_WhenNewProjectFails_SetsOutputCollectorAndCallsCal options.InteractionServiceFactory = (sp) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); return interactionService; }; diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 2cc0cdc21e7..59f04e7c528 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Cli.Backchannel; using Aspire.Cli.Utils; using Aspire.Cli.Certificates; using Aspire.Cli.Commands; @@ -17,7 +16,6 @@ using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; using Spectre.Console; -using Spectre.Console.Rendering; using NuGetPackage = Aspire.Shared.NuGetPackageCli; namespace Aspire.Cli.Tests.Commands; @@ -541,15 +539,12 @@ public async Task NewCommandDoesNotPromptForTemplateVersionIfSpecifiedOnCommandL [Fact] public async Task NewCommand_EmptyPackageList_DisplaysErrorMessage() { - string? displayedErrorMessage = null; + TestInteractionService? testInteractionService = null; using var workspace = TemporaryWorkspace.Create(outputHelper); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { options.InteractionServiceFactory = (sp) => { - var testInteractionService = new TestConsoleInteractionService(); - testInteractionService.DisplayErrorCallback = (message) => { - displayedErrorMessage = message; - }; + testInteractionService = new TestInteractionService(); return testInteractionService; }; @@ -570,7 +565,8 @@ public async Task NewCommand_EmptyPackageList_DisplaysErrorMessage() var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.FailedToCreateNewProject, exitCode); - Assert.Contains(TemplatingStrings.NoTemplateVersionsFound, displayedErrorMessage); + Assert.NotNull(testInteractionService); + Assert.Contains(testInteractionService.DisplayedErrors, e => e.Contains(TemplatingStrings.NoTemplateVersionsFound)); } [Fact] @@ -711,7 +707,20 @@ public async Task NewCommandPromptsForTemplateVersionBeforeTemplateOptions() options.InteractionServiceFactory = (sp) => { - var testInteractionService = new OrderTrackingInteractionService(operationOrder); + var testInteractionService = new TestInteractionService(); + testInteractionService.PromptForSelectionCallback = (promptText, choices, formatter, ct) => + { + // Track template option prompts + if (promptText?.Contains("Redis") == true || + promptText?.Contains("test framework") == true || + promptText?.Contains("Create a test project") == true || + promptText?.Contains("xUnit") == true) + { + operationOrder.Add("TemplateOption"); + } + + return choices.Cast().First(); + }; return testInteractionService; }; @@ -838,7 +847,7 @@ public async Task NewCommandWithLanguageOptionAndNoTemplateCanCreateCliEmptyTemp var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.InteractionServiceFactory = _ => new TestConsoleInteractionService + options.InteractionServiceFactory = _ => new TestInteractionService { PromptForSelectionCallback = (promptText, choices, choiceFormatter, cancellationToken) => choices.Cast().First() }; @@ -903,7 +912,7 @@ public async Task NewCommandWithLanguageOptionFiltersOutTypeScriptStarterForCSha var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.InteractionServiceFactory = _ => new TestConsoleInteractionService + options.InteractionServiceFactory = _ => new TestInteractionService { PromptForSelectionCallback = (promptText, choices, choiceFormatter, cancellationToken) => choices.Cast().First() }; @@ -1005,7 +1014,7 @@ public async Task NewCommandWithEmptyTemplateAndCSharpPromptsForLocalhostTldAndU var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.InteractionServiceFactory = _ => new TestConsoleInteractionService + options.InteractionServiceFactory = _ => new TestInteractionService { PromptForSelectionCallback = (promptText, choices, choiceFormatter, cancellationToken) => { @@ -1180,7 +1189,7 @@ public async Task NewCommandWithEmptyTemplateAndTypeScriptPromptsForLocalhostTld var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.InteractionServiceFactory = _ => new TestConsoleInteractionService + options.InteractionServiceFactory = _ => new TestInteractionService { PromptForSelectionCallback = (promptText, choices, choiceFormatter, cancellationToken) => { @@ -1350,75 +1359,6 @@ public override Task PromptForOutputPath(string path, CancellationToken } } -internal sealed class OrderTrackingInteractionService(List operationOrder) : IInteractionService -{ - public ConsoleOutput Console { get; set; } - - public Task ShowStatusAsync(string statusText, Func> action, KnownEmoji? emoji = null, bool allowMarkup = false) - { - return action(); - } - - public void ShowStatus(string statusText, Action action, KnownEmoji? emoji = null, bool allowMarkup = false) - { - action(); - } - - public Task PromptForStringAsync(string promptText, string? defaultValue = null, Func? validator = null, bool isSecret = false, bool required = false, CancellationToken cancellationToken = default) - { - return Task.FromResult(defaultValue ?? string.Empty); - } - - public Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull - { - if (!choices.Any()) - { - throw new EmptyChoicesException($"No items available for selection: {promptText}"); - } - - // Track template option prompts - if (promptText?.Contains("Redis") == true || - promptText?.Contains("test framework") == true || - promptText?.Contains("Create a test project") == true || - promptText?.Contains("xUnit") == true) - { - operationOrder.Add("TemplateOption"); - } - - return Task.FromResult(choices.First()); - } - - public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull - { - if (!choices.Any()) - { - throw new EmptyChoicesException($"No items available for selection: {promptText}"); - } - - return Task.FromResult>(choices.ToList()); - } - - public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion) => 0; - public void DisplayError(string errorMessage) { } - public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false) { } - public void DisplaySuccess(string message, bool allowMarkup = false) { } - public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) { } - public void DisplayCancellationMessage() { } - public Task ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default) => Task.FromResult(true); - public Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default) - => PromptForStringAsync(promptText, defaultValue, validator, isSecret: false, required, cancellationToken); - public void DisplaySubtleMessage(string message, bool escapeMarkup = true) { } - public void DisplayEmptyLine() { } - public void DisplayPlainText(string text) { } - public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) { } - public void DisplayMarkdown(string markdown) { } - public void DisplayMarkupLine(string markup) { } - public void WriteConsoleLog(string message, int? lineNumber = null, string? type = null, bool isErrorMessage = false) { } - public void DisplayVersionUpdateNotification(string newerVersion, string? updateCommand = null) { } - public void DisplayRenderable(IRenderable renderable) { } - public Task DisplayLiveAsync(IRenderable initialRenderable, Func, Task> callback) => callback(_ => { }); -} - internal sealed class NewCommandTestPackagingService : IPackagingService { public Func>>? GetChannelsAsyncCallback { get; set; } diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index d32dee8cd71..497e008ce15 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -10,9 +10,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Cli.Utils; using Microsoft.Extensions.DependencyInjection; -using Spectre.Console; -using Spectre.Console.Rendering; -using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using Microsoft.AspNetCore.InternalTesting; @@ -26,7 +23,7 @@ public async Task PublishCommand_TextInputPrompt_SendsCorrectKeyPresses() // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); var promptBackchannel = new TestPromptBackchannel(); - var consoleService = new TestConsoleInteractionServiceWithPromptTracking(); + var consoleService = new TestInteractionService(); // Set up the prompt that will be sent from AppHost promptBackchannel.AddPrompt("text-prompt-1", "Environment Name", InputTypes.Text, "Enter environment name:", isRequired: true); @@ -72,7 +69,7 @@ public async Task PublishCommand_SecretTextPrompt_SendsCorrectKeyPresses() // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); var promptBackchannel = new TestPromptBackchannel(); - var consoleService = new TestConsoleInteractionServiceWithPromptTracking(); + var consoleService = new TestInteractionService(); // Set up the prompt that will be sent from AppHost promptBackchannel.AddPrompt("secret-prompt-1", "Database Password", InputTypes.SecretText, "Enter secure password:", isRequired: true); @@ -118,7 +115,7 @@ public async Task PublishCommand_ChoicePrompt_SendsCorrectSelection() // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); var promptBackchannel = new TestPromptBackchannel(); - var consoleService = new TestConsoleInteractionServiceWithPromptTracking(); + var consoleService = new TestInteractionService(); // Set up the choice prompt with options var options = new List> @@ -171,7 +168,7 @@ public async Task PublishCommand_BooleanPrompt_SendsCorrectAnswer() // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); var promptBackchannel = new TestPromptBackchannel(); - var consoleService = new TestConsoleInteractionServiceWithPromptTracking(); + var consoleService = new TestInteractionService(); // Set up the boolean prompt promptBackchannel.AddPrompt("bool-prompt-1", "Enable Verbose Logging", InputTypes.Boolean, "Enable verbose logging?", isRequired: false); @@ -217,7 +214,7 @@ public async Task PublishCommand_NumberPrompt_SendsCorrectNumericValue() // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); var promptBackchannel = new TestPromptBackchannel(); - var consoleService = new TestConsoleInteractionServiceWithPromptTracking(); + var consoleService = new TestInteractionService(); // Set up the number prompt promptBackchannel.AddPrompt("number-prompt-1", "Instance Count", InputTypes.Number, "Enter number of instances:", isRequired: true); @@ -263,7 +260,7 @@ public async Task PublishCommand_MultiplePrompts_HandlesSequentialInteractions() // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); var promptBackchannel = new TestPromptBackchannel(); - var consoleService = new TestConsoleInteractionServiceWithPromptTracking(); + var consoleService = new TestInteractionService(); // Set up multiple prompts that will be sent in sequence promptBackchannel.AddPrompt("text-prompt-1", "Application Name", InputTypes.Text, "Enter app name:", isRequired: true); @@ -338,7 +335,7 @@ public async Task PublishCommand_SinglePromptWithMultipleInputs_HandlesAllInputs // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); var promptBackchannel = new TestPromptBackchannel(); - var consoleService = new TestConsoleInteractionServiceWithPromptTracking(); + var consoleService = new TestInteractionService(); // Set up a single prompt with multiple inputs promptBackchannel.AddMultiInputPrompt("multi-input-prompt-1", "Configuration Setup", "Please provide the following details:", @@ -422,7 +419,7 @@ public async Task PublishCommand_TextInputWithDefaultValue_UsesDefaultCorrectly( // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); var promptBackchannel = new TestPromptBackchannel(); - var consoleService = new TestConsoleInteractionServiceWithPromptTracking(); + var consoleService = new TestInteractionService(); // Set up the prompt with a default value promptBackchannel.AddPrompt("text-prompt-1", "Environment Name", InputTypes.Text, "Enter environment name:", isRequired: true, defaultValue: "development"); @@ -474,7 +471,7 @@ public async Task PublishCommand_TextInputWithValidationErrors_UsesValidationErr // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); var promptBackchannel = new TestPromptBackchannel(); - var consoleService = new TestConsoleInteractionServiceWithPromptTracking(); + var consoleService = new TestInteractionService(); // Set up the prompt with a default value promptBackchannel.AddPrompt("text-prompt-1", "Environment Name", InputTypes.Text, "Enter environment name:", isRequired: true, defaultValue: "de", validationErrors: ["Environment name must be at least 3 characters long."]); @@ -529,7 +526,7 @@ public async Task PublishCommand_MarkdownPromptText_ConvertsToSpectreMarkup() // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); var promptBackchannel = new TestPromptBackchannel(); - var consoleService = new TestConsoleInteractionServiceWithPromptTracking(); + var consoleService = new TestInteractionService(); // Set up the prompt with markdown in the activity status text promptBackchannel.AddPrompt("markdown-prompt-1", "Config Value", InputTypes.Text, "**Enter** the `config` value for [Azure Portal](https://portal.azure.com):", isRequired: true); @@ -600,7 +597,7 @@ public async Task PublishCommand_DebugMode_HandlesPromptsWithoutProgressUI() // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); var promptBackchannel = new TestPromptBackchannel(); - var consoleService = new TestConsoleInteractionServiceWithPromptTracking(); + var consoleService = new TestInteractionService(); // Set up the prompt that will be sent from AppHost promptBackchannel.AddPrompt("debug-prompt-1", "Environment Name", InputTypes.Text, "Enter environment name:", isRequired: true); @@ -644,7 +641,7 @@ public async Task PublishCommand_SingleInputPrompt_ShowsBothStatusTextAndLabel() // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); var promptBackchannel = new TestPromptBackchannel(); - var consoleService = new TestConsoleInteractionServiceWithPromptTracking(); + var consoleService = new TestInteractionService(); // Set up a single-input prompt where StatusText and Label are different promptBackchannel.AddPrompt("status-label-prompt", "Target Region", InputTypes.Text, "Configure deployment target", isRequired: true); @@ -687,7 +684,7 @@ public async Task PublishCommand_SingleInputPrompt_WhenStatusTextEqualsLabel_Sho // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); var promptBackchannel = new TestPromptBackchannel(); - var consoleService = new TestConsoleInteractionServiceWithPromptTracking(); + var consoleService = new TestInteractionService(); // Set up a single-input prompt where StatusText and Label are the same promptBackchannel.AddPrompt("duplicate-prompt", "Environment Name", InputTypes.Text, "Environment Name", isRequired: true); @@ -844,136 +841,6 @@ internal sealed record PromptInputData(string Name, string Label, string InputTy internal sealed record PromptData(string PromptId, IReadOnlyList Inputs, string Message, string? Title = null); internal sealed record PromptCompletion(string PromptId, PublishingPromptInputAnswer[] Answers, bool UpdateResponse); -// Enhanced TestConsoleInteractionService that tracks interaction types -[SuppressMessage("Usage", "ASPIREINTERACTION001:Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.")] -internal sealed class TestConsoleInteractionServiceWithPromptTracking : IInteractionService -{ - private readonly Queue<(string response, ResponseType type)> _responses = new(); - private bool _shouldCancel; - - public ConsoleOutput Console { get; set; } - public List StringPromptCalls { get; } = []; - public List SelectionPromptCalls { get; } = []; // Using object to handle generic types - public List BooleanPromptCalls { get; } = []; - public List DisplayedErrors { get; } = []; - - public void SetupStringPromptResponse(string response) => _responses.Enqueue((response, ResponseType.String)); - public void SetupSelectionResponse(string response) => _responses.Enqueue((response, ResponseType.Selection)); - public void SetupBooleanResponse(bool response) => _responses.Enqueue((response.ToString().ToLower(), ResponseType.Boolean)); - public void SetupCancellationResponse() => _shouldCancel = true; - - public void SetupSequentialResponses(params (string response, ResponseType type)[] responses) - { - foreach (var (response, type) in responses) - { - _responses.Enqueue((response, type)); - } - } - - public Task PromptForStringAsync(string promptText, string? defaultValue = null, Func? validator = null, bool isSecret = false, bool required = false, CancellationToken cancellationToken = default) - { - StringPromptCalls.Add(new StringPromptCall(promptText, defaultValue, isSecret)); - - if (_shouldCancel || cancellationToken.IsCancellationRequested) - { - throw new OperationCanceledException(); - } - - if (_responses.TryDequeue(out var response)) - { - return Task.FromResult(response.response); - } - - return Task.FromResult(defaultValue ?? string.Empty); - } - - public Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default) - => PromptForStringAsync(promptText, defaultValue, validator, isSecret: false, required, cancellationToken); - - public Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull - { - if (_shouldCancel || cancellationToken.IsCancellationRequested) - { - throw new OperationCanceledException(); - } - - if (_responses.TryDequeue(out var response)) - { - // Find the choice that matches the response - var matchingChoice = choices.FirstOrDefault(c => choiceFormatter(c) == response.response || c.ToString() == response.response); - if (matchingChoice != null) - { - return Task.FromResult(matchingChoice); - } - } - - return Task.FromResult(choices.First()); - } - - public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull - { - if (_shouldCancel || cancellationToken.IsCancellationRequested) - { - throw new OperationCanceledException(); - } - - _ = _responses.TryDequeue(out _); - // For simplicity, return all choices in the test - return Task.FromResult>(choices.ToList()); - } - - public Task ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default) - { - BooleanPromptCalls.Add(new BooleanPromptCall(promptText, defaultValue)); - - if (_shouldCancel || cancellationToken.IsCancellationRequested) - { - throw new OperationCanceledException(); - } - - if (_responses.TryDequeue(out var response)) - { - return Task.FromResult(bool.Parse(response.response)); - } - - return Task.FromResult(defaultValue); - } - - // Default implementations for other interface methods - public Task ShowStatusAsync(string statusText, Func> action, KnownEmoji? emoji = null, bool allowMarkup = false) => action(); - public void ShowStatus(string statusText, Action action, KnownEmoji? emoji = null, bool allowMarkup = false) => action(); - public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion) => 0; - public void DisplayError(string errorMessage) => DisplayedErrors.Add(errorMessage); - public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false) { } - public void DisplaySuccess(string message, bool allowMarkup = false) { } - public void DisplaySubtleMessage(string message, bool allowMarkup = false) { } - public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) { } - public void DisplayCancellationMessage() { } - public void DisplayEmptyLine() { } - public void DisplayPlainText(string text) { } - public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) { } - public void DisplayMarkdown(string markdown) { } - public void DisplayMarkupLine(string markup) { } - - public void DisplayVersionUpdateNotification(string newerVersion, string? updateCommand = null) { } - - public void DisplayRenderable(IRenderable renderable) { } - public Task DisplayLiveAsync(IRenderable initialRenderable, Func, Task> callback) => callback(_ => { }); - - public void WriteConsoleLog(string message, int? lineNumber = null, string? type = null, bool isErrorMessage = false) - { - var messageType = isErrorMessage ? "error" : "info"; - System.Console.WriteLine($"#{lineNumber} [{messageType}] {message}"); - } -} - -internal enum ResponseType -{ - String, - Selection, - Boolean -} - // Input type constants that match the Aspire CLI implementation internal static class InputTypes { @@ -983,7 +850,3 @@ internal static class InputTypes public const string Boolean = "boolean"; public const string Number = "number"; } - -internal sealed record StringPromptCall(string PromptText, string? DefaultValue, bool IsSecret); -internal sealed record SelectionPromptCall(string PromptText, IEnumerable Choices, Func ChoiceFormatter); -internal sealed record BooleanPromptCall(string PromptText, bool DefaultValue); diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 56d1e6e6346..11113158451 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Runtime.CompilerServices; @@ -386,7 +386,6 @@ public async Task RunCommand_WithNoResources_CompletesSuccessfully() [Fact] public async Task RunCommand_WhenDashboardFailsToStart_ReturnsNonZeroExitCodeWithClearErrorMessage() { - var errorMessages = new List(); var backchannelFactory = (IServiceProvider sp) => { @@ -437,12 +436,7 @@ public async Task RunCommand_WhenDashboardFailsToStart_ReturnsNonZeroExitCodeWit options.ProjectLocatorFactory = projectLocatorFactory; options.AppHostBackchannelFactory = backchannelFactory; options.DotNetCliRunnerFactory = runnerFactory; - options.InteractionServiceFactory = (sp) => - { - var interactionService = new TestConsoleInteractionService(); - interactionService.DisplayErrorCallback = errorMessages.Add; - return interactionService; - }; + options.InteractionServiceFactory = (sp) => new TestInteractionService(); }); var provider = services.BuildServiceProvider(); @@ -458,7 +452,7 @@ public async Task RunCommand_WhenDashboardFailsToStart_ReturnsNonZeroExitCodeWit [Fact] public async Task AppHostHelper_BuildAppHostAsync_IncludesRelativePathInStatusMessage() { - var testInteractionService = new TestConsoleInteractionService(); + var testInteractionService = new TestInteractionService(); testInteractionService.ShowStatusCallback = (statusText) => { Assert.Contains( diff --git a/tests/Aspire.Cli.Tests/Commands/SdkInstallerTests.cs b/tests/Aspire.Cli.Tests/Commands/SdkInstallerTests.cs index 4319ebd574a..7b5dc8db592 100644 --- a/tests/Aspire.Cli.Tests/Commands/SdkInstallerTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/SdkInstallerTests.cs @@ -38,7 +38,7 @@ public async Task RunCommand_WhenSdkNotInstalled_ReturnsCorrectExitCode() // Use TestDotNetCliRunner to avoid real process execution options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner(); - options.InteractionServiceFactory = _ => new TestConsoleInteractionService(); + options.InteractionServiceFactory = _ => new TestInteractionService(); }); var provider = services.BuildServiceProvider(); @@ -60,7 +60,7 @@ public async Task AddCommand_WhenSdkNotInstalled_ReturnsCorrectExitCode() CheckAsyncCallback = _ => (false, null, "9.0.302") // SDK not installed }; - options.InteractionServiceFactory = _ => new TestConsoleInteractionService(); + options.InteractionServiceFactory = _ => new TestInteractionService(); // Need to provide a project locator since AddCommand checks for project first options.ProjectLocatorFactory = _ => new TestProjectLocator(); @@ -85,7 +85,7 @@ public async Task NewCommand_WhenSdkNotInstalled_OnlyShowsCliTemplates() CheckAsyncCallback = _ => (false, null, "9.0.302") // SDK not installed }; - options.InteractionServiceFactory = _ => new TestConsoleInteractionService(); + options.InteractionServiceFactory = _ => new TestInteractionService(); }); var provider = services.BuildServiceProvider(); @@ -125,7 +125,7 @@ public async Task PublishCommand_WhenSdkNotInstalled_ReturnsCorrectExitCode() // Use TestDotNetCliRunner to avoid real process execution options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner(); - options.InteractionServiceFactory = _ => new TestConsoleInteractionService(); + options.InteractionServiceFactory = _ => new TestInteractionService(); }); var provider = services.BuildServiceProvider(); @@ -163,7 +163,7 @@ public async Task DeployCommand_WhenSdkNotInstalled_ReturnsCorrectExitCode() // Use TestDotNetCliRunner to avoid real process execution options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner(); - options.InteractionServiceFactory = _ => new TestConsoleInteractionService(); + options.InteractionServiceFactory = _ => new TestInteractionService(); }); var provider = services.BuildServiceProvider(); @@ -186,7 +186,7 @@ public async Task ExecCommand_WhenSdkNotInstalled_ReturnsCorrectExitCode() CheckAsyncCallback = _ => (false, null, "9.0.302") // SDK not installed }; - options.InteractionServiceFactory = _ => new TestConsoleInteractionService(); + options.InteractionServiceFactory = _ => new TestInteractionService(); }); var provider = services.BuildServiceProvider(); diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index aa1d32e3f1a..f66e03464b6 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -49,7 +49,7 @@ public async Task UpdateCommand_WhenProjectOptionSpecified_PassesProjectFileToPr } }; - options.InteractionServiceFactory = _ => new TestConsoleInteractionService(); + options.InteractionServiceFactory = _ => new TestInteractionService(); options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner(); @@ -179,7 +179,7 @@ public async Task UpdateCommand_WhenNoProjectFound_PromptsForCliSelfUpdate() } }; - options.InteractionServiceFactory = _ => new TestConsoleInteractionService() + options.InteractionServiceFactory = _ => new TestInteractionService() { ConfirmCallback = (prompt, defaultValue) => { @@ -222,7 +222,7 @@ public async Task UpdateCommand_WhenProjectUpdatedSuccessfully_AndChannelSupport } }; - options.InteractionServiceFactory = _ => new TestConsoleInteractionService() + options.InteractionServiceFactory = _ => new TestInteractionService() { ConfirmCallback = (prompt, defaultValue) => { @@ -295,7 +295,7 @@ public async Task UpdateCommand_WhenChannelHasNoCliDownloadUrl_DoesNotPromptForC } }; - options.InteractionServiceFactory = _ => new TestConsoleInteractionService() + options.InteractionServiceFactory = _ => new TestInteractionService() { ConfirmCallback = (prompt, defaultValue) => { @@ -360,7 +360,7 @@ public async Task UpdateCommand_SelfUpdate_WithChannelOption_DoesNotPromptForCha var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.InteractionServiceFactory = _ => new TestConsoleInteractionService() + options.InteractionServiceFactory = _ => new TestInteractionService() { PromptForSelectionCallback = (prompt, choices, formatter, ct) => { @@ -407,7 +407,7 @@ public async Task UpdateCommand_SelfUpdate_WithQualityOption_DoesNotPromptForQua var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.InteractionServiceFactory = _ => new TestConsoleInteractionService() + options.InteractionServiceFactory = _ => new TestInteractionService() { PromptForSelectionCallback = (prompt, choices, formatter, ct) => { @@ -503,7 +503,7 @@ public async Task UpdateCommand_ProjectUpdate_WithChannelOption_DoesNotPromptFor } }; - options.InteractionServiceFactory = _ => new TestConsoleInteractionService() + options.InteractionServiceFactory = _ => new TestInteractionService() { PromptForSelectionCallback = (prompt, choices, formatter, ct) => { @@ -570,7 +570,7 @@ public async Task UpdateCommand_ProjectUpdate_WithQualityOption_DoesNotPromptFor } }; - options.InteractionServiceFactory = _ => new TestConsoleInteractionService() + options.InteractionServiceFactory = _ => new TestInteractionService() { PromptForSelectionCallback = (prompt, choices, formatter, ct) => { @@ -624,8 +624,7 @@ public async Task UpdateCommand_ProjectUpdate_WithInvalidQuality_DisplaysError() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var errorDisplayed = false; - string? errorMessage = null; + TestInteractionService? testInteractionService = null; var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { @@ -637,13 +636,10 @@ public async Task UpdateCommand_ProjectUpdate_WithInvalidQuality_DisplaysError() } }; - options.InteractionServiceFactory = _ => new TestConsoleInteractionService() + options.InteractionServiceFactory = _ => { - DisplayErrorCallback = (message) => - { - errorDisplayed = true; - errorMessage = message; - } + testInteractionService = new TestInteractionService(); + return testInteractionService; }; options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner(); @@ -671,8 +667,9 @@ public async Task UpdateCommand_ProjectUpdate_WithInvalidQuality_DisplaysError() var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert - Assert.True(errorDisplayed, "Error should be displayed for invalid quality"); - Assert.NotNull(errorMessage); + Assert.NotNull(testInteractionService); + Assert.NotEmpty(testInteractionService.DisplayedErrors); + var errorMessage = Assert.Single(testInteractionService.DisplayedErrors); Assert.Contains("invalid", errorMessage); Assert.Contains("stable", errorMessage); Assert.Contains("daily", errorMessage); @@ -697,7 +694,7 @@ public async Task UpdateCommand_ProjectUpdate_ChannelTakesPrecedenceOverQuality( } }; - options.InteractionServiceFactory = _ => new TestConsoleInteractionService() + options.InteractionServiceFactory = _ => new TestInteractionService() { PromptForSelectionCallback = (prompt, choices, formatter, ct) => { @@ -755,7 +752,7 @@ public async Task UpdateCommand_ProjectUpdate_WhenCancelled_DisplaysCancellation var cancellationMessageDisplayed = false; - var wrappedService = new CancellationTrackingInteractionService(new TestConsoleInteractionService() + var wrappedService = new CancellationTrackingInteractionService(new TestInteractionService() { PromptForSelectionCallback = (prompt, choices, formatter, ct) => { @@ -820,7 +817,7 @@ public async Task UpdateCommand_WithoutHives_UsesImplicitChannelWithoutPrompting } }; - options.InteractionServiceFactory = _ => new TestConsoleInteractionService() + options.InteractionServiceFactory = _ => new TestInteractionService() { PromptForSelectionCallback = (prompt, choices, formatter, ct) => { @@ -876,7 +873,7 @@ public async Task UpdateCommand_SelfUpdate_WhenCancelled_DisplaysCancellationMes var cancellationMessageDisplayed = false; - var wrappedService = new CancellationTrackingInteractionService(new TestConsoleInteractionService() + var wrappedService = new CancellationTrackingInteractionService(new TestInteractionService() { PromptForSelectionCallback = (prompt, choices, formatter, ct) => { @@ -964,8 +961,8 @@ public Task ConfirmAsync(string promptText, bool defaultValue = true, Canc => _innerService.ConfirmAsync(promptText, defaultValue, cancellationToken); public Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull => _innerService.PromptForSelectionAsync(promptText, choices, choiceFormatter, cancellationToken); - public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull - => _innerService.PromptForSelectionsAsync(promptText, choices, choiceFormatter, cancellationToken); + public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, bool optional = false, CancellationToken cancellationToken = default) where T : notnull + => _innerService.PromptForSelectionsAsync(promptText, choices, choiceFormatter, optional, cancellationToken); public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion) => _innerService.DisplayIncompatibleVersionError(ex, appHostHostingVersion); public void DisplayError(string errorMessage) => _innerService.DisplayError(errorMessage); diff --git a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs index a3c51022a71..1494d255ed3 100644 --- a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs @@ -43,7 +43,7 @@ public async Task PromptForSelectionsAsync_EmptyChoices_ThrowsEmptyChoicesExcept // Act & Assert await Assert.ThrowsAsync(() => - interactionService.PromptForSelectionsAsync("Select items:", choices, x => x, CancellationToken.None)); + interactionService.PromptForSelectionsAsync("Select items:", choices, x => x, cancellationToken: CancellationToken.None)); } [Fact] @@ -273,7 +273,7 @@ public async Task PromptForSelectionsAsync_WhenInteractiveInputNotSupported_Thro // Act & Assert var exception = await Assert.ThrowsAsync(() => - interactionService.PromptForSelectionsAsync("Select items:", choices, x => x, CancellationToken.None)); + interactionService.PromptForSelectionsAsync("Select items:", choices, x => x, cancellationToken: CancellationToken.None)); Assert.Contains(InteractionServiceStrings.InteractiveInputNotSupported, exception.Message); } diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs index 0864fa78149..898501130cc 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs @@ -774,7 +774,7 @@ public async Task UseOrFindAppHostProjectFilePromptsWhenDirectoryHasMultipleProj var returnedProjectFile = await projectLocator.UseOrFindAppHostProjectFileAsync(directoryAsFileInfo, createSettingsFile: true).DefaultTimeout(); - // Should return the first project file (TestConsoleInteractionService returns the first choice) + // Should return the first project file (TestInteractionService returns the first choice) Assert.Equal(projectFile1.FullName, returnedProjectFile!.FullName); } @@ -967,7 +967,7 @@ private static ProjectLocator CreateProjectLocator( return new ProjectLocator( logger, executionContext, - interactionService ?? new TestConsoleInteractionService(), + interactionService ?? new TestInteractionService(), configurationService ?? new TestConfigurationService(), projectFactory ?? new TestAppHostProjectFactory(), languageDiscovery ?? new TestLanguageDiscovery(), diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs index 9c8cf17ea31..52946f70bb1 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs @@ -100,7 +100,7 @@ await File.WriteAllTextAsync( config.InteractionServiceFactory = (sp) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); interactionService.ConfirmCallback = (promptText, defaultValue) => { throw new InvalidOperationException("Should not prompt when no work required."); @@ -217,7 +217,7 @@ await File.WriteAllTextAsync( config.InteractionServiceFactory = (s) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); return interactionService; }; }); @@ -356,7 +356,7 @@ await File.WriteAllTextAsync( config.InteractionServiceFactory = (s) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); return interactionService; }; }); @@ -509,7 +509,7 @@ await File.WriteAllTextAsync( config.InteractionServiceFactory = (s) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); return interactionService; }; }); @@ -647,7 +647,7 @@ await File.WriteAllTextAsync( config.InteractionServiceFactory = (sp) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); interactionService.ConfirmCallback = (promptText, defaultValue) => { return true; @@ -752,7 +752,7 @@ await File.WriteAllTextAsync( config.InteractionServiceFactory = (sp) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); interactionService.ConfirmCallback = (promptText, defaultValue) => { return true; @@ -854,7 +854,7 @@ await File.WriteAllTextAsync( config.InteractionServiceFactory = (sp) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); interactionService.ConfirmCallback = (promptText, defaultValue) => { // Should not be called since no updates are needed @@ -998,7 +998,7 @@ await File.WriteAllTextAsync( config.InteractionServiceFactory = (sp) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); interactionService.ConfirmCallback = (promptText, defaultValue) => { return true; @@ -1121,7 +1121,7 @@ await File.WriteAllTextAsync( config.InteractionServiceFactory = (sp) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); interactionService.ConfirmCallback = (promptText, defaultValue) => { return true; @@ -1235,7 +1235,7 @@ await File.WriteAllTextAsync( config.InteractionServiceFactory = (sp) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); interactionService.ConfirmCallback = (promptText, defaultValue) => { return true; @@ -1345,7 +1345,7 @@ await File.WriteAllTextAsync( config.InteractionServiceFactory = (sp) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); interactionService.ConfirmCallback = (promptText, defaultValue) => { return true; @@ -1443,7 +1443,7 @@ await File.WriteAllTextAsync( config.InteractionServiceFactory = (sp) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); interactionService.ConfirmCallback = (promptText, defaultValue) => { return true; @@ -1545,7 +1545,7 @@ await File.WriteAllTextAsync( config.InteractionServiceFactory = (sp) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); interactionService.ConfirmCallback = (promptText, defaultValue) => { return true; @@ -1629,7 +1629,7 @@ await File.WriteAllTextAsync( config.InteractionServiceFactory = (sp) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); return interactionService; }; }); @@ -1711,7 +1711,7 @@ await File.WriteAllTextAsync( config.InteractionServiceFactory = (sp) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); return interactionService; }; }); @@ -1785,7 +1785,7 @@ await File.WriteAllTextAsync( config.InteractionServiceFactory = (sp) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); interactionService.ConfirmCallback = (promptText, defaultValue) => { return true; @@ -1868,7 +1868,7 @@ await File.WriteAllTextAsync( config.InteractionServiceFactory = (sp) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); interactionService.ConfirmCallback = (promptText, defaultValue) => { return true; @@ -1956,7 +1956,7 @@ await File.WriteAllTextAsync( config.InteractionServiceFactory = (sp) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); interactionService.ConfirmCallback = (promptText, defaultValue) => { return true; @@ -2038,7 +2038,7 @@ await File.WriteAllTextAsync( config.InteractionServiceFactory = (sp) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); interactionService.ConfirmCallback = (promptText, defaultValue) => { return true; @@ -2117,7 +2117,7 @@ await File.WriteAllTextAsync( config.InteractionServiceFactory = (sp) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); interactionService.ConfirmCallback = (promptText, defaultValue) => { return true; @@ -2208,7 +2208,7 @@ await File.WriteAllTextAsync( config.InteractionServiceFactory = (sp) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); interactionService.ConfirmCallback = (promptText, defaultValue) => { return true; @@ -2335,7 +2335,7 @@ await File.WriteAllTextAsync( config.InteractionServiceFactory = (sp) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); interactionService.ConfirmCallback = (promptText, defaultValue) => { return true; diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index fdd0bc788e2..5045f14ea41 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -8,7 +8,6 @@ using Aspire.Cli.Commands; using Aspire.Cli.Configuration; using Aspire.Cli.DotNet; -using Aspire.Cli.Interaction; using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; using Aspire.Cli.Templating; @@ -17,8 +16,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Cli.Utils; using Aspire.Shared; -using Spectre.Console; -using Spectre.Console.Rendering; namespace Aspire.Cli.Tests.Templating; @@ -450,52 +447,6 @@ public bool IsFeatureEnabled(string featureFlag, bool defaultValue) } } - private sealed class TestInteractionService : IInteractionService - { - public ConsoleOutput Console { get; set; } - - public Task PromptForSelectionAsync(string prompt, IEnumerable choices, Func displaySelector, CancellationToken cancellationToken) where T : notnull - => throw new NotImplementedException(); - - public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull - => throw new NotImplementedException(); - - public Task PromptForStringAsync(string promptText, string? defaultValue = null, Func? validator = null, bool isSecret = false, bool required = false, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - - public Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - - public Task ConfirmAsync(string prompt, bool defaultAnswer, CancellationToken cancellationToken) - => throw new NotImplementedException(); - - public Task ShowStatusAsync(string message, Func> work, KnownEmoji? emoji = null, bool allowMarkup = false) - => throw new NotImplementedException(); - - public Task ShowStatusAsync(string message, Func work) - => throw new NotImplementedException(); - - public void ShowStatus(string message, Action work, KnownEmoji? emoji = null, bool allowMarkup = false) - => throw new NotImplementedException(); - - public void DisplaySuccess(string message, bool allowMarkup = false) { } - public void DisplayError(string message) { } - public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false) { } - public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) { } - public void DisplayCancellationMessage() { } - public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion) => 0; - public void DisplayPlainText(string text) { } - public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) { } - public void DisplayMarkdown(string markdown) { } - public void DisplayMarkupLine(string markup) { } - public void DisplaySubtleMessage(string message, bool allowMarkup = false) { } - public void DisplayEmptyLine() { } - public void DisplayVersionUpdateNotification(string message, string? updateCommand = null) { } - public void WriteConsoleLog(string message, int? resourceHashCode, string? resourceName, bool isError) { } - public void DisplayRenderable(IRenderable renderable) { } - public Task DisplayLiveAsync(IRenderable initialRenderable, Func, Task> callback) => callback(_ => { }); - } - private sealed class TestDotNetCliRunner : IDotNetCliRunner { public Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, FileInfo? nugetConfigFile, string? nugetSource, bool force, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) diff --git a/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs index 08ec5586ee2..b4820e662e7 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs @@ -54,7 +54,7 @@ public Task PromptForSelectionAsync(string promptText, IEnumerable choi return Task.FromResult(choices.First()); } - public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull + public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, bool optional = false, CancellationToken cancellationToken = default) where T : notnull { if (!choices.Any()) { diff --git a/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs similarity index 60% rename from tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs rename to tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs index b06f83018c2..583166c4ba8 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs @@ -9,15 +9,20 @@ namespace Aspire.Cli.Tests.TestServices; -internal sealed class TestConsoleInteractionService : IInteractionService +internal sealed class TestInteractionService : IInteractionService { + private readonly Queue<(string Response, ResponseType Type)> _responses = new(); + private bool _shouldCancel; + public ConsoleOutput Console { get; set; } - public Action? DisplayErrorCallback { get; set; } + + // Callback hooks public Action? DisplaySubtleMessageCallback { get; set; } public Action? DisplayConsoleWriteLineMessage { get; set; } public Func? ConfirmCallback { get; set; } - public Action? ShowStatusCallback { get; set; } - + public Action? ShowStatusCallback { get; set; } + public Action? DisplayVersionUpdateNotificationCallback { get; set; } + /// /// Callback for capturing selection prompts in tests. Uses non-generic IEnumerable and object /// to work with the generic PromptForSelectionAsync<T> method regardless of T's type. @@ -25,6 +30,25 @@ internal sealed class TestConsoleInteractionService : IInteractionService /// public Func, CancellationToken, object>? PromptForSelectionCallback { get; set; } + // Call tracking + public List StringPromptCalls { get; } = []; + public List BooleanPromptCalls { get; } = []; + public List DisplayedErrors { get; } = []; + + // Response queue setup methods + public void SetupStringPromptResponse(string response) => _responses.Enqueue((response, ResponseType.String)); + public void SetupSelectionResponse(string response) => _responses.Enqueue((response, ResponseType.Selection)); + public void SetupBooleanResponse(bool response) => _responses.Enqueue((response.ToString().ToLowerInvariant(), ResponseType.Boolean)); + public void SetupCancellationResponse() => _shouldCancel = true; + + public void SetupSequentialResponses(params (string Response, ResponseType Type)[] responses) + { + foreach (var (response, type) in responses) + { + _responses.Enqueue((response, type)); + } + } + public Task ShowStatusAsync(string statusText, Func> action, KnownEmoji? emoji = null, bool allowMarkup = false) { ShowStatusCallback?.Invoke(statusText); @@ -38,6 +62,18 @@ public void ShowStatus(string statusText, Action action, KnownEmoji? emoji = nul public Task PromptForStringAsync(string promptText, string? defaultValue = null, Func? validator = null, bool isSecret = false, bool required = false, CancellationToken cancellationToken = default) { + StringPromptCalls.Add(new StringPromptCall(promptText, defaultValue, isSecret)); + + if (_shouldCancel || cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(); + } + + if (_responses.TryDequeue(out var response)) + { + return Task.FromResult(response.Response); + } + return Task.FromResult(defaultValue ?? string.Empty); } @@ -48,6 +84,11 @@ public Task PromptForFilePathAsync(string promptText, string? defaultVal public Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull { + if (_shouldCancel || cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(); + } + if (!choices.Any()) { throw new EmptyChoicesException($"No items available for selection: {promptText}"); @@ -55,24 +96,35 @@ public Task PromptForSelectionAsync(string promptText, IEnumerable choi if (PromptForSelectionCallback is not null) { - // Invoke the callback - casting is safe here because: - // 1. 'choices' is IEnumerable, and we cast items to T when calling choiceFormatter - // 2. 'result' comes from the callback which receives 'choices', so it must be of type T - // 3. These casts are for test infrastructure only, not production code var result = PromptForSelectionCallback(promptText, choices, o => choiceFormatter((T)o), cancellationToken); return Task.FromResult((T)result); } + if (_responses.TryDequeue(out var response)) + { + var matchingChoice = choices.FirstOrDefault(c => choiceFormatter(c) == response.Response || c.ToString() == response.Response); + if (matchingChoice is not null) + { + return Task.FromResult(matchingChoice); + } + } + return Task.FromResult(choices.First()); } - public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull + public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, bool optional = false, CancellationToken cancellationToken = default) where T : notnull { + if (_shouldCancel || cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(); + } + if (!choices.Any()) { throw new EmptyChoicesException($"No items available for selection: {promptText}"); } + _ = _responses.TryDequeue(out _); return Task.FromResult>(choices.ToList()); } @@ -83,7 +135,7 @@ public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, stri public void DisplayError(string errorMessage) { - DisplayErrorCallback?.Invoke(errorMessage); + DisplayedErrors.Add(errorMessage); } public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false) @@ -104,7 +156,24 @@ public void DisplayCancellationMessage() public Task ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default) { - return Task.FromResult(ConfirmCallback != null? ConfirmCallback(promptText, defaultValue) : defaultValue); + BooleanPromptCalls.Add(new BooleanPromptCall(promptText, defaultValue)); + + if (_shouldCancel || cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(); + } + + if (ConfirmCallback is not null) + { + return Task.FromResult(ConfirmCallback(promptText, defaultValue)); + } + + if (_responses.TryDequeue(out var response)) + { + return Task.FromResult(bool.Parse(response.Response)); + } + + return Task.FromResult(defaultValue); } public void DisplaySubtleMessage(string message, bool allowMarkup = false) @@ -138,8 +207,6 @@ public void WriteConsoleLog(string message, int? lineNumber = null, string? type DisplayConsoleWriteLineMessage?.Invoke(output); } - public Action? DisplayVersionUpdateNotificationCallback { get; set; } - public void DisplayVersionUpdateNotification(string newerVersion, string? updateCommand = null) { DisplayVersionUpdateNotificationCallback?.Invoke(newerVersion); @@ -154,3 +221,14 @@ public Task DisplayLiveAsync(IRenderable initialRenderable, Func { }); } } + +internal enum ResponseType +{ + String, + Selection, + Boolean +} + +internal sealed record StringPromptCall(string PromptText, string? DefaultValue, bool IsSecret); +internal sealed record SelectionPromptCall(string PromptText, IEnumerable Choices, Func ChoiceFormatter); +internal sealed record BooleanPromptCall(string PromptText, bool DefaultValue); diff --git a/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs b/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs index 77deca3237b..081dc306dbc 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs @@ -43,7 +43,7 @@ public async Task PrereleaseWillRecommendUpgradeToPrereleaseOnSameVersionFamily( configure.InteractionServiceFactory = (sp) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); interactionService.DisplayVersionUpdateNotificationCallback = (newerVersion) => { suggestedVersionTcs.SetResult(newerVersion); @@ -98,7 +98,7 @@ public async Task PrereleaseWillRecommendUpgradeToStableInCurrentVersionFamily() configure.InteractionServiceFactory = (sp) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); interactionService.DisplayVersionUpdateNotificationCallback = (newerVersion) => { suggestedVersionTcs.SetResult(newerVersion); @@ -153,7 +153,7 @@ public async Task StableWillOnlyRecommendGoingToNewerStable() configure.InteractionServiceFactory = (sp) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); interactionService.DisplayVersionUpdateNotificationCallback = (newerVersion) => { suggestedVersionTcs.SetResult(newerVersion); @@ -204,7 +204,7 @@ public async Task StableWillNotRecommendUpdatingToPreview() configure.InteractionServiceFactory = (sp) => { - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); interactionService.DisplayVersionUpdateNotificationCallback = (newerVersion) => { Assert.Fail("Should not suggest a preview version when current version is stable."); diff --git a/tests/Aspire.Cli.Tests/Utils/SdkInstallHelperTests.cs b/tests/Aspire.Cli.Tests/Utils/SdkInstallHelperTests.cs index 3937a2be44c..cde1daa7319 100644 --- a/tests/Aspire.Cli.Tests/Utils/SdkInstallHelperTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/SdkInstallHelperTests.cs @@ -20,7 +20,7 @@ public async Task EnsureSdkInstalledAsync_WhenSdkAlreadyInstalled_RecordsAlready CheckAsyncCallback = _ => (true, "9.0.302", "9.0.302") }; - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); var result = await SdkInstallHelper.EnsureSdkInstalledAsync( sdkInstaller, @@ -46,7 +46,7 @@ public async Task EnsureSdkInstalledAsync_WhenSdkMissing_RecordsNotInstalledTele CheckAsyncCallback = _ => (false, null, "9.0.302") }; - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); var result = await SdkInstallHelper.EnsureSdkInstalledAsync( sdkInstaller, @@ -72,7 +72,7 @@ public async Task EnsureSdkInstalledAsync_WhenSdkMissing_DisplaysError() CheckAsyncCallback = _ => (false, "8.0.100", "9.0.302") }; - var interactionService = new TestConsoleInteractionService(); + var interactionService = new TestInteractionService(); var result = await SdkInstallHelper.EnsureSdkInstalledAsync( sdkInstaller,