File: CliAppHostCommandsTests.cs
Web Access
Project: src\tests\Aspire.Tray.Tests\Aspire.Tray.Tests.csproj (Aspire.Tray.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.Diagnostics;
using Aspire.Tray.Tests.Helpers;
 
namespace Aspire.Tray.Tests;
 
public class CliAppHostCommandsTests
{
    public static bool SupportsShell => !OperatingSystem.IsWindows();
 
    [Fact]
    public void StopPreservesTheDiscoveredSourcePathCasing()
    {
        var path = Path.GetFullPath(Path.Combine("MyWorktree", "Shop.AppHost", "AppHost.cs"));
        var host = new AppHostInfo(path, 42, null) { ProcessStartTimeUnixMilliseconds = 1000 };
 
        var startInfo = CliAppHostCommands.CreateStopStartInfo(Path.GetFullPath("aspire"), host.Id);
 
        Assert.Equal(Arguments(new AppHostId(path, 42, 1000)), startInfo.ArgumentList);
    }
 
    [Fact]
    public void StopAlwaysSuppliesExactIdentityAndUsesNoShell()
    {
        var executable = Path.GetFullPath("cli path/aspire");
        var host = Host();
        var startInfo = CliAppHostCommands.CreateStopStartInfo(executable, host);
 
        Assert.Equal(executable, startInfo.FileName);
        Assert.Equal(Arguments(host), startInfo.ArgumentList);
        Assert.Equal(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), startInfo.WorkingDirectory);
        Assert.False(startInfo.UseShellExecute);
        Assert.True(startInfo.CreateNoWindow);
        Assert.True(startInfo.RedirectStandardOutput);
        Assert.True(startInfo.RedirectStandardError);
        Assert.True(startInfo.RedirectStandardInput);
    }
 
    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    public void StopRejectsInvalidPid(int pid)
    {
        Assert.Throws<ArgumentOutOfRangeException>(() =>
            CliAppHostCommands.CreateStopStartInfo(Path.GetFullPath("aspire"), Host() with { AppHostPid = pid }));
    }
 
    [Fact]
    public void StopRejectsRelativePaths()
    {
        Assert.Throws<ArgumentException>(() => CliAppHostCommands.CreateStopStartInfo("aspire", Host()));
        Assert.Throws<ArgumentException>(() =>
            CliAppHostCommands.CreateStopStartInfo(Path.GetFullPath("aspire"), Host() with { AppHostPath = "apphost.cs" }));
    }
 
    [Theory]
    [InlineData(null)]
    [InlineData(0L)]
    [InlineData(-1L)]
    public void StopNeverFallsBackToPidOnlyIdentity(long? startedAt)
    {
        Assert.Throws<ArgumentException>(() => CliAppHostCommands.CreateStopStartInfo(
            Path.GetFullPath("aspire"), Host() with { ProcessStartTimeUnixMilliseconds = startedAt }));
    }
 
    [Theory(Skip = "The fixture requires /bin/sh.", SkipUnless = nameof(SupportsShell))]
    [InlineData(0)]
    [InlineData(17)]
    public async Task StopDrainsBothPipesAndPreservesTypedOutcomeAndExitCode(int exitCode)
    {
        var outcome = exitCode == 0 ? "stopped" : "stop_failed";
        using var cli = new FixtureCli($$"""
            printf '{"version":1,"outcome":"{{outcome}}","exitCode":{{exitCode}},"padding":"'
            i=0
            while [ "$i" -lt 4096 ]; do
                printf '%080d' "$i"
                printf '%080d\n' "$i" >&2
                i=$((i + 1))
            done
            printf '"}\n'
            exit {{exitCode}}
            """);
        var commands = new CliAppHostCommands(cli.Path, TimeSpan.FromSeconds(30));
        var host = Host();
 
        Assert.Equal(new StopResult(exitCode == 0 ? StopOutcome.Stopped : StopOutcome.Failed, exitCode),
            await commands.StopAsync(host, TestContext.Current.CancellationToken));
        Assert.Equal(Arguments(host), cli.ReadArguments());
    }
 
    [Fact(Skip = "The fixture requires /bin/sh.", SkipUnless = nameof(SupportsShell))]
    public async Task CancellationTerminatesOnlyTheOwnedCliChild()
    {
        using var cli = new FixtureCli("exec /bin/sleep 60");
        using var otherCli = new FixtureCli("exec /bin/sleep 60");
        using var otherProcess = Process.Start(CliProcess.CreateStartInfo(otherCli.Path))
            ?? throw new InvalidOperationException("The other fixture process could not be started.");
        using var shutdown = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
        var commands = new CliAppHostCommands(cli.Path, TimeSpan.FromSeconds(30));
        var task = commands.StopAsync(Host(), shutdown.Token);
        try
        {
            var pid = await cli.WaitForPidAsync(TestContext.Current.CancellationToken);
            using var process = Process.GetProcessById(pid);
            await shutdown.CancelAsync();
 
            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => task);
            Assert.True(process.HasExited);
            Assert.False(otherProcess.HasExited);
        }
        finally
        {
            try
            {
                await shutdown.CancelAsync();
                try
                {
                    await task.ConfigureAwait(true);
                }
                catch (OperationCanceledException) when (shutdown.IsCancellationRequested)
                {
                }
            }
            finally
            {
                await CliProcess.TerminateOwnedChildAsync(otherProcess);
            }
        }
    }
 
    [Fact(Skip = "The fixture requires /bin/sh.", SkipUnless = nameof(SupportsShell))]
    public async Task TimeoutReportsUncertainShutdownAndCleansUpTheChild()
    {
        using var cli = new FixtureCli("exec /bin/sleep 60");
        var commands = new CliAppHostCommands(cli.Path, TimeSpan.FromSeconds(5));
        var task = commands.StopAsync(Host(), TestContext.Current.CancellationToken);
        var pid = await cli.WaitForPidAsync(TestContext.Current.CancellationToken);
        using var process = Process.GetProcessById(pid);
 
        Assert.Equal(new StopResult(StopOutcome.TimedOut, null), await task.ConfigureAwait(true));
        Assert.True(process.HasExited);
    }
 
    [Theory(Skip = "The fixture requires /bin/sh.", SkipUnless = nameof(SupportsShell))]
    [InlineData("exit 0")]
    [InlineData("printf 'Stopped successfully\\n'")]
    [InlineData("printf '{\"version\":2,\"outcome\":\"stopped\",\"exitCode\":0}\\n'")]
    [InlineData("printf '{\"version\":1,\"outcome\":\"stopped\",\"exitCode\":0}\\n'; exit 7")]
    [InlineData("printf '{\"version\":1,\"outcome\":\"stopped\",\"exitCode\":0}\\n{\"version\":1,\"outcome\":\"stopped\",\"exitCode\":0}\\n'")]
    public async Task InvalidOutputIsNotSuccessfulEvenWhenTheProcessExitsZero(string script)
    {
        using var cli = new FixtureCli(script);
        var result = await new CliAppHostCommands(cli.Path, TimeSpan.FromSeconds(30))
            .StopAsync(Host(), TestContext.Current.CancellationToken);
        Assert.Equal(StopOutcome.Incompatible, result.Outcome);
    }
 
    [Fact(Skip = "The fixture requires /bin/sh.", SkipUnless = nameof(SupportsShell))]
    public async Task SuccessPayloadStillRequiresTheCliProcessToExit()
    {
        using var cli = new FixtureCli("""
            printf '{"version":1,"outcome":"stopped","exitCode":0}\n'
            exec 1>&-
            exec /bin/sleep 60
            """);
        var commands = new CliAppHostCommands(cli.Path, TimeSpan.FromSeconds(2));
        var task = commands.StopAsync(Host(), TestContext.Current.CancellationToken);
        using var process = Process.GetProcessById(await cli.WaitForPidAsync(TestContext.Current.CancellationToken));
 
        Assert.Equal(new StopResult(StopOutcome.TimedOut, null), await task.ConfigureAwait(true));
        Assert.True(process.HasExited);
    }
 
    [Theory(Skip = "The fixture requires /bin/sh.", SkipUnless = nameof(SupportsShell))]
    [InlineData("not_found", "NotFound")]
    [InlineData("ambiguous", "Ambiguous")]
    [InlineData("identity_mismatch", "IdentityMismatch")]
    [InlineData("identity_unavailable", "IdentityUnavailable")]
    public async Task StructuredFailuresDoNotRequireParsingDiagnosticText(string outcome, string expected)
    {
        using var cli = new FixtureCli($$"""
            printf 'Localized diagnostic text is not the contract\n' >&2
            printf '{"version":1,"outcome":"{{outcome}}","exitCode":7}\n'
            exit 7
            """);
        var result = await new CliAppHostCommands(cli.Path, TimeSpan.FromSeconds(30))
            .StopAsync(Host(), TestContext.Current.CancellationToken);
        Assert.Equal(expected, result.Outcome.ToString());
        Assert.Equal(7, result.ExitCode);
    }
 
    [Fact]
    public async Task CanceledRequestDoesNotStartTheCli()
    {
        var commands = new CliAppHostCommands(Path.GetFullPath("does-not-exist"), TimeSpan.FromSeconds(30));
 
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => commands.StopAsync(Host(), new CancellationToken(canceled: true)));
    }
 
    [Fact]
    public void StopRequiresAFinitePositiveTimeout()
    {
        Assert.Throws<ArgumentOutOfRangeException>(() => new CliAppHostCommands(Path.GetFullPath("aspire"), TimeSpan.Zero));
        Assert.Throws<ArgumentOutOfRangeException>(() => new CliAppHostCommands(Path.GetFullPath("aspire"), Timeout.InfiniteTimeSpan));
    }
 
    [Fact]
    public void StartUsesConfiguredExecutableAndExplicitArgumentsWithoutAShell()
    {
        using var directory = new TestTrayStateDirectory();
        var appHost = directory.CreateAppHost("project with spaces & apostrophe's/apphost.cs");
        var executable = Path.GetFullPath("configured cli path/aspire");
 
        var info = CliAppHostCommands.CreateStartStartInfo(executable, appHost);
 
        Assert.Equal(executable, info.FileName);
        Assert.Equal(["start", "--apphost", appHost, "--non-interactive", "--nologo"], info.ArgumentList);
        Assert.Equal(Path.GetDirectoryName(appHost), info.WorkingDirectory);
        Assert.False(info.UseShellExecute);
        Assert.True(info.CreateNoWindow);
        Assert.True(info.RedirectStandardInput);
        Assert.True(info.RedirectStandardOutput);
        Assert.True(info.RedirectStandardError);
    }
 
    [Fact]
    public async Task StartRequiresAnExistingAbsoluteFileAndDoesNotLaunchForMissingPaths()
    {
        using var directory = new TestTrayStateDirectory();
        var appHost = directory.CreateAppHost("apphost.cs");
        var commands = new CliAppHostCommands(Path.GetFullPath("missing-cli"), TimeSpan.FromSeconds(60));
        Assert.Throws<ArgumentException>(() => CliAppHostCommands.CreateStartStartInfo("aspire", appHost));
        Assert.Throws<ArgumentException>(() => CliAppHostCommands.CreateStartStartInfo(Path.GetFullPath("aspire"), "apphost.cs"));
        Assert.Throws<FileNotFoundException>(() =>
            CliAppHostCommands.CreateStartStartInfo(Path.GetFullPath("aspire"), Path.GetDirectoryName(appHost)!));
        File.Delete(appHost);
 
        Assert.Equal(new StartResult(StartOutcome.NotFound, null),
            await commands.StartAsync(appHost, TestContext.Current.CancellationToken));
    }
 
    [Theory(Skip = "The fixture requires /bin/sh.", SkipUnless = nameof(SupportsShell))]
    [InlineData(0)]
    [InlineData(17)]
    public async Task StartDrainsBothStreamsWithoutParsingOrExposingDiagnostics(int exitCode)
    {
        using var directory = new TestTrayStateDirectory();
        var appHost = directory.CreateAppHost("project with spaces & apostrophe's/apphost.cs");
        using var cli = new FixtureCli($$"""
            i=0
            while [ "$i" -lt 4096 ]; do
                printf 'https://localhost/?token=private%080d\n' "$i"
                printf 'localized error with private credentials %080d\n' "$i" >&2
                i=$((i + 1))
            done
            exit {{exitCode}}
            """);
        var commands = new CliAppHostCommands(cli.Path, TimeSpan.FromSeconds(30));
 
        var result = await commands.StartAsync(appHost, TestContext.Current.CancellationToken);
 
        Assert.Equal(new StartResult(exitCode == 0 ? StartOutcome.Started : StartOutcome.Failed, exitCode), result);
        Assert.Equal(["start", "--apphost", appHost, "--non-interactive", "--nologo"], cli.ReadArguments());
    }
 
    [Fact(Skip = "The fixture requires /bin/sh.", SkipUnless = nameof(SupportsShell))]
    public async Task StartCancellationTerminatesOnlyCliAndLeavesDetachedAppHostRunning()
    {
        using var directory = new TestTrayStateDirectory();
        var appHost = directory.CreateAppHost("apphost.cs");
        using var cli = new FixtureCli("""
            /bin/sleep 60 </dev/null >/dev/null 2>&1 &
            printf '%s\n' "$!" > "$0.detached.pid"
            exec /bin/sleep 60
            """);
        using var shutdown = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
        var commands = new CliAppHostCommands(cli.Path, TimeSpan.FromSeconds(30));
        var task = commands.StartAsync(appHost, shutdown.Token);
        Process? detached = null;
        try
        {
            detached = Process.GetProcessById(await cli.WaitForDetachedPidAsync(TestContext.Current.CancellationToken));
            using var process = Process.GetProcessById(await cli.WaitForPidAsync(TestContext.Current.CancellationToken));
            await shutdown.CancelAsync();
 
            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => task);
            Assert.True(process.HasExited);
            Assert.False(detached.HasExited);
        }
        finally
        {
            try
            {
                await shutdown.CancelAsync();
                try
                {
                    await task.ConfigureAwait(true);
                }
                catch (OperationCanceledException) when (shutdown.IsCancellationRequested)
                {
                }
            }
            finally
            {
                if (detached is not null)
                {
                    using (detached)
                    {
                        await CliProcess.TerminateOwnedChildAsync(detached);
                    }
                }
            }
        }
    }
 
    [Fact(Skip = "The fixture requires /bin/sh.", SkipUnless = nameof(SupportsShell))]
    public async Task StartTimeoutReportsUncertainResultAndWaitsForCliExit()
    {
        using var directory = new TestTrayStateDirectory();
        var appHost = directory.CreateAppHost("apphost.cs");
        using var cli = new FixtureCli("exec /bin/sleep 60");
        var commands = new CliAppHostCommands(cli.Path, TimeSpan.FromSeconds(5));
        var task = commands.StartAsync(appHost, TestContext.Current.CancellationToken);
        using var process = Process.GetProcessById(await cli.WaitForPidAsync(TestContext.Current.CancellationToken));
 
        Assert.Equal(new StartResult(StartOutcome.TimedOut, null), await task.ConfigureAwait(true));
        Assert.True(process.HasExited);
    }
 
    [Fact]
    public async Task MissingCliReturnsSafeTypedStartFailure()
    {
        using var directory = new TestTrayStateDirectory();
        var appHost = directory.CreateAppHost("apphost.cs");
        var commands = new CliAppHostCommands(Path.GetFullPath("missing-cli"), TimeSpan.FromSeconds(60));
 
        Assert.Equal(new StartResult(StartOutcome.Failed, null),
            await commands.StartAsync(appHost, TestContext.Current.CancellationToken));
    }
 
    [Fact]
    public async Task CanceledStartDoesNotLaunchCli()
    {
        var commands = new CliAppHostCommands(Path.GetFullPath("missing-cli"), TimeSpan.FromSeconds(60));
 
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => commands.StartAsync(
            Path.GetFullPath("missing-apphost.cs"), new CancellationToken(canceled: true)));
    }
 
    private static AppHostId Host() => new(Path.GetFullPath("project with spaces & apostrophe's/apphost.cs"), 42, 1000);
 
    private static string[] Arguments(AppHostId host) =>
        ["stop", "--apphost", host.AppHostPath, "--pid", "42", "--started-at", "1000",
        "--format", "json", "--protocol-version", "1", "--non-interactive", "--nologo"];
}