File: Commands\BaseCommandTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using Aspire.Cli.Commands;
using Aspire.Cli.Interaction;
#if DEBUG
using Aspire.Cli.NuGet;
using Aspire.Cli.Packaging;
#endif
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
 
namespace Aspire.Cli.Tests.Commands;
 
public class BaseCommandTests(ITestOutputHelper outputHelper)
{
    [Theory]
    [InlineData("ps", false)]
    [InlineData("ps --format json", true)]
    [InlineData("ps --format table", false)]
    [InlineData("ps --format invalid", false)]
    [InlineData("docs --format json", false)]
    [InlineData("do --list-steps --format json", true)]
    [InlineData("do --list-steps --format=json", true)]
    [InlineData("do --list-steps", false)]
    public async Task BaseCommand_FormatOption_SetsConsoleOutputCorrectly(string args, bool expectErrorConsole)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testInteractionService = new TestInteractionService();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse(args);
 
        await result.InvokeAsync().DefaultTimeout();
 
        var expected = expectErrorConsole ? ConsoleOutput.Error : ConsoleOutput.Standard;
        Assert.Equal(expected, testInteractionService.Console);
    }
 
    [Fact]
    public async Task BaseCommand_IntegrationListFormatJson_SetsConsoleOutputCorrectly()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testInteractionService = new TestInteractionService();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
            options.DotNetCliRunnerFactory = _ =>
            {
                var runner = new TestDotNetCliRunner();
                runner.SearchPackagesAsyncCallback = (_, _, _, _, _, _, _, _, _, _) => (0, []);
                return runner;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("integration list --format json");
 
        await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(ConsoleOutput.Error, testInteractionService.Console);
    }
 
    [Fact]
    public async Task BaseCommand_WithNoUpdateNotification_DoesNotDisplayTrailingBlankLine()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testInteractionService = new TestInteractionService();
        var testNotifier = new TestCliUpdateNotifier();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
            options.CliUpdateNotifierFactory = _ => testNotifier;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("stop");
 
        await result.InvokeAsync().DefaultTimeout();
 
        Assert.True(testNotifier.NotifyWasCalled);
        Assert.Equal(0, testInteractionService.DisplayEmptyLineCount);
    }
 
    [Fact]
    public async Task BaseCommand_WithUpdateNotification_DoesNotDisplayTrailingBlankLine()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testInteractionService = new TestInteractionService();
        var testNotifier = new TestCliUpdateNotifier
        {
            IsUpdateAvailableCallback = () => true,
            NotifyIfUpdateAvailableCallback = () => testInteractionService.DisplayVersionUpdateNotification("13.3.0-preview.1", "aspire update")
        };
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
            options.CliUpdateNotifierFactory = _ => testNotifier;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("stop");
 
        await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(0, testInteractionService.DisplayEmptyLineCount);
    }
 
#if DEBUG
    [Fact]
    public async Task BaseCommand_UpdateNotificationValidationFailureEscapesInDebugBuilds()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var expectedException = new PackageMetadataPrefetchingValidationException("Package metadata prefetching validation failed.");
        var testNotifier = new TestCliUpdateNotifier
        {
            NotifyIfUpdateAvailableCallback = () => throw expectedException
        };
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliUpdateNotifierFactory = _ => testNotifier;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("stop");
        var action = Assert.IsAssignableFrom<System.CommandLine.Invocation.AsynchronousCommandLineAction>(result.Action);
 
        var actualException = await Assert.ThrowsAsync<PackageMetadataPrefetchingValidationException>(() => action.InvokeAsync(result, TestContext.Current.CancellationToken));
 
        Assert.Same(expectedException, actualException);
    }
 
    [Fact]
    public async Task BaseCommand_ExecuteValidationFailureEscapesInDebugBuilds()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.NuGetPackageCacheFactory = _ => new FakeNuGetPackageCache();
        });
        using var provider = services.BuildServiceProvider();
        var command = new TemplateMetadataConsumingCommand(
            provider.GetRequiredService<CommonCommandServices>(),
            provider.GetRequiredService<IPackagingService>(),
            workspace.WorkspaceRoot);
        var result = command.Parse("test");
        var action = Assert.IsAssignableFrom<System.CommandLine.Invocation.AsynchronousCommandLineAction>(result.Action);
 
        var exception = await Assert.ThrowsAsync<PackageMetadataPrefetchingValidationException>(() => action.InvokeAsync(result, TestContext.Current.CancellationToken));
 
        Assert.Contains(nameof(BaseCommand.PrefetchesTemplatePackageMetadata), exception.Message);
    }
#endif
 
#if !DEBUG
    [Fact]
    public async Task BaseCommand_UpdateNotificationFailureIsIgnoredInReleaseBuilds()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testNotifier = new TestCliUpdateNotifier
        {
            NotifyIfUpdateAvailableCallback = () => throw new InvalidOperationException("Update notification failed.")
        };
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliUpdateNotifierFactory = _ => testNotifier;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("stop");
 
        _ = await result.InvokeAsync().DefaultTimeout();
 
        Assert.True(testNotifier.NotifyWasCalled);
    }
#endif
 
    [Theory]
    [InlineData("run --format json", false)]
    [InlineData("run", true)]
    [InlineData("docs", false)]
    [InlineData("do --list-steps --format json", false)]
    [InlineData("do --list-steps --format=json", false)]
    [InlineData("do --list-steps --format table", true)]
    public async Task BaseCommand_UpdateNotification_RespectJsonFormat(string args, bool expectNotifyCalled)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testInteractionService = new TestInteractionService();
        var testNotifier = new TestCliUpdateNotifier
        {
            IsUpdateAvailableCallback = () => true,
            NotifyIfUpdateAvailableCallback = () => testInteractionService.DisplayVersionUpdateNotification("13.3.0-preview.1", "aspire update")
        };
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
            options.CliUpdateNotifierFactory = _ => testNotifier;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse(args);
 
        await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(expectNotifyCalled, testNotifier.NotifyWasCalled);
    }
 
    [Fact]
    public async Task BaseCommand_OnFailure_DisplaysLogFilePathOnStderr()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testInteractionService = new TestInteractionService();
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                Task.FromResult(new AppHostProjectSearchResult(null, []))
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
            options.ProjectLocatorFactory = _ => projectLocator;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.NotEqual(CliExitCodes.Success, exitCode);
 
        var executionContext = provider.GetRequiredService<CliExecutionContext>();
        var expectedLogMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.SeeLogsAt, executionContext.LogFilePath);
        var logMessage = Assert.Single(testInteractionService.DisplayedMessages, m => m.Message == expectedLogMessage);
        Assert.Equal(ConsoleOutput.Error, logMessage.ConsoleOverride);
    }
 
    [Fact]
    public async Task BaseCommand_OnFailure_DisplaysAppHostLogFilePathOnStderr()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testInteractionService = new TestInteractionService();
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                Task.FromResult(new AppHostProjectSearchResult(null, []))
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
            options.ProjectLocatorFactory = _ => projectLocator;
            options.CliExecutionContextFactory = _ =>
            {
                var ctx = workspace.CreateExecutionContext();
                ctx.AppHostCliLogFilePath = "/tmp/aspire-logs/apphost.log";
                return ctx;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.NotEqual(CliExitCodes.Success, exitCode);
 
        var executionContext = provider.GetRequiredService<CliExecutionContext>();
        var expectedCliLogMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.SeeLogsAt, executionContext.LogFilePath);
        var expectedAppHostLogMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.SeeAppHostLogsAt, "/tmp/aspire-logs/apphost.log");
 
        var cliLogMessage = Assert.Single(testInteractionService.DisplayedMessages, m => m.Message == expectedCliLogMessage);
        Assert.Equal(ConsoleOutput.Error, cliLogMessage.ConsoleOverride);
 
        var appHostLogMessage = Assert.Single(testInteractionService.DisplayedMessages, m => m.Message == expectedAppHostLogMessage);
        Assert.Equal(ConsoleOutput.Error, appHostLogMessage.ConsoleOverride);
    }
 
    [Fact]
    public async Task BaseCommand_OnCancellationWithErrorExitCode_DisplaysCancellationMessageOnStderr()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testInteractionService = new TestInteractionService();
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                throw new OperationCanceledException()
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
            options.ProjectLocatorFactory = _ => projectLocator;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("add SomePackage");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        // add catches OperationCanceledException and returns CommandResult.Cancelled() (exit code 130)
        Assert.Equal(CliExitCodes.Cancelled, exitCode);
 
        var cancellation = Assert.Single(testInteractionService.DisplayedCancellations);
        Assert.Null(cancellation.Message);
        Assert.Equal(ConsoleOutput.Error, cancellation.ConsoleOverride);
    }
 
    [Fact]
    public async Task BaseCommand_OnCancellationWithSuccessExitCode_DisplaysCancellationMessageOnStdout()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testInteractionService = new TestInteractionService();
        var projectLocator = new TestProjectLocator
        {
            // Throw with the cancellation token so RunCommand's
            // `catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken)`
            // matches and returns CommandResult.Cancelled(CliExitCodes.Success).
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, ct) =>
                throw new OperationCanceledException(ct)
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
            options.ProjectLocatorFactory = _ => projectLocator;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        // run catches OperationCanceledException and returns CommandResult.Cancelled(CliExitCodes.Success)
        var result = command.Parse("run");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var cancellation = Assert.Single(testInteractionService.DisplayedCancellations);
        Assert.Null(cancellation.Message);
        Assert.Null(cancellation.ConsoleOverride);
    }
 
    [Fact]
    public async Task BaseCommand_OnSuccess_DoesNotDisplayLogFilePath()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testInteractionService = new TestInteractionService();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("ps");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        // On success, no log file messages should be displayed
        Assert.DoesNotContain(testInteractionService.DisplayedMessages,
            m => m.ConsoleOverride == ConsoleOutput.Error);
    }
 
    [Fact]
    public async Task BaseCommand_OnUnexpectedException_ReturnsInvalidCommandExitCode_AndDisplaysError()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testInteractionService = new TestInteractionService();
        var backchannelMonitor = new TestAuxiliaryBackchannelMonitor
        {
            ScanAsyncCallback = _ => throw new InvalidOperationException("Something went wrong")
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
            options.AuxiliaryBackchannelMonitorFactory = _ => backchannelMonitor;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("ps");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
 
        // Verify error message was displayed
        var expectedErrorMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, "Something went wrong");
        Assert.Contains(expectedErrorMessage, testInteractionService.DisplayedErrors);
 
        // Verify log file path was displayed on stderr
        var executionContext = provider.GetRequiredService<CliExecutionContext>();
        var expectedLogMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.SeeLogsAt, executionContext.LogFilePath);
        var logMessage = Assert.Single(testInteractionService.DisplayedMessages, m => m.Message == expectedLogMessage);
        Assert.Equal(ConsoleOutput.Error, logMessage.ConsoleOverride);
    }
 
    [Fact]
    public async Task BaseCommand_OnCancellation_DisplaysStoppingMessageAfterDelay()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testInteractionService = new TestInteractionService();
        var handlerEnteredTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var projectLocator = new TestProjectLocator
        {
            // Block inside the handler until cancellation, then wait 500ms before throwing
            // so the 200ms stopping-message timer fires.
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = async (_, _, _, ct) =>
            {
                handlerEnteredTcs.SetResult();
                try
                {
                    await AsyncTestHelpers.WaitForCancellationAsync(ct);
                }
                catch (OperationCanceledException)
                {
                    // Simulate slow cleanup that takes longer than 200ms
                    await Task.Delay(500, CancellationToken.None);
                    throw;
                }
 
                return new AppHostProjectSearchResult(null, []);
            }
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
            options.ProjectLocatorFactory = _ => projectLocator;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        using var cts = new CancellationTokenSource();
        var invokeTask = result.InvokeAsync(cancellationToken: cts.Token);
 
        // Wait for the handler to start, then cancel
        await handlerEnteredTcs.Task.DefaultTimeout();
        await cts.CancelAsync();
 
        await invokeTask.DefaultTimeout();
 
        // The stopping message should have been shown exactly once (by the 200ms timer),
        // and not duplicated by the normal cancellation result path.
        Assert.Single(testInteractionService.DisplayedCancellations);
    }
 
    [Fact]
    public async Task BaseCommand_OnCancellation_DoesNotDisplayStoppingMessageIfHandlerCompletesQuickly()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testInteractionService = new TestInteractionService();
        var projectLocator = new TestProjectLocator
        {
            // Throw OperationCanceledException immediately on cancellation (no delay).
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = async (_, _, _, ct) =>
            {
                await AsyncTestHelpers.WaitForCancellationAsync(ct);
                return new AppHostProjectSearchResult(null, []);
            }
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
            options.ProjectLocatorFactory = _ => projectLocator;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        using var cts = new CancellationTokenSource();
        // Cancel immediately — handler will throw OperationCanceledException right away
        cts.Cancel();
 
        var exitCode = await result.InvokeAsync(cancellationToken: cts.Token).DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        // The cancellation message should still be displayed (from the normal result path)
        // since the handler completed before the 200ms timer.
        Assert.Single(testInteractionService.DisplayedCancellations);
    }
 
    [Theory]
    [InlineData("run --AppHost somepath", "--AppHost", "--apphost")]
    [InlineData("run --Apphost somepath", "--Apphost", "--apphost")]
    [InlineData("run --APPHOST somepath", "--APPHOST", "--apphost")]
    [InlineData("start --AppHost somepath", "--AppHost", "--apphost")]
    [InlineData("run --No-Build", "--No-Build", "--no-build")]
    [InlineData("run --Project somepath", "--Project", "--project")]
    [InlineData("run --Debug", "--Debug", "--debug")]
    [InlineData("run --AppHost=somepath", "--AppHost", "--apphost")]
    [InlineData("run --APPHOST=somepath", "--APPHOST", "--apphost")]
    public async Task BaseCommand_MiscasedOption_ReturnsErrorWithSuggestion(string args, string badOption, string correctOption)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testInteractionService = new TestInteractionService();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse(args);
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
        var expectedError = string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.UnrecognizedOptionDidYouMeanFormat, badOption, correctOption);
        Assert.Single(testInteractionService.DisplayedErrors, expectedError);
    }
 
    [Fact]
    public async Task BaseCommand_CorrectlyCasedOption_DoesNotReturnMiscasedError()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testInteractionService = new TestInteractionService();
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, ct) =>
                throw new OperationCanceledException(ct)
        };
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
            options.ProjectLocatorFactory = _ => projectLocator;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        // --apphost is the correct casing — should not trigger the miscased option check
        var result = command.Parse("run --apphost somepath");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Empty(testInteractionService.DisplayedErrors);
    }
 
    [Theory]
    [InlineData("--apphost", "somepath", false)]
    [InlineData("--AppHost", "somepath", true)]
    [InlineData("--APPHOST", "somepath", true)]
    public async Task BaseCommand_OptionWithEqualsValue_ParsedCorrectlyOrFlaggedAsMiscased(string optionName, string value, bool expectError)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testInteractionService = new TestInteractionService();
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, ct) =>
                throw new OperationCanceledException(ct)
        };
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
            options.ProjectLocatorFactory = _ => projectLocator;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"run {optionName}={value}");
 
        if (expectError)
        {
            var exitCode = await result.InvokeAsync().DefaultTimeout();
 
            Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
            var expectedError = string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.UnrecognizedOptionDidYouMeanFormat, optionName, "--apphost");
            Assert.Single(testInteractionService.DisplayedErrors, expectedError);
        }
        else
        {
            // System.CommandLine accepted the --option=value syntax
            Assert.Empty(result.Errors);
            Assert.Empty(result.UnmatchedTokens);
 
            // The option value was parsed correctly
            var parsedValue = result.GetValue(AppHostLauncher.s_appHostOption);
            Assert.NotNull(parsedValue);
            Assert.Equal(value, parsedValue.Name);
 
            var exitCode = await result.InvokeAsync().DefaultTimeout();
 
            Assert.Empty(testInteractionService.DisplayedErrors);
        }
    }
 
    [Fact]
    public async Task BaseCommand_UnrelatedUnmatchedToken_DoesNotReturnMiscasedError()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testInteractionService = new TestInteractionService();
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, ct) =>
                throw new OperationCanceledException(ct)
        };
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
            options.ProjectLocatorFactory = _ => projectLocator;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        // --custom-arg is a completely unknown option, not a miscased version of a known option
        var result = command.Parse("run --custom-arg value");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Empty(testInteractionService.DisplayedErrors);
    }
 
    [Fact]
    public async Task BaseCommand_MiscasedOptionAfterDoubleDash_IsNotFlagged()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testInteractionService = new TestInteractionService();
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, ct) =>
                throw new OperationCanceledException(ct)
        };
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
            options.ProjectLocatorFactory = _ => projectLocator;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        // --AppHost after "--" is an intentional pass-through argument, not a typo
        var result = command.Parse("run -- --AppHost somepath");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Empty(testInteractionService.DisplayedErrors);
    }
 
    [Fact]
    public async Task BaseCommand_MiscasedOptionBeforeDoubleDash_WithSameTokenAfter_IsFlagged()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testInteractionService = new TestInteractionService();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        // --AppHost before "--" is a typo and should be flagged even though
        // the same token also appears after "--" as a pass-through argument.
        var result = command.Parse("run --AppHost foo -- --AppHost bar");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
        var expectedError = string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.UnrecognizedOptionDidYouMeanFormat, "--AppHost", "--apphost");
        Assert.Single(testInteractionService.DisplayedErrors, expectedError);
    }
 
    [Fact]
    public void BaseCommand_TreatUnmatchedTokensAsErrorsTrue_DoesNotCheckMiscasedOptions()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        // "add" has TreatUnmatchedTokensAsErrors = true (the default), so System.CommandLine
        // handles unrecognized options. The miscased check should not run.
        var result = command.Parse("add --AppHost somepath");
 
        // System.CommandLine itself should report the error.
        Assert.NotEmpty(result.Errors);
    }
 
    [Fact]
    public async Task BaseCommand_ResourceCommand_MiscasedOptionBeforeDoubleDash_IsFlagged()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testInteractionService = new TestInteractionService();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        // --AppHost before "--" collides case-insensitively with the CLI's --apphost option.
        // Users who need to pass an argument named AppHost to a resource command should use
        // the "--" separator to disambiguate.
        var result = command.Parse("resource myresource mycommand --AppHost primary");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
        var expectedError = string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.UnrecognizedOptionDidYouMeanFormat, "--AppHost", "--apphost");
        Assert.Single(testInteractionService.DisplayedErrors, expectedError);
    }
 
#if DEBUG
    private sealed class TemplateMetadataConsumingCommand(
        CommonCommandServices services,
        IPackagingService packagingService,
        DirectoryInfo workingDirectory)
        : BaseCommand("test", "Test command", services)
    {
        protected override async Task<CommandResult> ExecuteAsync(System.CommandLine.ParseResult parseResult, CancellationToken cancellationToken)
        {
            var channel = (await packagingService.GetChannelsAsync(cancellationToken)).First();
            _ = await channel.GetTemplatePackagesAsync(workingDirectory, cancellationToken);
 
            return CommandResult.Success();
        }
    }
#endif
}