File: Model\DashboardClientTests.cs
Web Access
Project: src\tests\Aspire.Dashboard.Tests\Aspire.Dashboard.Tests.csproj (Aspire.Dashboard.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.Collections.Concurrent;
using System.Diagnostics;
using Aspire.Dashboard.Configuration;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Utils;
using Aspire.DashboardService.Proto.V1;
using Aspire.Tests;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing;
using Microsoft.Extensions.Options;
using Semver;
using Xunit;
using DashboardResources = Aspire.Dashboard.Resources.Resources;
 
namespace Aspire.Dashboard.Tests.Model;
 
public sealed class DashboardClientTests(ITestOutputHelper testOutputHelper) : IDisposable
{
    private readonly ILoggerFactory _loggerFactory = LoggerFactory.Create(builder =>
    {
        builder.AddXunit(testOutputHelper, LogLevel.Trace, DateTimeOffset.UtcNow);
        builder.SetMinimumLevel(LogLevel.Trace);
    });
 
    [Fact]
    public async Task SubscribeResources_OnCancel_ChannelRemoved()
    {
        await using var instance = CreateResourceServiceClient();
        instance.SetInitialDataReceived();
 
        IDashboardClient client = instance;
 
        var cts = new CancellationTokenSource();
 
        Assert.Equal(0, instance.OutgoingResourceSubscriberCount);
 
        var (_, subscription) = await client.SubscribeResourcesAsync(CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(1, instance.OutgoingResourceSubscriberCount);
 
        var readTask = Task.Run(async () =>
        {
            await foreach (var item in subscription.WithCancellation(cts.Token))
            {
            }
        });
 
        cts.Cancel();
 
        await TaskHelpers.WaitIgnoreCancelAsync(readTask).DefaultTimeout();
 
        Assert.Equal(0, instance.OutgoingResourceSubscriberCount);
    }
 
    [Fact]
    public async Task SubscribeResources_OnDispose_ChannelRemoved()
    {
        await using var instance = CreateResourceServiceClient();
        instance.SetInitialDataReceived();
 
        IDashboardClient client = instance;
 
        Assert.Equal(0, instance.OutgoingResourceSubscriberCount);
 
        var (_, subscription) = await client.SubscribeResourcesAsync(CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(1, instance.OutgoingResourceSubscriberCount);
 
        var readTask = Task.Run(async () =>
        {
            await foreach (var item in subscription)
            {
            }
        });
 
        await instance.DisposeAsync().DefaultTimeout();
 
        Assert.Equal(0, instance.OutgoingResourceSubscriberCount);
 
        await TaskHelpers.WaitIgnoreCancelAsync(readTask).DefaultTimeout();
    }
 
    [Fact]
    public async Task SubscribeResources_ThrowsIfDisposed()
    {
        await using IDashboardClient client = CreateResourceServiceClient();
 
        await client.DisposeAsync().DefaultTimeout();
 
        await Assert.ThrowsAsync<ObjectDisposedException>(() => client.SubscribeResourcesAsync(CancellationToken.None)).DefaultTimeout();
    }
 
    [Fact]
    public async Task SubscribeResources_IncreasesSubscriberCount()
    {
        await using var instance = CreateResourceServiceClient();
        instance.SetInitialDataReceived();
 
        IDashboardClient client = instance;
 
        Assert.Equal(0, instance.OutgoingResourceSubscriberCount);
 
        _ = await client.SubscribeResourcesAsync(CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(1, instance.OutgoingResourceSubscriberCount);
 
        await instance.DisposeAsync().DefaultTimeout();
 
        Assert.Equal(0, instance.OutgoingResourceSubscriberCount);
    }
 
    [Fact]
    public async Task SubscribeResources_HasInitialData_InitialDataReturned()
    {
        await using var instance = CreateResourceServiceClient();
 
        IDashboardClient client = instance;
 
        var cts = new CancellationTokenSource();
 
        var subscribeTask = client.SubscribeResourcesAsync(CancellationToken.None);
 
        Assert.False(subscribeTask.IsCompleted);
        Assert.Equal(0, instance.OutgoingResourceSubscriberCount);
 
        instance.SetInitialDataReceived([new Resource
        {
            Name = "test",
            CreatedAt = Timestamp.FromDateTime(DateTime.UtcNow),
        }]);
 
        var (initialData, subscription) = await subscribeTask.DefaultTimeout();
 
        Assert.Single(initialData);
    }
 
    [Fact]
    public async Task SubscribeConsoleLogs_ReceivesAndPersistsLogs()
    {
        var repositoryWriter = new RecordingResourceRepositoryWriter();
        await using var instance = CreateResourceServiceClient(resourceRepositoryWriter: repositoryWriter);
        instance.SetDashboardServiceClient(new MockDashboardServiceClient
        {
            ConsoleLogUpdates =
            [
                new WatchResourceConsoleLogsUpdate
                {
                    LogLines =
                    {
                        new ConsoleLogLine { LineNumber = 1, Text = "Hello", IsStdErr = false }
                    }
                }
            ]
        });
 
        var batches = new List<IReadOnlyList<ResourceLogLine>>();
        await foreach (var batch in instance.SubscribeConsoleLogs("api", CancellationToken.None))
        {
            batches.Add(batch);
        }
 
        var line = Assert.Single(Assert.Single(batches));
        Assert.Equal(new ResourceLogLine(1, "Hello", false), line);
        var persistedLogs = Assert.Single(repositoryWriter.ConsoleLogs);
        Assert.Equal("api", persistedLogs.ResourceName);
        Assert.Equal("Hello", Assert.Single(persistedLogs.LogLines).Text);
        Assert.Equal("api", Assert.Single(repositoryWriter.LoadedConsoleLogs));
    }
 
    [Fact]
    public async Task SubscribeInteractions_OnCancel_ChannelRemoved()
    {
        await using var instance = CreateResourceServiceClient();
 
        IDashboardClient client = instance;
 
        var cts = new CancellationTokenSource();
 
        Assert.Equal(0, instance.OutgoingInteractionSubscriberCount);
 
        var subscription = client.SubscribeInteractionsAsync(CancellationToken.None);
 
        Assert.Equal(1, instance.OutgoingInteractionSubscriberCount);
 
        var readTask = Task.Run(async () =>
        {
            await foreach (var item in subscription.WithCancellation(cts.Token))
            {
            }
        });
 
        cts.Cancel();
 
        await TaskHelpers.WaitIgnoreCancelAsync(readTask).DefaultTimeout();
 
        Assert.Equal(0, instance.OutgoingInteractionSubscriberCount);
    }
 
    [Fact]
    public async Task SubscribeInteractions_OnDispose_ChannelRemoved()
    {
        await using var instance = CreateResourceServiceClient();
 
        IDashboardClient client = instance;
 
        Assert.Equal(0, instance.OutgoingInteractionSubscriberCount);
 
        var subscription = client.SubscribeInteractionsAsync(CancellationToken.None);
 
        Assert.Equal(1, instance.OutgoingInteractionSubscriberCount);
 
        var readTask = Task.Run(async () =>
        {
            await foreach (var item in subscription)
            {
            }
        });
 
        await instance.DisposeAsync().DefaultTimeout();
 
        Assert.Equal(0, instance.OutgoingInteractionSubscriberCount);
 
        await TaskHelpers.WaitIgnoreCancelAsync(readTask).DefaultTimeout();
    }
 
    [Fact]
    public async Task SubscribeInteractions_ThrowsIfDisposed()
    {
        await using IDashboardClient client = CreateResourceServiceClient();
 
        await client.DisposeAsync().DefaultTimeout();
 
        Assert.Throws<ObjectDisposedException>(() => client.SubscribeInteractionsAsync(CancellationToken.None));
    }
 
    [Fact]
    public async Task SubscribeInteractions_IncreasesSubscriberCount()
    {
        await using var instance = CreateResourceServiceClient();
 
        IDashboardClient client = instance;
 
        Assert.Equal(0, instance.OutgoingInteractionSubscriberCount);
 
        _ = client.SubscribeInteractionsAsync(CancellationToken.None);
 
        Assert.Equal(1, instance.OutgoingInteractionSubscriberCount);
 
        await instance.DisposeAsync().DefaultTimeout();
 
        Assert.Equal(0, instance.OutgoingInteractionSubscriberCount);
    }
 
    [Fact]
    public async Task WhenConnected_InteractionMethodUnimplemented_InteractionWatchCompleted()
    {
        await using var instance = CreateResourceServiceClient();
        instance.SetDashboardServiceClient(new MockDashboardServiceClient());
 
        await instance.WhenConnected.DefaultTimeout();
 
        await instance.InteractionWatchCompleteTask.DefaultTimeout();
    }
 
    [Fact]
    public async Task ConnectionState_InitialState_IsConnecting()
    {
        await using var instance = CreateResourceServiceClient();
 
        IDashboardClient client = instance;
 
        Assert.Equal(DashboardConnectionState.Connecting, client.ConnectionState);
    }
 
    [Fact]
    public async Task WhenConnected_AmbientActivity_DoesNotFlowToConnection()
    {
        await using var instance = CreateResourceServiceClient();
        var serviceClient = new MockDashboardServiceClient();
        instance.SetDashboardServiceClient(serviceClient);
 
        using var activity = new Activity("request").Start();
 
        await instance.WhenConnected.DefaultTimeout();
 
        Assert.Null(serviceClient.ActivityOnGetApplicationInformation);
    }
 
    [Fact]
    public async Task WatchResources_ResponseCreatesActivity()
    {
        using var activitySource = new DashboardActivitySource();
        await using var instance = CreateResourceServiceClient(activitySource);
        instance.SetDashboardServiceClient(new MockDashboardServiceClient
        {
            ResourceUpdates =
            [
                new WatchResourcesUpdate { InitialData = new InitialResourceData() },
                new WatchResourcesUpdate { Changes = new WatchResourcesChanges() }
            ]
        });
        var activities = new ConcurrentQueue<Activity>();
        var activitiesReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        using var listener = ActivityListenerHelper.Create(activitySource.ActivitySource, onActivityStopped: activity =>
        {
            activities.Enqueue(activity);
            if (activities.Count == 2)
            {
                activitiesReceived.TrySetResult();
            }
        });
 
        _ = instance.WhenConnected;
        await activitiesReceived.Task.DefaultTimeout();
        await instance.DisposeAsync().DefaultTimeout();
 
        Assert.Collection(
            activities,
            activity => AssertActivity(activity, WatchResourcesUpdate.KindOneofCase.InitialData),
            activity => AssertActivity(activity, WatchResourcesUpdate.KindOneofCase.Changes));
 
        static void AssertActivity(Activity activity, WatchResourcesUpdate.KindOneofCase kind)
        {
            Assert.Equal(DashboardActivitySource.ActivitySourceName, activity.Source.Name);
            Assert.Equal("Process resource update", activity.OperationName);
            Assert.Equal(ActivityKind.Consumer, activity.Kind);
            Assert.Equal(kind.ToString(), activity.GetTagItem("aspire.dashboard.resource_update.type"));
        }
    }
 
    [Fact]
    public async Task ConnectionState_SetConnected_FiresEvent()
    {
        await using var instance = CreateResourceServiceClient();
 
        IDashboardClient client = instance;
        var stateChanges = new List<DashboardConnectionState>();
        client.ConnectionStateChanged += stateChanges.Add;
 
        instance.SetConnectionStateForTesting(DashboardConnectionState.Connected);
 
        Assert.Equal(DashboardConnectionState.Connected, client.ConnectionState);
        Assert.Single(stateChanges);
        Assert.Equal(DashboardConnectionState.Connected, stateChanges[0]);
    }
 
    [Fact]
    public async Task ConnectionState_DuplicateState_DoesNotFireEvent()
    {
        await using var instance = CreateResourceServiceClient();
 
        IDashboardClient client = instance;
        var stateChanges = new List<DashboardConnectionState>();
        client.ConnectionStateChanged += stateChanges.Add;
 
        instance.SetConnectionStateForTesting(DashboardConnectionState.Connected);
        instance.SetConnectionStateForTesting(DashboardConnectionState.Connected);
 
        Assert.Single(stateChanges);
    }
 
    [Fact]
    public async Task ConnectionState_DisconnectedResetsWhenConnected()
    {
        await using var instance = CreateResourceServiceClient();
 
        IDashboardClient client = instance;
        var stateChanges = new List<DashboardConnectionState>();
        client.ConnectionStateChanged += stateChanges.Add;
 
        // Transition through Connected then to Disconnected.
        instance.SetConnectionStateForTesting(DashboardConnectionState.Connected);
        instance.SetConnectionStateForTesting(DashboardConnectionState.Disconnected);
 
        Assert.Equal(DashboardConnectionState.Disconnected, client.ConnectionState);
        Assert.Collection(stateChanges,
            s => Assert.Equal(DashboardConnectionState.Connected, s),
            s => Assert.Equal(DashboardConnectionState.Disconnected, s));
    }
 
    [Fact]
    public async Task ReconnectAsync_CancelsDelay()
    {
        await using var instance = CreateResourceServiceClient();
 
        IDashboardClient client = instance;
 
        // ReconnectAsync should not throw even when there's no active delay.
        await client.ReconnectAsync().DefaultTimeout();
    }
 
    [Fact]
    public async Task ConnectionState_ConcurrentSetSameState_FiresEventOnce()
    {
        await using var instance = CreateResourceServiceClient();
 
        IDashboardClient client = instance;
        var eventCount = 0;
        client.ConnectionStateChanged += _ => Interlocked.Increment(ref eventCount);
 
        // Simulate concurrent calls from both watch tasks transitioning to Disconnected.
        var tasks = Enumerable.Range(0, 10).Select(_ => Task.Run(() =>
        {
            instance.SetConnectionStateForTesting(DashboardConnectionState.Disconnected);
        }));
        await Task.WhenAll(tasks).DefaultTimeout();
 
        // The event should fire exactly once because the lock prevents duplicate transitions.
        Assert.Equal(1, eventCount);
    }
 
    [Fact]
    public async Task WatchWithRecovery_RepeatedFailures_FiresMultipleDisconnectedEvents()
    {
        await using var instance = CreateResourceServiceClient();
        instance.SetDashboardServiceClient(new MockDashboardServiceClient { FailOnWatchResources = true });
 
        IDashboardClient client = instance;
        var disconnectedCount = 0;
        var disconnectedSemaphore = new SemaphoreSlim(0);
        client.ConnectionStateChanged += state =>
        {
            if (state == DashboardConnectionState.Disconnected)
            {
                Interlocked.Increment(ref disconnectedCount);
                disconnectedSemaphore.Release();
            }
        };
 
        // Trigger the connection. ConnectWithRetryAsync succeeds, then WatchResources starts failing.
        await instance.WhenConnected.DefaultTimeout();
 
        // Wait for at least 3 Disconnected events to prove each retry fires a new event.
        // Without the Connecting transition between retries, only 1 Disconnected event would fire.
        for (var i = 0; i < 3; i++)
        {
            await disconnectedSemaphore.WaitAsync().DefaultTimeout();
        }
 
        Assert.True(disconnectedCount >= 3, $"Expected at least 3 Disconnected events but got {disconnectedCount}.");
    }
 
    [Fact]
    public async Task ConnectWithRetry_LogsErrorWithTroubleshootingLink()
    {
        var testSink = new TestSink();
        _loggerFactory.AddProvider(new TestLoggerProvider(testSink));
 
        await using var instance = CreateResourceServiceClient();
        instance.SetDashboardServiceClient(new MockDashboardServiceClient { FailOnGetApplicationInformation = true });
 
        IDashboardClient client = instance;
        var disconnectedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        client.ConnectionStateChanged += state =>
        {
            if (state == DashboardConnectionState.Disconnected)
            {
                disconnectedTcs.TrySetResult();
            }
        };
 
        // Trigger the connection attempt which will fail on GetApplicationInformationAsync.
        _ = client.WhenConnected;
 
        // Wait for the first Disconnected event which means the error has been logged.
        await disconnectedTcs.Task.DefaultTimeout();
 
        var errorLog = testSink.Writes.FirstOrDefault(w => w.LogLevel == LogLevel.Error);
        Assert.NotNull(errorLog);
        Assert.Contains("https://aka.ms/aspire/dashboard-apphost-connection-failed", errorLog.Message);
    }
 
    [Fact]
    public async Task ConnectWithRetry_UnsupportedDashboardVersion_SetsUnsupportedState()
    {
        await using var instance = CreateResourceServiceClient();
        instance.SetDashboardServiceClient(new MockDashboardServiceClient { MinDashboardVersion = "99.0.0" });
 
        IDashboardClient client = instance;
        var unsupportedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        client.ConnectionStateChanged += state =>
        {
            if (state == DashboardConnectionState.Unsupported)
            {
                unsupportedTcs.TrySetResult();
            }
        };
 
        _ = client.WhenConnected;
 
        await unsupportedTcs.Task.DefaultTimeout();
 
        Assert.Equal(DashboardConnectionState.Unsupported, client.ConnectionState);
        Assert.False(client.WhenConnected.IsCompleted);
    }
 
    [Theory]
    [InlineData("13.5.0", "13.5.0", true)]
    [InlineData("13.5.0-dev", "13.5.0", true)]
    [InlineData("13.5.0-preview.1.26307.2", "13.5.0", true)]
    [InlineData("13.6.0", "13.5.0", true)]
    [InlineData("14.0.0", "13.5.0", true)]
    [InlineData("13.5.1", "13.5.0", true)]
    [InlineData("13.4.0", "13.5.0", false)]
    [InlineData("13.4.9", "13.5.0", false)]
    [InlineData("12.0.0", "13.5.0", false)]
    [InlineData("13.5.0-dev", "13.5.1", false)]
    [InlineData("13.5.0", null, true)]
    [InlineData("13.5.0", "", true)]
    [InlineData(null, "13.5.0", false)]
    [InlineData(null, null, true)]
    [InlineData(null, "", true)]
    public void IsDashboardVersionSufficient_ReturnsExpectedResult(string? dashboardVersion, string? requiredVersion, bool expected)
    {
        var dashboard = dashboardVersion is not null ? SemVersion.Parse(dashboardVersion, SemVersionStyles.Any) : null;
 
        var result = DashboardClient.IsDashboardVersionSufficient(dashboard, requiredVersion);
 
        Assert.Equal(expected, result);
    }
 
    [Theory]
    [InlineData("")]
    [InlineData("0.0.0")]
    [InlineData("1.0.0")]
    public async Task ConnectWithRetry_CompatibleMinVersion_SetsConnectedState(string minDashboardVersion)
    {
        await using var instance = CreateResourceServiceClient();
        instance.SetDashboardServiceClient(new MockDashboardServiceClient { MinDashboardVersion = minDashboardVersion });
 
        IDashboardClient client = instance;
        var connectedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        client.ConnectionStateChanged += state =>
        {
            if (state == DashboardConnectionState.Connected)
            {
                connectedTcs.TrySetResult();
            }
        };
 
        _ = client.WhenConnected;
 
        await connectedTcs.Task.DefaultTimeout();
 
        Assert.Equal(DashboardConnectionState.Connected, client.ConnectionState);
    }
 
    [Fact]
    public async Task ExecuteResourceCommandAsync_AppHostUnavailable_ReturnsClearFailure()
    {
        await using var instance = CreateResourceServiceClient();
        instance.SetDashboardServiceClient(new MockDashboardServiceClient { FailOnExecuteResourceCommand = true });
 
        var response = await instance.ExecuteResourceCommandAsync(
            "api",
            "Project",
            CreateCommand(),
            new ExecuteResourceCommandOptions(),
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(Aspire.Dashboard.Model.ResourceCommandResponseKind.Failed, response.Kind);
        Assert.Equal("Localized:ResourceCommandAppHostDisconnected", response.Message);
        Assert.Equal(response.Message, response.ErrorMessage);
    }
 
    [Fact]
    public async Task ExecuteResourceCommandAsync_ClientCancellation_ReturnsAppHostDisconnectedFailure()
    {
        await using var instance = CreateResourceServiceClient();
        instance.SetDashboardServiceClient(new MockDashboardServiceClient { CancelExecuteResourceCommandOnCallCancellation = true });
 
        var commandTask = instance.ExecuteResourceCommandAsync(
            "api",
            "Project",
            CreateCommand(),
            new ExecuteResourceCommandOptions(),
            CancellationToken.None);
 
        await instance.DisposeAsync().DefaultTimeout();
 
        var response = await commandTask.DefaultTimeout();
 
        Assert.Equal(Aspire.Dashboard.Model.ResourceCommandResponseKind.Failed, response.Kind);
        Assert.Equal("Localized:ResourceCommandAppHostDisconnected", response.Message);
        Assert.Equal(response.Message, response.ErrorMessage);
    }
 
    private sealed class MockDashboardServiceClient : Aspire.DashboardService.Proto.V1.DashboardService.DashboardServiceClient
    {
        public bool FailOnWatchResources { get; init; }
        public bool FailOnGetApplicationInformation { get; init; }
        public bool FailOnExecuteResourceCommand { get; init; }
        public bool CancelExecuteResourceCommandOnCallCancellation { get; init; }
        public string MinDashboardVersion { get; init; } = "";
        public IReadOnlyList<WatchResourceConsoleLogsUpdate> ConsoleLogUpdates { get; init; } = [];
        public IReadOnlyList<WatchResourcesUpdate> ResourceUpdates { get; init; } = [];
        public Activity? ActivityOnGetApplicationInformation { get; private set; }
        private int _resourceUpdatesReturned;
 
        public override AsyncServerStreamingCall<WatchResourceConsoleLogsUpdate> WatchResourceConsoleLogs(WatchResourceConsoleLogsRequest request, CallOptions options)
        {
            return new AsyncServerStreamingCall<WatchResourceConsoleLogsUpdate>(
                new AsyncStreamReader<WatchResourceConsoleLogsUpdate>(ConsoleLogUpdates),
                Task.FromResult(new Metadata()),
                () => Status.DefaultSuccess,
                () => new Metadata(),
                () => { });
        }
 
        public override AsyncDuplexStreamingCall<WatchInteractionsRequestUpdate, WatchInteractionsResponseUpdate> WatchInteractions(CallOptions options)
        {
            return new AsyncDuplexStreamingCall<WatchInteractionsRequestUpdate, WatchInteractionsResponseUpdate>(
                new ClientStreamWriter<WatchInteractionsRequestUpdate>(),
                new AsyncStreamReader<WatchInteractionsResponseUpdate>(),
                Task.FromResult(new Metadata()),
                () => new Status(StatusCode.Unimplemented, "Unimplemented!"),
                () => new Metadata(),
                () => { });
        }
 
        public override AsyncUnaryCall<ApplicationInformationResponse> GetApplicationInformationAsync(ApplicationInformationRequest request, CallOptions options)
        {
            ActivityOnGetApplicationInformation = Activity.Current;
            if (FailOnGetApplicationInformation)
            {
                return new AsyncUnaryCall<ApplicationInformationResponse>(
                    Task.FromException<ApplicationInformationResponse>(new RpcException(new Status(StatusCode.Unavailable, "Service unavailable"))),
                    Task.FromResult(new Metadata()),
                    () => new Status(StatusCode.Unavailable, "Service unavailable"),
                    () => new Metadata(),
                    () => { });
            }
 
            return new AsyncUnaryCall<ApplicationInformationResponse>(
                Task.FromResult(new ApplicationInformationResponse
                {
                    ApplicationName = "TestApplication",
                    MinDashboardVersion = MinDashboardVersion
                }),
                Task.FromResult(new Metadata()),
                () => Status.DefaultSuccess,
                () => new Metadata(),
                () => { });
        }
 
        public override AsyncUnaryCall<ResourceCommandResponse> ExecuteResourceCommandAsync(ResourceCommandRequest request, CallOptions options)
        {
            if (CancelExecuteResourceCommandOnCallCancellation)
            {
                return new AsyncUnaryCall<ResourceCommandResponse>(
                    WaitForCallCancellationAsync(options.CancellationToken),
                    Task.FromResult(new Metadata()),
                    () => new Status(StatusCode.Cancelled, "Cancelled"),
                    () => new Metadata(),
                    () => { });
            }
 
            if (FailOnExecuteResourceCommand)
            {
                return new AsyncUnaryCall<ResourceCommandResponse>(
                    Task.FromException<ResourceCommandResponse>(new RpcException(new Status(StatusCode.Unavailable, "Service unavailable"))),
                    Task.FromResult(new Metadata()),
                    () => new Status(StatusCode.Unavailable, "Service unavailable"),
                    () => new Metadata(),
                    () => { });
            }
 
            return new AsyncUnaryCall<ResourceCommandResponse>(
                Task.FromResult(new ResourceCommandResponse
                {
                    Kind = Aspire.DashboardService.Proto.V1.ResourceCommandResponseKind.Succeeded
                }),
                Task.FromResult(new Metadata()),
                () => Status.DefaultSuccess,
                () => new Metadata(),
                () => { });
        }
 
        private static async Task<ResourceCommandResponse> WaitForCallCancellationAsync(CancellationToken cancellationToken)
        {
            try
            {
                await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
            }
            catch (OperationCanceledException)
            {
                throw new RpcException(new Status(StatusCode.Cancelled, "Cancelled"));
            }
 
            throw new InvalidOperationException("The command should only complete when the call is canceled.");
        }
 
        public override AsyncServerStreamingCall<WatchResourcesUpdate> WatchResources(WatchResourcesRequest request, CallOptions options)
        {
            var reader = FailOnWatchResources
                ? (IAsyncStreamReader<WatchResourcesUpdate>)new FailingAsyncStreamReader<WatchResourcesUpdate>()
                : new AsyncStreamReader<WatchResourcesUpdate>(Interlocked.Exchange(ref _resourceUpdatesReturned, 1) == 0 ? ResourceUpdates : []);
 
            return new AsyncServerStreamingCall<WatchResourcesUpdate>(
                reader,
                Task.FromResult(new Metadata()),
                () => Status.DefaultSuccess,
                () => new Metadata(),
                () => { });
        }
    }
 
    private sealed class FailingAsyncStreamReader<T> : IAsyncStreamReader<T>
    {
        public T Current { get; } = default!;
 
        public Task<bool> MoveNext(CancellationToken cancellationToken)
        {
            throw new RpcException(new Status(StatusCode.Unavailable, "Service unavailable"));
        }
    }
 
    private sealed class AsyncStreamReader<T> : IAsyncStreamReader<T>
    {
        private readonly Queue<T> _items;
 
        public AsyncStreamReader(IEnumerable<T>? items = null)
        {
            _items = new Queue<T>(items ?? []);
        }
 
        public T Current { get; private set; } = default!;
 
        public Task<bool> MoveNext(CancellationToken cancellationToken)
        {
            if (_items.TryDequeue(out var item))
            {
                Current = item;
                return Task.FromResult(true);
            }
 
            return Task.FromResult(false);
        }
    }
 
    private sealed class RecordingResourceRepositoryWriter : IResourceRepositoryWriter
    {
        public List<(string ResourceName, IReadOnlyList<ConsoleLogLine> LogLines)> ConsoleLogs { get; } = [];
        public List<string> LoadedConsoleLogs { get; } = [];
 
        public Task ReplaceResourcesAsync(IReadOnlyList<Resource> resources)
        {
            return Task.CompletedTask;
        }
 
        public Task ApplyChangesAsync(IReadOnlyList<WatchResourcesChange> changes)
        {
            return Task.CompletedTask;
        }
 
        public Task MarkConsoleLogsLoadedAsync(string resourceName)
        {
            LoadedConsoleLogs.Add(resourceName);
            return Task.CompletedTask;
        }
 
        public Task AddConsoleLogsAsync(string resourceName, IReadOnlyList<ConsoleLogLine> logLines)
        {
            ConsoleLogs.Add((resourceName, logLines));
            return Task.CompletedTask;
        }
 
        public Task ClearConsoleLogsAsync(IReadOnlyList<string> resourceNames, DateTime clearDate) => Task.CompletedTask;
    }
 
    private sealed class ClientStreamWriter<T> : IClientStreamWriter<T>
    {
        public WriteOptions? WriteOptions { get; set; }
 
        public Task CompleteAsync()
        {
            throw new NotImplementedException();
        }
 
        public Task WriteAsync(T message)
        {
            throw new NotImplementedException();
        }
    }
 
    private DashboardClient CreateResourceServiceClient(
        DashboardActivitySource? activitySource = null,
        IResourceRepositoryWriter? resourceRepositoryWriter = null)
    {
        return CreateResourceServiceClient(_loggerFactory, activitySource, resourceRepositoryWriter);
    }
 
    private static DashboardClient CreateResourceServiceClient(
        ILoggerFactory loggerFactory,
        DashboardActivitySource? activitySource,
        IResourceRepositoryWriter? resourceRepositoryWriter)
    {
        var options = new DashboardOptions
        {
            ResourceServiceClient =
            {
                AuthMode = ResourceClientAuthMode.Unsecured,
                Url = "http://localhost:12345"
            }
        };
        options.ResourceServiceClient.TryParseOptions(out _);
 
        return new DashboardClient(
            activitySource ?? new DashboardActivitySource(),
            loggerFactory,
            new ConfigurationManager(),
            Options.Create(options),
            new MockKnownPropertyLookup(),
            new TestStringLocalizer<DashboardResources>(),
            resourceRepositoryWriter: resourceRepositoryWriter ?? new RecordingResourceRepositoryWriter());
    }
 
    public void Dispose()
    {
        _loggerFactory.Dispose();
    }
 
    private static CommandViewModel CreateCommand()
    {
        return new CommandViewModel(
            "restart",
            CommandViewModelState.Enabled,
            "Restart",
            "Restart API",
            confirmationMessage: string.Empty,
            [],
            isHighlighted: false,
            iconName: string.Empty,
            iconVariant: Microsoft.FluentUI.AspNetCore.Components.IconVariant.Regular);
    }
}