File: Commands\TrayCommandTests.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 System.Runtime.InteropServices;
using Aspire.Cli.Acquisition;
using Aspire.Cli.Bundles;
using Aspire.Cli.Commands;
using Aspire.Cli.Layout;
using Aspire.Cli.Resources;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Shared;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Time.Testing;
 
namespace Aspire.Cli.Tests.Commands;
 
public class TrayCommandTests(ITestOutputHelper outputHelper)
{
    public static bool SupportsSymlinks => !OperatingSystem.IsWindows();
 
    [Theory]
    [InlineData("script", true)]
    [InlineData("pr", true)]
    [InlineData("localhive", true)]
    [InlineData("dotnet-tool", false)]
    [InlineData("npm", false)]
    [InlineData("brew", false)]
    [InlineData("winget", false)]
    [InlineData("nix", false)]
    [InlineData("unknown", false)]
    [InlineData(null, false)]
    public async Task StartupEntryRequiresVerifiedStableInstallationMetadata(string? source, bool expectedStartup)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (services, bundle, factory, _, cliPath) = CreateServices(workspace);
        var sidecar = Path.Combine(Path.GetDirectoryName(cliPath)!, InstallSidecarReader.SidecarFileName);
        if (source is null)
        {
            File.Delete(sidecar);
        }
        else
        {
            File.WriteAllText(sidecar, $$"""{"source":"{{source}}"}""");
        }
        using var provider = services.BuildServiceProvider();
 
        Assert.Equal(CliExitCodes.Success, await provider.GetRequiredService<RootCommand>()
            .Parse("tray start --non-interactive --nologo").InvokeAsync().DefaultTimeout());
        Assert.NotNull(factory.LastArguments);
        Assert.Equal(expectedStartup
            ? ["start", "--cli", cliPath, "--bundle-root", bundle.Layout!.LayoutPath!, "--startup-cli", cliPath]
            : ["start", "--cli", cliPath, "--bundle-root", bundle.Layout!.LayoutPath!], factory.LastArguments);
    }
 
    [Theory]
    [InlineData("node_modules")]
    [InlineData(".store")]
    public async Task VersionedPackageStoresDoNotBecomeStartupEntriesEvenWithPortableMetadata(string store)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (services, bundle, factory, _, cliPath) = CreateServices(workspace);
        var packageBin = Path.Combine(workspace.WorkspaceRoot.FullName, store, "aspire", "1.0.0", "bin");
        Directory.CreateDirectory(packageBin);
        var packagedCli = Path.Combine(packageBin, "aspire");
        File.Copy(cliPath, packagedCli);
        File.WriteAllText(Path.Combine(packageBin, InstallSidecarReader.SidecarFileName), """{"source":"script"}""");
        services.AddSingleton<Aspire.Cli.Utils.IProcessPathProvider>(new TestProcessPathProvider(packagedCli) { IsNativeAot = true });
        using var provider = services.BuildServiceProvider();
 
        Assert.Equal(CliExitCodes.Success, await provider.GetRequiredService<RootCommand>()
            .Parse("tray start --non-interactive --nologo").InvokeAsync().DefaultTimeout());
        Assert.NotNull(factory.LastArguments);
        Assert.Equal(["start", "--cli", packagedCli, "--bundle-root", bundle.Layout!.LayoutPath!], factory.LastArguments);
    }
 
    [Fact]
    public async Task MalformedInstallationMetadataDoesNotPreventManualTrayStart()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (services, bundle, factory, _, cliPath) = CreateServices(workspace);
        File.WriteAllText(Path.Combine(Path.GetDirectoryName(cliPath)!, InstallSidecarReader.SidecarFileName), "{");
        using var provider = services.BuildServiceProvider();
 
        Assert.Equal(CliExitCodes.Success, await provider.GetRequiredService<RootCommand>()
            .Parse("tray start --non-interactive --nologo").InvokeAsync().DefaultTimeout());
        Assert.NotNull(factory.LastArguments);
        Assert.Equal(["start", "--cli", cliPath, "--bundle-root", bundle.Layout!.LayoutPath!], factory.LastArguments);
    }
 
    [Fact(Skip = "Creating installation symlinks does not require elevation on Unix.", SkipUnless = nameof(SupportsSymlinks))]
    public async Task StartupUsesTheInvokingInstallationSymlinkWhileBackendUsesResolvedExecutable()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (services, bundle, factory, _, cliPath) = CreateServices(workspace);
        var installed = Path.Combine(workspace.WorkspaceRoot.FullName, "stable & installed aspire");
        File.CreateSymbolicLink(installed, cliPath);
        services.AddSingleton<Aspire.Cli.Utils.IProcessPathProvider>(new TestProcessPathProvider(installed) { IsNativeAot = true });
        using var provider = services.BuildServiceProvider();
 
        Assert.Equal(CliExitCodes.Success, await provider.GetRequiredService<RootCommand>()
            .Parse("tray start --non-interactive --nologo").InvokeAsync().DefaultTimeout());
        Assert.NotNull(factory.LastArguments);
        Assert.Equal(["start", "--cli", cliPath, "--bundle-root", bundle.Layout!.LayoutPath!, "--startup-cli", installed],
            factory.LastArguments);
    }
 
    [Fact]
    public void ManagedBuildIsNotClassifiedAsNative()
    {
        Assert.False(new Aspire.Cli.Utils.EnvironmentProcessPathProvider().IsNativeAot);
    }
 
    [Theory]
    [InlineData("start", false)]
    [InlineData("stop", false)]
    [InlineData("start", true)]
    [InlineData("stop", true)]
    public async Task HelperArgumentsAndLeaseHandoffAreBoundToInvokingBundle(string action, bool windows)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (services, bundle, factory, interaction, cliPath) = CreateServices(workspace);
        if (windows)
        {
            ConfigureWindows(services, bundle);
        }
        var layout = bundle.Layout!;
        var root = layout.LayoutPath!;
        var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var acknowledge = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        BundleVersionLease? companionLease = null;
        factory.AsyncAttemptCallback = async (_, _, token) =>
        {
            Assert.True(BundleVersionLease.HasActiveLease(root));
            started.SetResult();
            await acknowledge.Task.WaitAsync(token);
            if (action == "start")
            {
                companionLease = BundleVersionLease.Acquire(root, "tray", "gui");
            }
            return (0, null);
        };
        using var provider = services.BuildServiceProvider();
        try
        {
            var invocation = provider.GetRequiredService<RootCommand>()
                .Parse($"tray {action} --non-interactive --nologo").InvokeAsync();
            await started.Task.DefaultTimeout();
 
            Assert.False(invocation.IsCompleted);
            Assert.Empty(interaction.DisplayedSuccess);
            Assert.True(BundleVersionLease.HasActiveLease(root));
            Assert.Equal(layout.GetTrayPath(), factory.LastFileName);
            Assert.Equal(action == "start" ? ["start", "--cli", cliPath, "--bundle-root", root, "--startup-cli", cliPath] : ["stop"], factory.LastArguments);
            Assert.Equal(root, factory.LastEnvironmentVariables![BundleDiscovery.BundleVersionDirectoryEnvVar]);
            Assert.Equal(root, factory.LastWorkingDirectory!.FullName);
            Assert.False(factory.LastProcessInvocationOptions!.Detached);
            Assert.False(factory.LastProcessInvocationOptions.KillOnParentExit);
            Assert.Equal("cli", bundle.LastHolderKind);
            Assert.Equal($"tray {action}", bundle.LastCommandName);
 
            acknowledge.SetResult();
            Assert.Equal(CliExitCodes.Success, await invocation.DefaultTimeout());
            Assert.Equal(action == "start" ? TrayCommandStrings.Started : TrayCommandStrings.Stopped, Assert.Single(interaction.DisplayedSuccess));
            Assert.Empty(interaction.DisplayedErrors);
            Assert.Equal(1, Assert.IsType<TestProcessExecution>(Assert.Single(factory.CreatedExecutions)).DisposeCount);
            Assert.Equal(action == "start", BundleVersionLease.HasActiveLease(root));
            Assert.Equal(action == "start" ? 1 : 0, Directory.Exists(Path.Combine(root, ".leases"))
                ? Directory.GetFiles(Path.Combine(root, ".leases"), "*.lease").Length : 0);
        }
        finally
        {
            companionLease?.Dispose();
        }
        Assert.False(BundleVersionLease.HasActiveLease(root));
    }
 
    [Fact]
    public async Task RepeatedStartUsesSameInvokingCliAndAcknowledgedHelper()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (services, bundle, factory, _, cliPath) = CreateServices(workspace);
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
 
        for (var i = 0; i < 2; i++)
        {
            Assert.Equal(CliExitCodes.Success, await command.Parse("tray start --non-interactive --nologo").InvokeAsync().DefaultTimeout());
            Assert.Equal(["start", "--cli", cliPath, "--bundle-root", bundle.Layout!.LayoutPath!, "--startup-cli", cliPath], factory.LastArguments!);
            Assert.False(BundleVersionLease.HasActiveLease(bundle.Layout.LayoutPath!));
        }
        Assert.Equal(2, factory.AttemptCount);
        Assert.Equal(2, bundle.AcquireLayoutCallCount);
    }
 
    [Theory]
    [InlineData("linux", true)]
    [InlineData("macos", false)]
    [InlineData("windows", false)]
    public void TrayCommandIsHiddenOnUnsupportedPlatforms(string platform, bool expectedHidden)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (services, _, _, _, _) = CreateServices(workspace);
        services.AddSingleton<IEnvironment>(platform switch
        {
            "linux" => TestEnvironment.CreateLinux(),
            "macos" => TestEnvironment.CreateMacOS(),
            _ => TestEnvironment.CreateWindows()
        });
        using var provider = services.BuildServiceProvider();
 
        Assert.Equal(expectedHidden, provider.GetRequiredService<TrayCommand>().Hidden);
    }
 
    [Theory]
    [InlineData("start")]
    [InlineData("stop")]
    public async Task UnsupportedPlatformDoesNotAcquireBundleOrLaunchHelper(string action)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (services, bundle, factory, interaction, _) = CreateServices(workspace);
        services.AddSingleton<IEnvironment>(TestEnvironment.CreateLinux());
        using var provider = services.BuildServiceProvider();
 
        Assert.Equal(CliExitCodes.InvalidCommand, await provider.GetRequiredService<RootCommand>()
            .Parse($"tray {action}").InvokeAsync().DefaultTimeout());
 
        Assert.Equal(TrayCommandStrings.UnsupportedPlatform, Assert.Single(interaction.DisplayedErrors));
        Assert.Equal(0, bundle.AcquireLayoutCallCount);
        Assert.Empty(factory.CreatedExecutions);
    }
 
    [Theory]
    [InlineData("dotnet")]
    [InlineData("apphost")]
    [InlineData("missing")]
    [InlineData("relative")]
    [InlineData("null")]
    public async Task StartRejectsManagedOrUnavailableInvokingExecutable(string scenario)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (services, bundle, factory, interaction, cliPath) = CreateServices(workspace);
        var path = scenario switch
        {
            "dotnet" => Path.Combine(workspace.WorkspaceRoot.FullName, "dotnet"),
            "missing" => Path.Combine(workspace.WorkspaceRoot.FullName, "missing-aspire"),
            "relative" => "aspire",
            "null" => null,
            _ => cliPath
        };
        if (scenario == "dotnet")
        {
            File.WriteAllText(path!, "fake dotnet");
        }
        services.AddSingleton<Aspire.Cli.Utils.IProcessPathProvider>(
            new TestProcessPathProvider(path) { IsNativeAot = scenario is not ("dotnet" or "apphost") });
        using var provider = services.BuildServiceProvider();
 
        Assert.Equal(CliExitCodes.InvalidCommand, await provider.GetRequiredService<RootCommand>()
            .Parse("tray start").InvokeAsync().DefaultTimeout());
 
        Assert.Equal(TrayCommandStrings.NativeCliRequired, Assert.Single(interaction.DisplayedErrors));
        Assert.Equal(0, bundle.AcquireLayoutCallCount);
        Assert.Empty(factory.CreatedExecutions);
    }
 
    [Theory]
    [InlineData("start", "layout")]
    [InlineData("stop", "layout")]
    [InlineData("start", "lease")]
    [InlineData("stop", "lease")]
    [InlineData("start", "component")]
    [InlineData("stop", "component")]
    [InlineData("start", "payload")]
    [InlineData("stop", "payload")]
    public async Task MissingBundleOrPayloadFailsWithoutFallback(string action, string missing)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (services, bundle, factory, interaction, _) = CreateServices(workspace);
        var root = bundle.Layout!.LayoutPath!;
        switch (missing)
        {
            case "layout":
                bundle.Layout = null;
                bundle.CreateLayoutLease = null;
                break;
            case "lease":
                bundle.CreateLayoutLease = null;
                break;
            case "component":
                bundle.Layout.Components.Tray = null;
                break;
            case "payload":
                File.Delete(bundle.Layout.GetTrayPath()!);
                break;
        }
        using var provider = services.BuildServiceProvider();
 
        Assert.Equal(CliExitCodes.InvalidCommand, await provider.GetRequiredService<RootCommand>()
            .Parse($"tray {action}").InvokeAsync().DefaultTimeout());
 
        Assert.Equal(missing is "layout" or "lease" ? TrayCommandStrings.BundleRequired : TrayCommandStrings.PayloadMissing,
            Assert.Single(interaction.DisplayedErrors));
        Assert.Empty(factory.CreatedExecutions);
        Assert.False(BundleVersionLease.HasActiveLease(root));
    }
 
    [Theory]
    [InlineData("start", null)]
    [InlineData("stop", null)]
    [InlineData("start", "")]
    [InlineData("stop", "")]
    [InlineData("start", "  ")]
    [InlineData("stop", "  ")]
    [InlineData("start", "Tray readiness timed out. See logs at /bundle/tray.log. Stop an older Aspire tray and retry.")]
    [InlineData("stop", "The running tray uses an older control protocol. Quit it from the menu bar.")]
    public async Task HelperFailureShowsDiagnosticsPreservesExitCodeAndReleasesLease(string action, string? error)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (services, bundle, factory, interaction, _) = CreateServices(workspace);
        factory.AttemptCallback = (_, options) =>
        {
            if (error is not null)
            {
                options.StandardErrorCallback?.Invoke(error);
            }
            return (42, null);
        };
        using var provider = services.BuildServiceProvider();
 
        Assert.Equal(42, await provider.GetRequiredService<RootCommand>()
            .Parse($"tray {action}").InvokeAsync().DefaultTimeout());
 
        var expectedMessage = string.IsNullOrWhiteSpace(error)
            ? string.Format(CultureInfo.CurrentCulture, TrayCommandStrings.HelperFailed, action, 42)
            : string.Format(CultureInfo.CurrentCulture, TrayCommandStrings.OperationFailed, action, error.Trim());
        Assert.Equal(expectedMessage, Assert.Single(interaction.DisplayedErrors));
        Assert.Empty(interaction.DisplayedSuccess);
        Assert.False(BundleVersionLease.HasActiveLease(bundle.Layout!.LayoutPath!));
    }
 
    [Theory]
    [InlineData("start", true)]
    [InlineData("stop", true)]
    [InlineData("start", false)]
    [InlineData("stop", false)]
    public async Task ExtractionOrProcessStartFailureDoesNotLeakLease(string action, bool extraction)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (services, bundle, factory, interaction, _) = CreateServices(workspace);
        if (extraction)
        {
            bundle.EnsureExtractedException = new IOException("Extraction failed.");
        }
        else
        {
            factory.CreateExecutionCallback = (_, _, _, _) => throw new IOException("Helper launch failed.");
        }
        using var provider = services.BuildServiceProvider();
 
        Assert.Equal(CliExitCodes.InvalidCommand, await provider.GetRequiredService<RootCommand>()
            .Parse($"tray {action}").InvokeAsync().DefaultTimeout());
 
        Assert.Single(interaction.DisplayedErrors);
        Assert.Empty(interaction.DisplayedSuccess);
        Assert.False(BundleVersionLease.HasActiveLease(bundle.Layout!.LayoutPath!));
        Assert.Equal(extraction ? 0 : 1, factory.AttemptCount);
    }
 
    [Theory]
    [InlineData("start", true)]
    [InlineData("stop", true)]
    [InlineData("stop", false)]
    public async Task TimeoutAndCancellationObserveHelperBeforeReleasingLease(string action, bool timeout)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (services, bundle, factory, interaction, _) = CreateServices(workspace);
        var time = new FakeTimeProvider();
        services.AddSingleton<TimeProvider>(time);
        using var cancellation = new CancellationTokenSource();
        var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var helperObserved = false;
        var root = bundle.Layout!.LayoutPath!;
        factory.AsyncAttemptCallback = async (_, _, token) =>
        {
            started.SetResult();
            try
            {
                await Task.Delay(Timeout.InfiniteTimeSpan, token);
                return (0, null);
            }
            finally
            {
                Assert.True(BundleVersionLease.HasActiveLease(root));
                helperObserved = true;
            }
        };
        using var provider = services.BuildServiceProvider();
        var invocation = provider.GetRequiredService<RootCommand>()
            .Parse($"tray {action}").InvokeAsync(cancellationToken: cancellation.Token);
        await started.Task.DefaultTimeout();
        if (timeout)
        {
            if (action == "start")
            {
                cancellation.Cancel();
            }
            time.Advance(TrayLifecycleService.HelperTimeout);
        }
        else
        {
            cancellation.Cancel();
        }
 
        Assert.Equal(timeout ? CliExitCodes.WaitTimeout : CliExitCodes.Cancelled, await invocation.DefaultTimeout());
        Assert.True(helperObserved);
        Assert.False(BundleVersionLease.HasActiveLease(root));
        Assert.Equal(1, Assert.IsType<TestProcessExecution>(Assert.Single(factory.CreatedExecutions)).DisposeCount);
        Assert.Empty(interaction.DisplayedSuccess);
        if (timeout)
        {
            Assert.Equal(string.Format(CultureInfo.CurrentCulture, TrayCommandStrings.HelperTimedOut, action), Assert.Single(interaction.DisplayedErrors));
        }
    }
 
    [Theory]
    [InlineData(0)]
    [InlineData(42)]
    public async Task StartFinishesLeaseHandoffAfterFirstTerminationSignal(int helperExitCode)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (services, bundle, factory, interaction, _) = CreateServices(workspace);
        var started = new TaskCompletionSource<CancellationToken>(TaskCreationOptions.RunContinuationsAsynchronously);
        var acknowledge = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var root = bundle.Layout!.LayoutPath!;
        BundleVersionLease? companionLease = null;
        factory.AsyncAttemptCallback = async (_, _, token) =>
        {
            started.SetResult(token);
            await acknowledge.Task.WaitAsync(token);
            Assert.True(BundleVersionLease.HasActiveLease(root));
            if (helperExitCode == 0)
            {
                companionLease = BundleVersionLease.Acquire(root, "tray", "gui");
            }
            return (helperExitCode, null);
        };
        using var provider = services.BuildServiceProvider();
        var cancellation = provider.GetRequiredService<ConsoleCancellationManager>();
        try
        {
            var invocation = provider.GetRequiredService<RootCommand>()
                .Parse("tray start").InvokeAsync(cancellationToken: cancellation.Token);
            var helperToken = await started.Task.DefaultTimeout();
            cancellation.Cancel(CliExitCodes.Cancelled);
 
            Assert.True(cancellation.IsEnabled);
            Assert.False(cancellation.GracefulShutdownToken.IsCancellationRequested);
            Assert.False(helperToken.IsCancellationRequested);
            Assert.False(invocation.IsCompleted);
            Assert.True(BundleVersionLease.HasActiveLease(root));
            Assert.Empty(interaction.DisplayedSuccess);
 
            acknowledge.SetResult();
            Assert.Equal(helperExitCode, await invocation.DefaultTimeout());
            Assert.Equal(helperExitCode == 0, BundleVersionLease.HasActiveLease(root));
            Assert.Equal(1, Assert.IsType<TestProcessExecution>(Assert.Single(factory.CreatedExecutions)).DisposeCount);
        }
        finally
        {
            companionLease?.Dispose();
        }
    }
 
    [Theory]
    [InlineData("start")]
    [InlineData("stop")]
    public async Task CancellationBeforeSpawnReleasesLeaseWithoutLaunchingHelper(string action)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (services, bundle, factory, _, _) = CreateServices(workspace);
        using var cancellation = new CancellationTokenSource();
        bundle.EnsureExtractedAndAcquireLayoutAsyncCallback = _ =>
        {
            cancellation.Cancel();
            return Task.CompletedTask;
        };
        using var provider = services.BuildServiceProvider();
 
        var exitCode = await provider.GetRequiredService<RootCommand>()
            .Parse($"tray {action}").InvokeAsync(cancellationToken: cancellation.Token).DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Cancelled, exitCode);
        Assert.Empty(factory.CreatedExecutions);
        Assert.False(BundleVersionLease.HasActiveLease(bundle.Layout!.LayoutPath!));
    }
 
    [Theory]
    [InlineData("tray start --smoke-seconds 5")]
    [InlineData("tray stop --smoke-seconds 5")]
    public async Task TrayCommandsDoNotPassThroughUnrecognizedHelperOptions(string arguments)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (services, bundle, factory, _, _) = CreateServices(workspace);
        using var provider = services.BuildServiceProvider();
        var result = provider.GetRequiredService<RootCommand>().Parse(arguments);
 
        Assert.NotEmpty(result.Errors);
        Assert.NotEqual(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
        Assert.Equal(0, bundle.AcquireLayoutCallCount);
        Assert.Empty(factory.CreatedExecutions);
    }
 
    [Fact]
    public async Task TrayHelpDoesNotAcquireBundleOrLaunchHelper()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (services, bundle, factory, _, _) = CreateServices(workspace);
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var tray = Assert.IsType<TrayCommand>(Assert.Single(command.Subcommands, c => c.Name == "tray"));
        Assert.Equal(TrayCommandStrings.Description, tray.Description);
        Assert.Collection(tray.Subcommands,
            c => Assert.IsType<TrayStartCommand>(c),
            c => Assert.IsType<TrayStopCommand>(c));
 
        Assert.Equal(CliExitCodes.Success, await command.Parse("tray --help").InvokeAsync().DefaultTimeout());
        Assert.Equal(0, bundle.AcquireLayoutCallCount);
        Assert.Empty(factory.CreatedExecutions);
    }
 
    private (IServiceCollection Services, TestBundleService Bundle, TestProcessExecutionFactory Factory, TestInteractionService Interaction, string CliPath) CreateServices(TemporaryWorkspace workspace)
    {
        var root = Path.Combine(workspace.WorkspaceRoot.FullName, "bundle versions", "version-1");
        var layout = new LayoutConfiguration { LayoutPath = root, Components = new LayoutComponents { Tray = LayoutComponents.MacTrayExecutablePath } };
        var trayPath = layout.GetTrayPath()!;
        Directory.CreateDirectory(Path.GetDirectoryName(trayPath)!);
        File.WriteAllText(trayPath, "fake native helper");
        var binaryDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "installed aspire", "bin");
        Directory.CreateDirectory(binaryDirectory);
        File.WriteAllText(Path.Combine(binaryDirectory, InstallSidecarReader.SidecarFileName), """{"source":"script"}""");
        var cliPath = Path.Combine(binaryDirectory, "aspire");
        File.WriteAllText(cliPath, "fake native CLI");
        var bundle = new TestBundleService(isBundle: true)
        {
            Layout = layout,
            CreateLayoutLease = () => new BundleLayoutLease(layout, BundleVersionLease.Acquire(root, "cli", "tray"))
        };
        var factory = new TestProcessExecutionFactory();
        var interaction = new TestInteractionService();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.BundleServiceFactory = _ => bundle;
            options.ProcessPathProviderFactory = _ => new TestProcessPathProvider(cliPath) { IsNativeAot = true };
            options.DotNetCliExecutionFactoryFactory = _ => factory;
            options.InteractionServiceFactory = _ => interaction;
            options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment();
        });
        services.AddSingleton<IEnvironment>(TestEnvironment.CreateMacOS());
        return (services, bundle, factory, interaction, cliPath);
    }
 
    private static void ConfigureWindows(IServiceCollection services, TestBundleService bundle)
    {
        services.AddSingleton<IEnvironment>(TestEnvironment.CreateWindows());
        bundle.Layout!.Components.Tray = WindowsTrayPayload.ExecutablePath;
        WindowsTrayTestPayload.Create(Path.Combine(bundle.Layout.LayoutPath!, "tray"),
            RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "win-arm64" : "win-x64");
    }
 
    [Theory]
    [InlineData("start")]
    [InlineData("stop")]
    public async Task WindowsHelperRedirectedDiagnosticsPreserveNativeExitCode(string action)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (services, bundle, factory, interaction, _) = CreateServices(workspace);
        ConfigureWindows(services, bundle);
        factory.AttemptCallback = (_, options) =>
        {
            Assert.NotNull(options.StandardOutputCallback);
            Assert.NotNull(options.StandardErrorCallback);
            options.StandardOutputCallback("helper diagnostic");
            options.StandardErrorCallback("Windows tray control protocol is incompatible.");
            return (42, null);
        };
        using var provider = services.BuildServiceProvider();
 
        Assert.Equal(42, await provider.GetRequiredService<RootCommand>()
            .Parse($"tray {action}").InvokeAsync().DefaultTimeout());
        Assert.Equal(string.Format(CultureInfo.CurrentCulture, TrayCommandStrings.OperationFailed, action,
            "Windows tray control protocol is incompatible."), Assert.Single(interaction.DisplayedErrors));
        Assert.Empty(interaction.DisplayedSuccess);
        Assert.False(BundleVersionLease.HasActiveLease(bundle.Layout!.LayoutPath!));
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task ManagedWindowsCliMayStopLeasedTrayButCannotStartIt(bool start)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (services, bundle, factory, interaction, cliPath) = CreateServices(workspace);
        ConfigureWindows(services, bundle);
        services.AddSingleton<Aspire.Cli.Utils.IProcessPathProvider>(
            new TestProcessPathProvider(cliPath) { IsNativeAot = false });
        using var provider = services.BuildServiceProvider();
 
        Assert.Equal(start ? CliExitCodes.InvalidCommand : CliExitCodes.Success,
            await provider.GetRequiredService<RootCommand>()
                .Parse(start ? "tray start" : "tray stop").InvokeAsync().DefaultTimeout());
        Assert.Equal(start ? 0 : 1, bundle.AcquireLayoutCallCount);
        if (start)
        {
            Assert.Equal(TrayCommandStrings.NativeCliRequired, Assert.Single(interaction.DisplayedErrors));
            Assert.Empty(factory.CreatedExecutions);
        }
        else
        {
            Assert.NotNull(factory.LastArguments);
            Assert.Equal(["stop"], factory.LastArguments);
            Assert.Equal(TrayCommandStrings.Stopped, Assert.Single(interaction.DisplayedSuccess));
        }
    }
 
    [Theory]
    [InlineData("start", "wrong-rid")]
    [InlineData("stop", "wrong-rid")]
    [InlineData("start", "missing-icon")]
    [InlineData("stop", "missing-icon")]
    [InlineData("start", "managed")]
    [InlineData("stop", "managed")]
    public async Task WindowsRejectsIncompleteOrIncompatiblePayload(string action, string scenario)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (services, bundle, factory, interaction, _) = CreateServices(workspace);
        ConfigureWindows(services, bundle);
        var root = bundle.Layout!.LayoutPath!;
        if (scenario == "wrong-rid")
        {
            WindowsTrayTestPayload.Create(Path.Combine(root, "tray"),
                RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "win-x64" : "win-arm64");
        }
        else if (scenario == "missing-icon")
        {
            File.Delete(Path.Combine(root, "tray", "Aspire.ico"));
        }
        else
        {
            File.Copy(typeof(TrayCommandTests).Assembly.Location, bundle.Layout.GetTrayPath()!, overwrite: true);
        }
        using var provider = services.BuildServiceProvider();
 
        Assert.Equal(CliExitCodes.InvalidCommand, await provider.GetRequiredService<RootCommand>()
            .Parse($"tray {action}").InvokeAsync().DefaultTimeout());
        Assert.Single(interaction.DisplayedErrors);
        Assert.Empty(factory.CreatedExecutions);
        Assert.False(BundleVersionLease.HasActiveLease(root));
    }
}