File: CliAppHostClientTests.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;
using Microsoft.Extensions.Time.Testing;
using static Aspire.Tray.Tests.CliProtocolTests;
 
namespace Aspire.Tray.Tests;
 
public class CliAppHostClientTests
{
    public static bool SupportsShell => !OperatingSystem.IsWindows();
 
    [Fact]
    public void WatchOptsIntoTheVersionedContractWithoutAShell()
    {
        var executable = Path.GetFullPath("cli path/aspire");
        var startInfo = CliAppHostClient.CreateWatchStartInfo(executable);
        Assert.Equal(executable, startInfo.FileName);
        Assert.Equal(["ps", "--follow", "--format", "json", "--output", "snapshot", "--non-interactive", "--nologo"],
            startInfo.ArgumentList);
        Assert.False(startInfo.UseShellExecute);
        Assert.Equal(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), startInfo.WorkingDirectory);
    }
 
    [Fact(Skip = "The fixture requires /bin/sh.", SkipUnless = nameof(SupportsShell))]
    public async Task StartUsesTheConfiguredCliWithoutCreatingADiscoveryStream()
    {
        using var directory = new TestTrayStateDirectory();
        var appHost = directory.CreateAppHost("apphost.cs");
        using var cli = new FixtureCli("exit 0");
        IAppHostClient client = new CliAppHostClient(cli.Path);
 
        Assert.Equal(new StartResult(StartOutcome.Started, 0),
            await client.StartAsync(appHost, TestContext.Current.CancellationToken));
        Assert.Equal(["start", "--apphost", appHost, "--non-interactive", "--nologo"], cli.ReadArguments());
    }
 
    [Fact(Skip = "The fixture requires /bin/sh.", SkipUnless = nameof(SupportsShell))]
    public async Task EmptySnapshotIsLiveAndDisposingTheReaderCleansUpItsChild()
    {
        using var cli = new FixtureCli("""
            printf '{"version":1,"type":"snapshot","appHosts":[]}\n'
            exec /bin/sleep 60
            """);
        var client = new CliAppHostClient(cli.Path);
        var stream = client.WatchAsync(TestContext.Current.CancellationToken).GetAsyncEnumerator(TestContext.Current.CancellationToken);
        await using var lifetime = stream.ConfigureAwait(true);
        Assert.True(await stream.MoveNextAsync());
        Assert.Equal(DiscoveryState.Connecting, stream.Current.Discovery);
        Assert.True(await stream.MoveNextAsync());
        Assert.Equal(DiscoveryState.Live, stream.Current.Discovery);
        Assert.Empty(stream.Current.AppHosts);
        using var process = Process.GetProcessById(await cli.WaitForPidAsync(TestContext.Current.CancellationToken));
        await stream.DisposeAsync();
        Assert.True(process.HasExited);
    }
 
    [Fact(Skip = "The fixture requires /bin/sh.", SkipUnless = nameof(SupportsShell))]
    public async Task EofKeepsTheLastSnapshotButMarksItDisconnected()
    {
        using var cli = new FixtureCli($"printf '%s\\n' '{Snapshot(Host(42))}'");
        var stream = new CliAppHostClient(cli.Path)
            .WatchAsync(TestContext.Current.CancellationToken).GetAsyncEnumerator(TestContext.Current.CancellationToken);
        await using var lifetime = stream.ConfigureAwait(true);
        Assert.True(await stream.MoveNextAsync());
        Assert.True(await stream.MoveNextAsync());
        Assert.Equal(DiscoveryState.Live, stream.Current.Discovery);
        Assert.Equal(42, Assert.Single(stream.Current.AppHosts).AppHostPid);
        Assert.True(await stream.MoveNextAsync());
        Assert.Equal(DiscoveryState.Disconnected, stream.Current.Discovery);
        Assert.Equal(42, Assert.Single(stream.Current.AppHosts).AppHostPid);
    }
 
    [Theory(Skip = "The fixture requires /bin/sh.", SkipUnless = nameof(SupportsShell))]
    [InlineData("exit 0")]
    [InlineData("printf 'unsupported option\\n' >&2; exit 1")]
    [InlineData("printf '{\"version\":2,\"type\":\"snapshot\",\"appHosts\":[]}\\n'")]
    [InlineData("printf '{\"version\":1,\"type\":\"heartbeat\"}\\n'")]
    [InlineData("printf 'Unexpected diagnostic on stdout\\n'")]
    public async Task IncompatibleCliFailsClosedWithoutRetrying(string script)
    {
        using var cli = new FixtureCli(script);
        var stream = new CliAppHostClient(cli.Path)
            .WatchAsync(TestContext.Current.CancellationToken).GetAsyncEnumerator(TestContext.Current.CancellationToken);
        await using var lifetime = stream.ConfigureAwait(true);
        Assert.True(await stream.MoveNextAsync());
        Assert.True(await stream.MoveNextAsync());
        Assert.Equal(DiscoveryState.Incompatible, stream.Current.Discovery);
        Assert.Empty(stream.Current.AppHosts);
        Assert.False(await stream.MoveNextAsync());
    }
 
    [Fact(Skip = "The fixture requires /bin/sh.", SkipUnless = nameof(SupportsShell))]
    public async Task HeartbeatsDoNotCreatePresentationUpdates()
    {
        using var cli = new FixtureCli($$"""
            printf '{"version":1,"type":"snapshot","appHosts":[]}\n'
            printf '{"version":1,"type":"heartbeat"}\n'
            printf '%s\n' '{{Snapshot(Host(42))}}'
            """);
        var stream = new CliAppHostClient(cli.Path)
            .WatchAsync(TestContext.Current.CancellationToken).GetAsyncEnumerator(TestContext.Current.CancellationToken);
        await using var lifetime = stream.ConfigureAwait(true);
        Assert.True(await stream.MoveNextAsync());
        Assert.True(await stream.MoveNextAsync());
        Assert.Empty(stream.Current.AppHosts);
        Assert.True(await stream.MoveNextAsync());
        Assert.Equal(DiscoveryState.Live, stream.Current.Discovery);
        Assert.Equal(42, Assert.Single(stream.Current.AppHosts).AppHostPid);
    }
 
    [Fact(Skip = "The fixture requires /bin/sh.", SkipUnless = nameof(SupportsShell))]
    public async Task DiscoveryFailureNeverBecomesAnEmptyLiveList()
    {
        using var cli = new FixtureCli("""
            printf '{"version":1,"type":"error","errorCode":"discovery_failed"}\n'
            exit 1
            """);
        var stream = new CliAppHostClient(cli.Path)
            .WatchAsync(TestContext.Current.CancellationToken).GetAsyncEnumerator(TestContext.Current.CancellationToken);
        await using var lifetime = stream.ConfigureAwait(true);
        Assert.True(await stream.MoveNextAsync());
        Assert.True(await stream.MoveNextAsync());
        Assert.Equal(DiscoveryState.Disconnected, stream.Current.Discovery);
    }
 
    [Fact(Skip = "The fixture requires /bin/sh.", SkipUnless = nameof(SupportsShell))]
    public async Task LimitFailureIsExplicitAndDoesNotRestartTheSameOversizedQuery()
    {
        using var cli = new FixtureCli("""
            printf '{"version":1,"type":"error","errorCode":"limit_exceeded"}\n'
            exit 1
            """);
        var stream = new CliAppHostClient(cli.Path)
            .WatchAsync(TestContext.Current.CancellationToken).GetAsyncEnumerator(TestContext.Current.CancellationToken);
        await using var lifetime = stream.ConfigureAwait(true);
        Assert.True(await stream.MoveNextAsync());
        Assert.True(await stream.MoveNextAsync());
        Assert.Equal(DiscoveryState.LimitExceeded, stream.Current.Discovery);
        Assert.False(await stream.MoveNextAsync());
    }
 
    [Fact(Skip = "The fixture requires /bin/sh.", SkipUnless = nameof(SupportsShell))]
    public async Task ReconnectReplacesThePreviousListInOneSnapshot()
    {
        using var cli = new FixtureCli($$"""
            if [ -e "$0.attempt" ]; then
                printf '%s\n' '{{Snapshot(Host(43))}}'
                exec /bin/sleep 60
            fi
            printf 'first\n' > "$0.attempt"
            printf '%s\n' '{{Snapshot(Host(42))}}'
            """);
        var client = new CliAppHostClient(cli.Path, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(10), TimeProvider.System);
        var stream = client.WatchAsync(TestContext.Current.CancellationToken).GetAsyncEnumerator(TestContext.Current.CancellationToken);
        await using var lifetime = stream.ConfigureAwait(true);
        Assert.True(await stream.MoveNextAsync());
        Assert.True(await stream.MoveNextAsync());
        Assert.Equal(42, Assert.Single(stream.Current.AppHosts).AppHostPid);
        Assert.True(await stream.MoveNextAsync());
        Assert.Equal(DiscoveryState.Disconnected, stream.Current.Discovery);
        Assert.True(await stream.MoveNextAsync());
        Assert.Equal(DiscoveryState.Connecting, stream.Current.Discovery);
        Assert.Equal(42, Assert.Single(stream.Current.AppHosts).AppHostPid);
        Assert.True(await stream.MoveNextAsync());
        Assert.Equal(DiscoveryState.Live, stream.Current.Discovery);
        Assert.Equal(43, Assert.Single(stream.Current.AppHosts).AppHostPid);
    }
 
    [Fact(Skip = "The fixture requires /bin/sh.", SkipUnless = nameof(SupportsShell))]
    public async Task QuietHungWatcherTimesOutAndItsProcessIsCleanedUp()
    {
        using var cli = new FixtureCli("""
            printf '{"version":1,"type":"snapshot","appHosts":[]}\n'
            exec /bin/sleep 60
            """);
        var client = new CliAppHostClient(cli.Path, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(1), TimeProvider.System);
        var stream = client.WatchAsync(TestContext.Current.CancellationToken).GetAsyncEnumerator(TestContext.Current.CancellationToken);
        await using var lifetime = stream.ConfigureAwait(true);
        Assert.True(await stream.MoveNextAsync());
        Assert.True(await stream.MoveNextAsync());
        using var process = Process.GetProcessById(await cli.WaitForPidAsync(TestContext.Current.CancellationToken));
        Assert.True(await stream.MoveNextAsync());
        Assert.Equal(DiscoveryState.Disconnected, stream.Current.Discovery);
        Assert.True(process.HasExited);
    }
 
    [Fact(Skip = "The fixture requires /bin/sh.", SkipUnless = nameof(SupportsShell))]
    public async Task CancellationWhileConnectingJoinsTheWatcherChild()
    {
        using var cli = new FixtureCli("exec /bin/sleep 60");
        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
        var stream = new CliAppHostClient(cli.Path).WatchAsync(cancellation.Token).GetAsyncEnumerator(cancellation.Token);
        await using var lifetime = stream.ConfigureAwait(true);
        Assert.True(await stream.MoveNextAsync());
        var next = stream.MoveNextAsync().AsTask();
        using var process = Process.GetProcessById(await cli.WaitForPidAsync(TestContext.Current.CancellationToken));
        await cancellation.CancelAsync();
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => next);
        Assert.True(process.HasExited);
    }
 
    [Theory(Skip = "The fixture requires /bin/sh.", SkipUnless = nameof(SupportsShell))]
    [InlineData(false)]
    [InlineData(true)]
    public async Task ShortLivedSessionsRetainExponentialBackoff(bool emitHeartbeat)
    {
        using var cli = new FixtureCli("""
            printf '{"version":1,"type":"snapshot","appHosts":[]}\n'
            """ + (emitHeartbeat ? """
 
            printf '{"version":1,"type":"heartbeat"}\n'
            """ : ""));
        var time = new FakeTimeProvider();
        var client = new CliAppHostClient(cli.Path, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(1), time);
        await using var stream = client.WatchAsync(TestContext.Current.CancellationToken).GetAsyncEnumerator(TestContext.Current.CancellationToken);
        Assert.True(await stream.MoveNextAsync());
        Assert.Equal(DiscoveryState.Connecting, stream.Current.Discovery);
 
        foreach (var seconds in new[] { 1, 2, 4, 8, 10, 10 })
        {
            Assert.True(await stream.MoveNextAsync());
            Assert.Equal(DiscoveryState.Live, stream.Current.Discovery);
            Assert.True(await stream.MoveNextAsync());
            Assert.Equal(DiscoveryState.Disconnected, stream.Current.Discovery);
            var reconnect = stream.MoveNextAsync().AsTask();
            time.Advance(TimeSpan.FromSeconds(seconds) - TimeSpan.FromMilliseconds(1));
            Assert.False(reconnect.IsCompleted);
            time.Advance(TimeSpan.FromMilliseconds(1));
            Assert.True(await reconnect.WaitAsync(TestContext.Current.CancellationToken));
            Assert.Equal(DiscoveryState.Connecting, stream.Current.Discovery);
        }
    }
 
    [Fact(Skip = "The fixture requires /bin/sh.", SkipUnless = nameof(SupportsShell))]
    public async Task SustainedHeartbeatResetsRetryBackoff()
    {
        using var cli = new FixtureCli("""
            printf '{"version":1,"type":"snapshot","appHosts":[]}\n'
            if [ -e "$0.attempt" ]; then
                while [ ! -e "$0.heartbeat" ]; do /bin/sleep 0.01; done
                printf '{"version":1,"type":"heartbeat"}\n'
                printf '{"version":1,"type":"snapshot","appHosts":[]}\n'
            fi
            printf 'first\n' > "$0.attempt"
            """);
        var time = new FakeTimeProvider();
        var client = new CliAppHostClient(cli.Path, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(1), time);
        await using var stream = client.WatchAsync(TestContext.Current.CancellationToken).GetAsyncEnumerator(TestContext.Current.CancellationToken);
        Assert.True(await stream.MoveNextAsync());
        Assert.True(await stream.MoveNextAsync());
        Assert.True(await stream.MoveNextAsync());
        Assert.Equal(DiscoveryState.Disconnected, stream.Current.Discovery);
        var reconnect = stream.MoveNextAsync().AsTask();
        time.Advance(TimeSpan.FromSeconds(1));
        Assert.True(await reconnect.WaitAsync(TestContext.Current.CancellationToken));
        Assert.True(await stream.MoveNextAsync());
        Assert.Equal(DiscoveryState.Live, stream.Current.Discovery);
 
        time.Advance(TimeSpan.FromSeconds(30));
        File.WriteAllText(cli.Path + ".heartbeat", "ready");
        Assert.True(await stream.MoveNextAsync());
        Assert.Equal(DiscoveryState.Live, stream.Current.Discovery);
        Assert.True(await stream.MoveNextAsync());
        Assert.Equal(DiscoveryState.Disconnected, stream.Current.Discovery);
        reconnect = stream.MoveNextAsync().AsTask();
        time.Advance(TimeSpan.FromMilliseconds(999));
        Assert.False(reconnect.IsCompleted);
        time.Advance(TimeSpan.FromMilliseconds(1));
        Assert.True(await reconnect.WaitAsync(TestContext.Current.CancellationToken));
        Assert.Equal(DiscoveryState.Connecting, stream.Current.Discovery);
    }
}