File: TrayControllerTests.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 Aspire.Tray.Tests.Helpers;
using static Aspire.Tray.Tests.Helpers.TestAppHostClient;
 
namespace Aspire.Tray.Tests;
 
public class TrayControllerTests
{
    [Fact]
    public async Task DistinguishesConnectingEmptyAndDisconnected()
    {
        var client = new TestAppHostClient();
        var controller = new TrayController(client);
        await using var lifetime = controller.ConfigureAwait(true);
        Assert.Equal(DiscoveryState.Connecting, controller.State.Discovery);
        Assert.Equal("Connecting to Aspire...", controller.State.Status);
        controller.Start();
 
        client.Publish(new([], DiscoveryState.Live));
        await WaitForStateAsync(controller, state => state.Discovery == DiscoveryState.Live);
        Assert.Equal("No AppHosts running", controller.State.Status);
        client.Publish(new([], DiscoveryState.Disconnected));
        await WaitForStateAsync(controller, state => state.Discovery == DiscoveryState.Disconnected);
        Assert.Equal("Discovery unavailable. Reconnecting...", controller.State.Status);
    }
 
    [Fact]
    public async Task DifferentAppHostsCanStopConcurrentlyAndDuplicateStopsAreRejected()
    {
        var client = new TestAppHostClient();
        var controller = new TrayController(client);
        await using var lifetime = controller.ConfigureAwait(true);
        var first = Host(42);
        var second = Host(43);
        client.Publish(new([first, second], DiscoveryState.Live));
        controller.Start();
        await WaitForStateAsync(controller, state => state.AppHosts.Count == 2);
 
        controller.RequestStop(first.Id);
        Assert.Equal(first.Id, await client.NextStopAsync());
        Assert.True(controller.State.AppHosts[0].IsStopping);
        Assert.False(controller.State.AppHosts[0].CanStop);
        Assert.True(controller.State.AppHosts[1].CanStop);
        Assert.Throws<InvalidOperationException>(() => controller.RequestStop(first.Id));
 
        controller.RequestStop(second.Id);
        Assert.Equal(second.Id, await client.NextStopAsync());
        Assert.All(controller.State.AppHosts, row => Assert.True(row.IsStopping));
        Assert.Equal("Stopping 2 AppHosts...", controller.State.Status);
 
        client.CompleteStop(second.Id, new(StopOutcome.Stopped, 0));
        await WaitForStateAsync(controller, state => !state.AppHosts[1].IsStopping);
        Assert.True(controller.State.AppHosts[0].IsStopping);
        Assert.False(controller.State.AppHosts[1].CanStop);
        Assert.Equal(2, controller.State.AppHosts.Count);
 
        client.CompleteStop(first.Id, new(StopOutcome.Stopped, 0));
        await WaitForStateAsync(controller, state => state.AppHosts.All(row => !row.IsStopping));
        Assert.Throws<InvalidOperationException>(() => controller.GetDashboardUri(first.Id));
        client.Publish(new([], DiscoveryState.Live));
        await WaitForStateAsync(controller, state => state.AppHosts.Count == 0);
        Assert.Equal([first.Id, second.Id], client.Requests.ToArray());
    }
 
    [Fact]
    public async Task ActionsStayBoundToLifetimeAcrossReorderingAndReplacement()
    {
        var client = new TestAppHostClient();
        var controller = new TrayController(client);
        await using var lifetime = controller.ConfigureAwait(true);
        var first = Host(42);
        var second = Host(43);
        client.Publish(new([first, second], DiscoveryState.Live));
        controller.Start();
        await WaitForStateAsync(controller, state => state.AppHosts.Count == 2);
 
        var updated = first with { DashboardUrl = "https://localhost:5678/" };
        client.Publish(new([second, updated], DiscoveryState.Live));
        await WaitForStateAsync(controller, state => state.AppHosts[0].Id == second.Id);
        Assert.Same(updated, controller.RequireLiveInstance(first.Id));
        Assert.Equal("https://localhost:5678/", controller.GetDashboardUri(first.Id).AbsoluteUri);
 
        var replacement = Host(42, 2000);
        client.Publish(new([replacement, second], DiscoveryState.Live));
        await WaitForStateAsync(controller, state => state.AppHosts[0].Id == replacement.Id);
        Assert.Throws<InvalidOperationException>(() => controller.RequestStop(first.Id));
        Assert.Throws<InvalidOperationException>(() => controller.GetDashboardUri(first.Id));
        Assert.Empty(client.Requests);
 
        controller.RequestStop(replacement.Id);
        Assert.Equal(replacement.Id, await client.NextStopAsync());
    }
 
    [Fact]
    public async Task ReconnectionAndMissingLifetimeDisableActionsWithoutLosingTheRows()
    {
        var client = new TestAppHostClient();
        var controller = new TrayController(client);
        await using var lifetime = controller.ConfigureAwait(true);
        var known = Host(42);
        var unknown = Host(43) with { ProcessStartTimeUnixMilliseconds = null };
        client.Publish(new([known, unknown], DiscoveryState.Live));
        controller.Start();
        await WaitForStateAsync(controller, state => state.AppHosts.Count == 2);
        Assert.True(controller.State.AppHosts[1].CanOpenDashboard);
        Assert.True(controller.State.AppHosts[1].IsRunning);
        Assert.Equal("1 AppHost needs attention", controller.State.Status);
        Assert.Equal(unknown.DashboardUri, controller.GetDashboardUri(unknown.Id));
        Assert.False(controller.State.AppHosts[1].CanStop);
        Assert.Throws<InvalidOperationException>(() => controller.RequestStop(unknown.Id));
 
        client.Publish(new([known, unknown], DiscoveryState.Disconnected));
        await WaitForStateAsync(controller, state => state.Discovery == DiscoveryState.Disconnected);
        Assert.All(controller.State.AppHosts, row =>
        {
            Assert.False(row.CanOpenDashboard);
            Assert.False(row.CanStop);
        });
        Assert.Throws<InvalidOperationException>(() => controller.RequestStop(known.Id));
        Assert.Throws<InvalidOperationException>(() => controller.GetDashboardUri(known.Id));
        Assert.Empty(client.Requests);
    }
 
    [Fact]
    public async Task FailureBelongsToItsRowAndRetryClearsIt()
    {
        var client = new TestAppHostClient();
        var controller = new TrayController(client);
        await using var lifetime = controller.ConfigureAwait(true);
        var first = Host(42);
        var second = Host(43);
        client.Publish(new([first, second], DiscoveryState.Live));
        controller.Start();
        await WaitForStateAsync(controller, state => state.AppHosts.Count == 2);
        controller.RequestStop(first.Id);
        await client.NextStopAsync();
        client.CompleteStop(first.Id, new(StopOutcome.TimedOut, null));
        await WaitForStateAsync(controller, state => state.AppHosts[0].Error is not null);
        Assert.Equal("Stop timed out. The shutdown may still complete.", controller.State.AppHosts[0].Error);
        Assert.True(controller.State.AppHosts[0].IsRunning);
        Assert.True(controller.State.AppHosts[0].CanOpenDashboard);
        Assert.Equal(first.DashboardUri, controller.GetDashboardUri(first.Id));
        Assert.Null(controller.State.AppHosts[1].Error);
        Assert.True(controller.State.AppHosts[1].CanStop);
 
        controller.RequestStop(first.Id);
        Assert.Equal(first.Id, await client.NextStopAsync());
        Assert.Null(controller.State.AppHosts[0].Error);
        Assert.True(controller.State.AppHosts[0].IsStopping);
    }
 
    [Fact]
    public async Task QuitCancelsAndJoinsAllOwnedOperationsWithoutRequestingMoreStops()
    {
        var client = new TestAppHostClient();
        var controller = new TrayController(client);
        var first = Host(42);
        var second = Host(43);
        try
        {
            client.Publish(new([first, second], DiscoveryState.Live));
            controller.Start();
            await WaitForStateAsync(controller, state => state.AppHosts.Count == 2);
            controller.RequestStop(first.Id);
            controller.RequestStop(second.Id);
            await client.NextStopAsync();
            await client.NextStopAsync();
            await controller.DisposeAsync();
            Assert.True(client.WatchFinished.Task.IsCompletedSuccessfully);
            var finished = new[] { await client.NextFinishedStopAsync(), await client.NextFinishedStopAsync() };
            Assert.Equal([42, 43], finished.Select(id => id.AppHostPid).Order());
            Assert.Equal(2, client.Requests.Count);
            Assert.Throws<ObjectDisposedException>(() => controller.RequestStop(first.Id));
        }
        finally
        {
            await controller.DisposeAsync();
        }
    }
 
    [Fact]
    public async Task ControllerOwnsOnlyOneDiscoveryLoop()
    {
        var controller = new TrayController(new TestAppHostClient());
        await using var lifetime = controller.ConfigureAwait(true);
        controller.Start();
        Assert.Throws<InvalidOperationException>(controller.Start);
    }
}