// 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.Collections.Immutable;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.ServiceClient;
using Aspire.DashboardService.Proto.V1;
using Google.Protobuf.WellKnownTypes;
namespace Aspire.Dashboard.Tests.Shared;
public class TestDashboardClient : IDashboardClient
{
private DashboardConnectionState _connectionState = DashboardConnectionState.Connected;
private readonly Func<string, Channel<IReadOnlyList<ResourceLogLine>>>? _consoleLogsChannelProvider;
private readonly Func<Channel<IReadOnlyList<ResourceViewModelChange>>>? _resourceChannelProvider;
private readonly Func<Channel<WatchInteractionsResponseUpdate>>? _interactionChannelProvider;
private readonly Func<Channel<WatchTerminalsUpdate>>? _terminalChannelProvider;
private readonly Func<string, CancellationToken, Task>? _closeTerminal;
private readonly Func<string, CancellationToken, Task<Stream>>? _attachTerminal;
private readonly Channel<ResourceCommandResponseViewModel>? _resourceCommandsChannel;
private readonly Func<string, string, CommandViewModel, ExecuteResourceCommandOptions, CancellationToken, Task<ResourceCommandResponseViewModel>>? _executeResourceCommand;
private readonly Channel<WatchInteractionsRequestUpdate>? _sendInteractionUpdateChannel;
private readonly IList<ResourceViewModel>? _initialResources;
private int _terminalSubscriptionCount;
private int _activeTerminalSubscriptionCount;
private int _resourceSubscriptionCount;
private int _getResourceCallCount;
private int _getResourcesCallCount;
public bool IsEnabled { get; }
public bool IsReadOnly { get; set; }
public Task WhenConnected { get; }
public Task WhenResourcesReady { get; }
public string ApplicationName { get; } = "TestApp";
public string? MinRequiredVersion => null;
public DashboardConnectionState ConnectionState => _connectionState;
public ConcurrentQueue<(IReadOnlyList<string> ResourceNames, DateTime ClearDate)> ClearedConsoleLogs { get; } = new();
public ConcurrentQueue<string> ClosedTerminals { get; } = new();
public Action? OnTerminalSubscriptionDisposed { get; set; }
public Action? OnResourceSubscriptionDisposed { get; set; }
public Action<string>? OnConsoleSubscriptionDisposed { get; set; }
public Func<string, CancellationToken, Task>? BeforeConsoleLogBatchAsync { get; set; }
public Func<WatchTerminalsUpdate, Task>? BeforeTerminalUpdateAsync { get; set; }
public Func<CancellationToken, Task>? BeforeResourceSubscriptionAsync { get; set; }
public Action<WatchTerminalsUpdate>? OnTerminalUpdateProcessed { get; set; }
public int TerminalSubscriptionCount => Volatile.Read(ref _terminalSubscriptionCount);
public int ActiveTerminalSubscriptionCount => Volatile.Read(ref _activeTerminalSubscriptionCount);
public int ResourceSubscriptionCount => Volatile.Read(ref _resourceSubscriptionCount);
public int GetResourceCallCount => Volatile.Read(ref _getResourceCallCount);
public int GetResourcesCallCount => Volatile.Read(ref _getResourcesCallCount);
public event Action<DashboardConnectionState>? ConnectionStateChanged;
public Task ReconnectAsync() => Task.CompletedTask;
public TestDashboardClient(
bool? isEnabled = false,
string? applicationName = null,
Func<string, Channel<IReadOnlyList<ResourceLogLine>>>? consoleLogsChannelProvider = null,
Func<Channel<IReadOnlyList<ResourceViewModelChange>>>? resourceChannelProvider = null,
Func<Channel<WatchInteractionsResponseUpdate>>? interactionChannelProvider = null,
Channel<ResourceCommandResponseViewModel>? resourceCommandsChannel = null,
Func<string, string, CommandViewModel, ExecuteResourceCommandOptions, CancellationToken, Task<ResourceCommandResponseViewModel>>? executeResourceCommand = null,
Channel<WatchInteractionsRequestUpdate>? sendInteractionUpdateChannel = null,
IList<ResourceViewModel>? initialResources = null,
Task? whenConnected = null,
bool isReadOnly = false,
Func<Channel<WatchTerminalsUpdate>>? terminalChannelProvider = null,
Func<string, CancellationToken, Task>? closeTerminal = null,
Func<string, CancellationToken, Task<Stream>>? attachTerminal = null,
Task? whenResourcesReady = null)
{
IsEnabled = isEnabled ?? false;
IsReadOnly = isReadOnly;
ApplicationName = applicationName ?? "TestApp";
WhenConnected = whenConnected ?? Task.CompletedTask;
WhenResourcesReady = whenResourcesReady ?? WhenConnected;
_consoleLogsChannelProvider = consoleLogsChannelProvider;
_resourceChannelProvider = resourceChannelProvider;
_interactionChannelProvider = interactionChannelProvider;
_resourceCommandsChannel = resourceCommandsChannel;
_executeResourceCommand = executeResourceCommand;
_sendInteractionUpdateChannel = sendInteractionUpdateChannel;
_initialResources = initialResources;
_terminalChannelProvider = terminalChannelProvider;
_closeTerminal = closeTerminal;
_attachTerminal = attachTerminal;
}
public ValueTask DisposeAsync()
{
return default;
}
public void SetConnectionState(DashboardConnectionState state)
{
_connectionState = state;
ConnectionStateChanged?.Invoke(state);
}
public Task<ResourceCommandResponseViewModel> ExecuteResourceCommandAsync(string resourceName, string resourceType, CommandViewModel command, ExecuteResourceCommandOptions options, CancellationToken cancellationToken)
{
if (_executeResourceCommand is not null)
{
return _executeResourceCommand(resourceName, resourceType, command, options, cancellationToken);
}
if (_resourceCommandsChannel == null)
{
throw new InvalidOperationException("No resource command channel set.");
}
return _resourceCommandsChannel.Reader.ReadAsync(cancellationToken).AsTask();
}
public Task<string> UploadFileAsync(Stream fileStream, string fileName, long expectedSize, int interactionId, string inputName, CancellationToken cancellationToken)
{
return Task.FromResult(Guid.NewGuid().ToString("N"));
}
public Task<Stream> AttachTerminalAsync(string terminalId, CancellationToken cancellationToken)
{
return _attachTerminal?.Invoke(terminalId, cancellationToken) ?? Task.FromResult<Stream>(new MemoryStream());
}
public async IAsyncEnumerable<WatchTerminalsUpdate> SubscribeTerminalsAsync([EnumeratorCancellation] CancellationToken cancellationToken)
{
Interlocked.Increment(ref _terminalSubscriptionCount);
Interlocked.Increment(ref _activeTerminalSubscriptionCount);
try
{
if (_terminalChannelProvider is { } provider)
{
await foreach (var update in provider().Reader.ReadAllAsync(cancellationToken))
{
// Allow a test to hold an already-received update while its subscriber is being replaced.
if (BeforeTerminalUpdateAsync is { } beforeUpdate)
{
await beforeUpdate(update);
}
yield return update;
// Resuming after yield confirms the subscriber finished handling this update, even if it
// intentionally did not render. Tests can synchronize without depending on UI side effects.
OnTerminalUpdateProcessed?.Invoke(update);
}
}
}
finally
{
Interlocked.Decrement(ref _activeTerminalSubscriptionCount);
OnTerminalSubscriptionDisposed?.Invoke();
}
}
public Task CloseTerminalAsync(string terminalId, CancellationToken cancellationToken)
{
ClosedTerminals.Enqueue(terminalId);
return _closeTerminal?.Invoke(terminalId, cancellationToken) ?? Task.CompletedTask;
}
public async IAsyncEnumerable<IReadOnlyList<ResourceLogLine>> SubscribeConsoleLogs(string resourceName, [EnumeratorCancellation] CancellationToken cancellationToken)
{
if (_consoleLogsChannelProvider == null)
{
throw new InvalidOperationException("No channel provider set.");
}
var channel = _consoleLogsChannelProvider(resourceName);
try
{
await foreach (var item in channel.Reader.ReadAllAsync(cancellationToken))
{
if (BeforeConsoleLogBatchAsync is { } beforeBatch)
{
await beforeBatch(resourceName, cancellationToken);
}
yield return item;
}
}
finally
{
OnConsoleSubscriptionDisposed?.Invoke(resourceName);
}
}
public async IAsyncEnumerable<IReadOnlyList<ResourceLogLine>> GetConsoleLogs(string resourceName, [EnumeratorCancellation] CancellationToken cancellationToken)
{
if (_consoleLogsChannelProvider == null)
{
throw new InvalidOperationException("No channel provider set.");
}
var channel = _consoleLogsChannelProvider(resourceName);
await foreach (var item in channel.Reader.ReadAllAsync(cancellationToken))
{
yield return item;
}
}
public Task ClearConsoleLogsAsync(IReadOnlyList<string> resourceNames, DateTime clearDate)
{
ClearedConsoleLogs.Enqueue((resourceNames, clearDate));
return Task.CompletedTask;
}
public async Task<ResourceViewModelSubscription> SubscribeResourcesAsync(CancellationToken cancellationToken)
{
if (_resourceChannelProvider == null)
{
throw new InvalidOperationException("No channel provider set.");
}
if (BeforeResourceSubscriptionAsync is { } beforeSubscription)
{
await beforeSubscription(cancellationToken);
}
var channel = _resourceChannelProvider();
Interlocked.Increment(ref _resourceSubscriptionCount);
return new ResourceViewModelSubscription(_initialResources?.ToImmutableArray() ?? [], BuildSubscription(channel, cancellationToken));
async IAsyncEnumerable<IReadOnlyList<ResourceViewModelChange>> BuildSubscription(Channel<IReadOnlyList<ResourceViewModelChange>> channel, [EnumeratorCancellation] CancellationToken cancellationToken)
{
try
{
await foreach (var item in channel.Reader.ReadAllAsync(cancellationToken))
{
yield return item;
}
}
finally
{
OnResourceSubscriptionDisposed?.Invoke();
}
}
}
public IAsyncEnumerable<WatchInteractionsResponseUpdate> SubscribeInteractionsAsync(CancellationToken cancellationToken)
{
if (_interactionChannelProvider == null)
{
throw new InvalidOperationException("No channel provider set.");
}
var channel = _interactionChannelProvider();
return BuildSubscription(channel, cancellationToken);
async static IAsyncEnumerable<WatchInteractionsResponseUpdate> BuildSubscription(Channel<WatchInteractionsResponseUpdate> channel, [EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var item in channel.Reader.ReadAllAsync(cancellationToken))
{
yield return item;
}
}
}
public async Task SendInteractionRequestAsync(WatchInteractionsRequestUpdate request, CancellationToken cancellationToken)
{
if (_sendInteractionUpdateChannel == null)
{
throw new InvalidOperationException("No resource command channel set.");
}
await _sendInteractionUpdateChannel.Writer.WriteAsync(request, cancellationToken);
}
public ResourceViewModel? GetResource(string resourceName)
{
Interlocked.Increment(ref _getResourceCallCount);
return _initialResources?.FirstOrDefault(resource => StringComparers.ResourceName.Equals(resource.Name, resourceName));
}
public IReadOnlyList<ResourceViewModel> GetResources()
{
Interlocked.Increment(ref _getResourcesCallCount);
return _initialResources?.ToList() ?? [];
}
}