File: Commands\RootCommandTests.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 Aspire.Cli.Commands;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.InternalTesting;
 
namespace Aspire.Cli.Tests.Commands;
 
public class RootCommandTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task RootCommandVersionOption_PrintsIdentityVersion_WhenIdentityOverridden()
    {
        // Emulates `ASPIRE_CLI_VERSION=13.4.2` so `--version` must report the identity version,
        // not the assembly's build-time stamp.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => TestExecutionContextHelper.CreateExecutionContext(
                workspace.WorkspaceRoot,
                identityVersion: "13.4.2",
                identityOverridden: true);
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("--version");
 
        var output = new StringWriter();
        var exitCode = await result.InvokeAsync(new System.CommandLine.InvocationConfiguration { Output = output }).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
        Assert.Equal("13.4.2", output.ToString().Trim());
    }
 
    [Fact]
    public async Task RootCommandVersionShortAlias_PrintsIdentityVersion_WhenIdentityOverridden()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => TestExecutionContextHelper.CreateExecutionContext(
                workspace.WorkspaceRoot,
                identityVersion: "13.4.2",
                identityOverridden: true);
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("-v");
 
        var output = new StringWriter();
        var exitCode = await result.InvokeAsync(new System.CommandLine.InvocationConfiguration { Output = output }).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
        Assert.Equal("13.4.2", output.ToString().Trim());
    }
 
    [Fact]
    public async Task RootCommandVersionOption_IncludesCommitSha_WhenCommitProvided()
    {
        // When a commit SHA is provided, it should be included in the version output as "+<sha>".
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => TestExecutionContextHelper.CreateExecutionContext(
                workspace.WorkspaceRoot,
                identityVersion: "13.4.2",
                identityCommit: "abcdef01",
                identityOverridden: true);
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("--version");
 
        var output = new StringWriter();
        var exitCode = await result.InvokeAsync(new System.CommandLine.InvocationConfiguration { Output = output }).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
        Assert.Equal("13.4.2+abcdef01", output.ToString().Trim());
    }
 
    /// <summary>
    /// Theory test validating version output across all build stages/channels:
    /// PR builds, daily builds, staging builds, and stable builds.
    /// Tests various override combinations (env vars, sidecar) to ensure the output
    /// matches the legacy behavior (version + optional +sha).
    /// </summary>
    [Theory]
    [InlineData("pr-18087", "13.5.0-preview.1.26318.5", "abc123def456", "13.5.0-preview.1.26318.5+abc123def456")]
    [InlineData("pr-18087", "13.5.0-preview.1.26318.5", null, "13.5.0-preview.1.26318.5")]
    [InlineData("daily", "13.5.0-preview.1.26318.1", "95f0d296", "13.5.0-preview.1.26318.1+95f0d296")]
    [InlineData("daily", "13.5.0-preview.1.26318.1", null, "13.5.0-preview.1.26318.1")]
    [InlineData("staging", "13.4.0-preview.1.26280.6", "abcdef01", "13.4.0-preview.1.26280.6+abcdef01")]
    [InlineData("staging", "13.4.0", "abcdef01", "13.4.0+abcdef01")]
    [InlineData("stable", "13.4.5", "73114e86c64aeb9f3f3c7da8e37df1ae4281b27e", "13.4.5+73114e86c64aeb9f3f3c7da8e37df1ae4281b27e")]
    [InlineData("local", "13.5.0-dev", "localcommit123", "13.5.0-dev+localcommit123")]
    [InlineData("local", "13.5.0-dev", null, "13.5.0-dev")]
    // IdentityVersion may already carry a "+<sha>" build-metadata suffix (e.g. ASPIRE_CLI_VERSION
    // set to a full informational version). The output must keep a single "+" and never double it.
    [InlineData("stable", "13.4.5+73114e86", "73114e86", "13.4.5+73114e86")]
    [InlineData("stable", "13.4.5+73114e86", null, "13.4.5+73114e86")]
    // When the version carries an embedded "+<sha>" AND an explicit commit is provided, the
    // explicit commit must win and the embedded sha must be discarded (single "+", explicit value).
    [InlineData("stable", "13.4.5+aaaaaaaa", "bbbbbbbb", "13.4.5+bbbbbbbb")]
    public async Task RootCommandVersion_ProducesCorrectOutput_AcrossBuildStages(
        string channel,
        string version,
        string? commit,
        string expectedOutput)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => TestExecutionContextHelper.CreateExecutionContext(
                workspace.WorkspaceRoot,
                identityChannel: channel,
                identityVersion: version,
                identityCommit: commit,
                identityOverridden: true);
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("--version");
 
        var output = new StringWriter();
        var exitCode = await result.InvokeAsync(new System.CommandLine.InvocationConfiguration { Output = output }).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
        Assert.Equal(expectedOutput, output.ToString().Trim());
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task IdentityOverrideNotice_UsesDedicatedNoticePolicyInsteadOfGenericOverrideState(bool noticeRequired)
    {
        // Both rows deliberately keep IdentityOverridden=true. Installed sidecar identity needs
        // the same non-source-tree behavior as explicit emulation, but only the latter should warn.
        // This display-path test catches a tempting regression where startup UI is changed back to
        // checking the broader IdentityOverridden property.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var errorWriter = new StringWriter();
        var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = true };
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.DisableAnsi = true;
            options.ErrorTextWriter = errorWriter;
            options.FirstTimeUseNoticeSentinelFactory = _ => sentinel;
            options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(
                identityChannel: "staging",
                identityVersion: "13.5.0",
                identityOverridden: true,
                identityOverrideNoticeRequired: noticeRequired);
        });
        using var provider = services.BuildServiceProvider();
 
        await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, []);
 
        var errorOutput = errorWriter.ToString();
        if (noticeRequired)
        {
            Assert.Contains(
                "Aspire CLI is emulating identity 'staging' version '13.5.0'",
                errorOutput,
                StringComparison.Ordinal);
        }
        else
        {
            Assert.Equal(string.Empty, errorOutput);
        }
    }
 
    [Fact]
    public async Task RootCommandWithHelpArgumentReturnsZero()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("--help");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RootCommandWithNoLogoArgumentReturnsZero()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("--nologo --help");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task CaptureProfileOptions_AreHiddenFromHelp()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.OutputTextWriter = outputWriter;
            options.DisableAnsi = true;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("--help");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(0, exitCode);
        var help = string.Join(Environment.NewLine, outputWriter.Logs);
        Assert.DoesNotContain("--capture-profile", help, StringComparison.Ordinal);
        Assert.DoesNotContain("--capture-profile-output", help, StringComparison.Ordinal);
        Assert.DoesNotContain("--capture-profile-delay", help, StringComparison.Ordinal);
        Assert.DoesNotContain("--log-file", help, StringComparison.Ordinal);
    }
 
    [Fact]
    public void CaptureProfileOptions_ParseOnSubcommands()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run --capture-profile --capture-profile-output profile.zip --capture-profile-delay 1");
 
        Assert.Empty(result.Errors);
        Assert.True(result.GetValue(RootCommand.CaptureProfileOption));
        Assert.Equal("profile.zip", result.GetValue(RootCommand.CaptureProfileOutputOption)?.Name);
        Assert.Equal(1, result.GetValue(RootCommand.CaptureProfileDelayOption));
        Assert.DoesNotContain("--capture-profile", result.UnmatchedTokens);
    }
 
    [Fact]
    public void InternalLogFileOption_IsConsumedOnSubcommands()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse(
        [
            "run",
            "--log-file", "child.log",
            "--launch-profile=E2E",
            "--",
            "--from-profile", "value"
        ]);
 
        Assert.Empty(result.Errors);
        Assert.Equal("E2E", result.GetValue(AppHostLauncher.s_launchProfileOption));
        Assert.Equal(["--from-profile", "value"], result.UnmatchedTokens);
    }
 
    [Fact]
    public void StartDebugSessionOption_IsOnlyAddedInExtensionContext()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var normalServices = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var normalProvider = normalServices.BuildServiceProvider();
 
        var normalCommand = normalProvider.GetRequiredService<RootCommand>();
        Assert.DoesNotContain(normalCommand.Options, option => ReferenceEquals(option, RootCommand.StartDebugSessionOption));
 
        var extensionServices = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel();
            options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp);
        });
        using var extensionProvider = extensionServices.BuildServiceProvider();
 
        var extensionCommand = extensionProvider.GetRequiredService<RootCommand>();
        Assert.Contains(extensionCommand.Options, option => ReferenceEquals(option, RootCommand.StartDebugSessionOption));
    }
 
    [Theory]
    [InlineData("1", true)]
    [InlineData("true", true)]
    [InlineData("TRUE", true)]
    [InlineData("True", true)]
    [InlineData("0", false)]
    [InlineData("false", false)]
    [InlineData("", false)]
    [InlineData(null, false)]
    public async Task NoLogoEnvironmentVariable_ParsedCorrectly(string? value, bool expectedNoLogo)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ConfigurationCallback = config =>
            {
                config[CliConfigNames.NoLogo] = value;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var configuration = provider.GetRequiredService<IConfiguration>();
        var isNoLogo = configuration.GetBool(CliConfigNames.NoLogo, defaultValue: false);
 
        Assert.Equal(expectedNoLogo, isNoLogo);
 
        // Also verify command still works with the configuration
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("--help");
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task FirstTimeUseNotice_BannerDisplayedWhenSentinelDoesNotExist()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var errorWriter = new StringWriter();
        var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = false };
        var bannerService = new TestBannerService();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ErrorTextWriter = errorWriter;
            options.FirstTimeUseNoticeSentinelFactory = _ => sentinel;
            options.BannerServiceFactory = _ => bannerService;
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
        });
        using var provider = services.BuildServiceProvider();
 
        await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, []);
 
        Assert.True(bannerService.WasBannerDisplayed);
        Assert.True(sentinel.WasCreated);
    }
 
    [Fact]
    public async Task FirstTimeUseNotice_BannerNotDisplayedWhenSentinelExists()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var errorWriter = new StringWriter();
        var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = true };
        var bannerService = new TestBannerService();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ErrorTextWriter = errorWriter;
            options.FirstTimeUseNoticeSentinelFactory = _ => sentinel;
            options.BannerServiceFactory = _ => bannerService;
        });
        using var provider = services.BuildServiceProvider();
 
        await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, []);
 
        Assert.False(bannerService.WasBannerDisplayed);
        Assert.False(sentinel.WasCreated);
    }
 
    [Fact]
    public async Task FirstTimeUseNotice_BannerNotDisplayedWithNoLogoArgument()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var errorWriter = new StringWriter();
        var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = false };
        var bannerService = new TestBannerService();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ErrorTextWriter = errorWriter;
            options.FirstTimeUseNoticeSentinelFactory = _ => sentinel;
            options.BannerServiceFactory = _ => bannerService;
        });
        using var provider = services.BuildServiceProvider();
 
        await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, [CommonOptionNames.NoLogo]);
 
        Assert.False(bannerService.WasBannerDisplayed);
        Assert.True(sentinel.WasCreated);
    }
 
    [Fact]
    public async Task FirstTimeUseNotice_BannerNotDisplayedWithNoLogoEnvironmentVariable()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var errorWriter = new StringWriter();
        var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = false };
        var bannerService = new TestBannerService();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ErrorTextWriter = errorWriter;
            options.FirstTimeUseNoticeSentinelFactory = _ => sentinel;
            options.BannerServiceFactory = _ => bannerService;
            options.ConfigurationCallback = config =>
            {
                config[CliConfigNames.NoLogo] = "1";
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var configuration = provider.GetRequiredService<IConfiguration>();
        var noLogo = configuration.GetBool(CliConfigNames.NoLogo, defaultValue: false);
 
        await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, []);
 
        Assert.False(bannerService.WasBannerDisplayed);
        Assert.True(sentinel.WasCreated);
    }
 
    [Fact]
    public async Task Banner_DisplayedWhenExplicitlyRequested()
    {
        // When --banner is passed, banner should show even if not first run
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var errorWriter = new StringWriter();
        var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = true }; // Not first run
        var bannerService = new TestBannerService();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ErrorTextWriter = errorWriter;
            options.FirstTimeUseNoticeSentinelFactory = _ => sentinel;
            options.BannerServiceFactory = _ => bannerService;
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
        });
        using var provider = services.BuildServiceProvider();
 
        await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, [CommonOptionNames.Banner]);
 
        Assert.True(bannerService.WasBannerDisplayed);
        // Telemetry notice should NOT be shown since it's not first run
        var errorOutput = errorWriter.ToString();
        Assert.DoesNotContain("Telemetry", errorOutput);
    }
 
    [Fact]
    public async Task Banner_CanBeInvokedMultipleTimes()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var bannerService = new TestBannerService();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.BannerServiceFactory = _ => bannerService;
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
        });
        using var provider = services.BuildServiceProvider();
 
        // Invoke multiple times (simulating multiple --banner calls)
        await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, [CommonOptionNames.Banner]);
        await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, [CommonOptionNames.Banner]);
        await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, [CommonOptionNames.Banner]);
 
        Assert.Equal(3, bannerService.DisplayCount);
    }
 
    [Fact]
    public void BannerOption_HasCorrectDescription()
    {
        Assert.Equal("--banner", RootCommand.BannerOption.Name);
        Assert.NotNull(RootCommand.BannerOption.Description);
        Assert.NotEmpty(RootCommand.BannerOption.Description);
    }
 
    [Fact]
    public async Task Banner_DisplayedOnFirstRunAndExplicitRequest()
    {
        // When it's a first run AND user explicitly requests --banner,
        // the banner should be shown (only once via the explicit request logic)
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var errorWriter = new StringWriter();
        var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = false };
        var bannerService = new TestBannerService();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ErrorTextWriter = errorWriter;
            options.FirstTimeUseNoticeSentinelFactory = _ => sentinel;
            options.BannerServiceFactory = _ => bannerService;
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
        });
        using var provider = services.BuildServiceProvider();
 
        await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, [CommonOptionNames.Banner]);
 
        Assert.True(bannerService.WasBannerDisplayed);
        Assert.True(sentinel.WasCreated);
        // Telemetry notice should be shown on first run
        var errorOutput = errorWriter.ToString();
        Assert.Contains("Telemetry", errorOutput);
    }
 
    [Fact]
    public async Task Banner_TelemetryNoticeShownOnFirstRun()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var errorWriter = new StringWriter();
        var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = false };
        var bannerService = new TestBannerService();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ErrorTextWriter = errorWriter;
            options.FirstTimeUseNoticeSentinelFactory = _ => sentinel;
            options.BannerServiceFactory = _ => bannerService;
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
        });
        using var provider = services.BuildServiceProvider();
 
        await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, []);
 
        var errorOutput = errorWriter.ToString();
        Assert.Contains("Telemetry", errorOutput);
    }
 
    [Fact]
    public async Task Banner_TelemetryNoticeNotShownOnSubsequentRuns()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var errorWriter = new StringWriter();
        var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = true }; // Not first run
        var bannerService = new TestBannerService();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ErrorTextWriter = errorWriter;
            options.FirstTimeUseNoticeSentinelFactory = _ => sentinel;
            options.BannerServiceFactory = _ => bannerService;
        });
        using var provider = services.BuildServiceProvider();
 
        await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, []);
 
        var errorOutput = errorWriter.ToString();
        Assert.DoesNotContain("Telemetry", errorOutput);
    }
 
    [Theory]
    [InlineData("--version")]
    [InlineData("--help")]
    [InlineData("-h")]
    [InlineData("-?")]
    public async Task InformationalFlag_SuppressesBannerAndDoesNotCreateSentinel(string flag)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = false };
        var bannerService = new TestBannerService();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.FirstTimeUseNoticeSentinelFactory = _ => sentinel;
            options.BannerServiceFactory = _ => bannerService;
        });
        using var provider = services.BuildServiceProvider();
 
        await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, [flag]);
 
        // Informational flags set noLogo, which suppresses banner and telemetry notice
        Assert.False(bannerService.WasBannerDisplayed);
        // Sentinel should NOT be created for informational commands
        Assert.False(sentinel.WasCreated);
    }
 
    [Fact]
    public async Task InformationalFlag_DoesNotCreateSentinel_OnSubsequentFirstRun()
    {
        // Verifies that running --version on first run doesn't mark first-run as complete,
        // so a subsequent normal invocation still shows the first-run experience.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = false };
        var bannerService = new TestBannerService();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.FirstTimeUseNoticeSentinelFactory = _ => sentinel;
            options.BannerServiceFactory = _ => bannerService;
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
        });
        using var provider = services.BuildServiceProvider();
 
        // First invocation with --version: should not create sentinel
        await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, ["--version"]);
        Assert.False(sentinel.WasCreated);
 
        // Second invocation without informational flag: should create sentinel and show banner
        await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, []);
        Assert.True(bannerService.WasBannerDisplayed);
        Assert.True(sentinel.WasCreated);
    }
 
    [Theory]
    [InlineData("ps", "--format", "json")]
    [InlineData("ps", "--format=json")]
    [InlineData("config", "info", "--json")]
    [InlineData("resource", "database", "reset-password", "--load-arguments")]
    [InlineData("extension", "get-apphosts")]
    public async Task MachineReadableCommand_SuppressesBannerAndDoesNotCreateSentinel(params string[] args)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = false };
        var bannerService = new TestBannerService();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.FirstTimeUseNoticeSentinelFactory = _ => sentinel;
            options.BannerServiceFactory = _ => bannerService;
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
        });
        using var provider = services.BuildServiceProvider();
 
        await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, args);
 
        Assert.False(bannerService.WasBannerDisplayed);
        Assert.False(sentinel.WasCreated);
    }
 
    [Theory]
    [InlineData("--json")]
    [InlineData("--nologo")]
    [InlineData("--help")]
    public async Task ArgumentsAfterDelimiter_DoNotSuppressFirstRunNotice(string appArg)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = false };
        var bannerService = new TestBannerService();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.FirstTimeUseNoticeSentinelFactory = _ => sentinel;
            options.BannerServiceFactory = _ => bannerService;
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
        });
        using var provider = services.BuildServiceProvider();
 
        await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, ["run", "--", appArg]);
 
        Assert.True(bannerService.WasBannerDisplayed);
        Assert.True(sentinel.WasCreated);
    }
 
    [Fact]
    public async Task BannerArgumentAfterDelimiter_DoesNotForceBanner()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = true };
        var bannerService = new TestBannerService();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.FirstTimeUseNoticeSentinelFactory = _ => sentinel;
            options.BannerServiceFactory = _ => bannerService;
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
        });
        using var provider = services.BuildServiceProvider();
 
        await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, ["run", "--", "--banner"]);
 
        Assert.False(bannerService.WasBannerDisplayed);
        Assert.False(sentinel.WasCreated);
    }
 
    [Fact]
    public async Task FirstTimeUseNotice_BannerNotDisplayedInNonInteractiveEnvironment()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = false };
        var bannerService = new TestBannerService();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.FirstTimeUseNoticeSentinelFactory = _ => sentinel;
            options.BannerServiceFactory = _ => bannerService;
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
        });
        using var provider = services.BuildServiceProvider();
 
        await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, []);
 
        Assert.False(bannerService.WasBannerDisplayed);
        Assert.True(sentinel.WasCreated);
    }
 
    [Fact]
    public async Task Banner_NotDisplayedWithExplicitBannerFlag_InNonInteractiveEnvironment()
    {
        // Even when --banner is explicitly passed, the banner should NOT display in a
        // non-interactive environment because Spectre.Console's Live display requires
        // valid console handles (e.g., stdout must not be redirected).
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = true }; // Not first run
        var bannerService = new TestBannerService();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.FirstTimeUseNoticeSentinelFactory = _ => sentinel;
            options.BannerServiceFactory = _ => bannerService;
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
        });
        using var provider = services.BuildServiceProvider();
 
        await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, [CommonOptionNames.Banner]);
 
        Assert.False(bannerService.WasBannerDisplayed);
    }
 
    [Fact]
    public void SetupCommand_NotAvailable_WhenBundleIsNotAvailable()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var hasSetupCommand = command.Subcommands.Any(cmd => cmd.Name == "setup");
 
        Assert.False(hasSetupCommand);
    }
 
    [Fact]
    public void SetupCommand_Available_WhenBundleIsAvailable()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.BundleServiceFactory = _ => new TestBundleService(isBundle: true);
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var hasSetupCommand = command.Subcommands.Any(cmd => cmd.Name == "setup");
 
        Assert.True(hasSetupCommand);
    }
 
    [Fact]
    public void AllVisibleCommands_HaveHelpGroup()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.BundleServiceFactory = _ => new TestBundleService(isBundle: true);
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
 
        // Either the subcommand isn't a BaseCommand, or it hasn't specified a HelpGroup.
        var missingGroup = command.Subcommands
            .Where(sub => !sub.Hidden)
            .Where(cmd => cmd is not BaseCommand baseCmd || baseCmd.HelpGroup is HelpGroup.None)
            .Select(cmd => cmd.Name)
            .ToList();
 
        Assert.True(missingGroup.Count == 0,
            $"The following visible commands are missing a HelpGroup: {string.Join(", ", missingGroup)}. " +
            "Add 'internal override HelpGroup HelpGroup => HelpGroup.XXX;' to each command class.");
    }
 
    [Fact]
    public void GroupedHelp_ContainsAllVisibleCommands()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.BundleServiceFactory = _ => new TestBundleService(isBundle: true);
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var helpWriter = new StringWriter();
        GroupedHelpWriter.WriteHelp(command, helpWriter, 120);
 
        var helpOutput = helpWriter.ToString();
        var visibleCommands = command.Subcommands.Where(sub => !sub.Hidden).ToList();
 
        foreach (var sub in visibleCommands)
        {
            Assert.Contains(sub.Name, helpOutput);
        }
    }
 
    [Fact]
    public void RootCommand_DoesNotExposeRemovedExecSubcommand()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
 
        Assert.DoesNotContain(command.Subcommands, subcommand => subcommand.Name == "exec");
    }
}