File: TestServices\TestAppHostAuxiliaryBackchannel.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.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.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Cli.Backchannel;
using ModelContextProtocol.Protocol;
 
namespace Aspire.Cli.Tests.TestServices;
 
/// <summary>
/// A test implementation of IAppHostAuxiliaryBackchannel for unit testing.
/// </summary>
internal sealed class TestAppHostAuxiliaryBackchannel : IAppHostAuxiliaryBackchannel
{
    private int _getResourceSnapshotsCallCount;
    private int _lastGetResourceSnapshotsIncludeHidden = -1;
 
    public string Hash { get; set; } = "test-hash";
    public string SocketPath { get; set; } = "/tmp/test.sock";
    public AppHostInformation? AppHostInfo { get; set; }
    public bool IsInScope { get; set; } = true;
    public DateTimeOffset ConnectedAt { get; set; } = DateTimeOffset.UtcNow;
    public bool SupportsV2 { get; set; } = true;
    public bool SupportsV3 { get; set; }
    public bool SupportsTerminalsV1 { get; set; } = true;
    public bool SupportsResourceSnapshotVersionsV1 { get; set; }
 
    /// <summary>
    /// Gets or sets the resource snapshots to return from GetResourceSnapshotsAsync and WatchResourceSnapshotsAsync.
    /// </summary>
    public List<ResourceSnapshot> ResourceSnapshots { get; set; } = [];
 
    /// <summary>
    /// Gets or sets the dashboard URLs state to return from GetDashboardUrlsAsync.
    /// </summary>
    public DashboardUrlsState? DashboardUrlsState { get; set; }
 
    /// <summary>
    /// Gets or sets the AppHost info response to return from GetAppHostInfoV2Async.
    /// </summary>
    public GetAppHostInfoResponse? AppHostInfoResponse { get; set; }
 
    public WaitForAppHostReadyResponse? WaitForAppHostReadyResponse { get; set; }
    public Func<CancellationToken, Task<WaitForAppHostReadyResponse?>>? WaitForAppHostReadyHandler { get; set; }
 
    /// <summary>
    /// Gets or sets the log lines to return from GetResourceLogsAsync.
    /// </summary>
    public List<ResourceLogLine> LogLines { get; set; } = [];
 
    /// <summary>
    /// Gets or sets the result to return from StopAppHostAsync.
    /// </summary>
    public bool StopAppHostResult { get; set; } = true;
 
    /// <summary>
    /// Gets or sets the function to call when CallResourceMcpToolAsync is invoked.
    /// </summary>
    public Func<string, string, IReadOnlyDictionary<string, JsonElement>?, CancellationToken, Task<CallToolResult>>? CallResourceMcpToolHandler { get; set; }
 
    /// <summary>
    /// Gets or sets the function to call when GetResourceSnapshotsAsync is invoked.
    /// If null, returns the ResourceSnapshots list.
    /// </summary>
    public Func<CancellationToken, Task<List<ResourceSnapshot>>>? GetResourceSnapshotsHandler { get; set; }
 
    /// <summary>
    /// Gets the number of snapshot requests made through this backchannel.
    /// </summary>
    public int GetResourceSnapshotsCallCount => Volatile.Read(ref _getResourceSnapshotsCallCount);
 
    /// <summary>
    /// Gets the include-hidden value from the latest snapshot request.
    /// </summary>
    public bool? LastGetResourceSnapshotsIncludeHidden => Volatile.Read(ref _lastGetResourceSnapshotsIncludeHidden) switch
    {
        0 => false,
        1 => true,
        _ => null
    };
 
    /// <summary>
    /// Gets or sets the function to call when WatchResourceSnapshotsAsync is invoked.
    /// If null, yields the ResourceSnapshots list.
    /// </summary>
    public Func<bool, CancellationToken, IAsyncEnumerable<ResourceSnapshot>>? WatchResourceSnapshotsHandler { get; set; }
 
    /// <summary>
    /// Gets or sets the function to call when GetResourceLogsAsync is invoked.
    /// If null, yields the LogLines list.
    /// </summary>
    public Func<string?, bool, CancellationToken, IAsyncEnumerable<ResourceLogLine>>? GetResourceLogsHandler { get; set; }
 
    /// <summary>
    /// Gets or sets the function to call when GetConsoleLogsAsync is invoked.
    /// If null, falls back to GetResourceLogsAsync.
    /// </summary>
    public Func<GetConsoleLogsRequest, CancellationToken, IAsyncEnumerable<ResourceLogLine>>? GetConsoleLogsHandler { get; set; }
 
    /// <summary>
    /// Gets or sets the function to call when GetConsoleLogBatchesAsync is invoked.
    /// If null, falls back to GetConsoleLogsAsync or GetResourceLogsAsync.
    /// </summary>
    public Func<GetConsoleLogsRequest, CancellationToken, IAsyncEnumerable<ResourceLogBatch>>? GetConsoleLogBatchesHandler { get; set; }
 
    public Task<DashboardUrlsState?> GetDashboardUrlsAsync(CancellationToken cancellationToken = default)
    {
        return Task.FromResult(DashboardUrlsState);
    }
 
    public Task<GetAppHostInfoResponse?> GetAppHostInfoV2Async(CancellationToken cancellationToken = default)
    {
        _ = cancellationToken;
 
        if (AppHostInfoResponse is not null)
        {
            return Task.FromResult<GetAppHostInfoResponse?>(AppHostInfoResponse);
        }
 
        if (AppHostInfo is null)
        {
            return Task.FromResult<GetAppHostInfoResponse?>(null);
        }
 
        return Task.FromResult<GetAppHostInfoResponse?>(new GetAppHostInfoResponse
        {
            Pid = AppHostInfo.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture),
            AspireHostVersion = "unknown",
            AppHostPath = AppHostInfo.AppHostPath,
            CliProcessId = AppHostInfo.CliProcessId,
            StartedAt = AppHostInfo.StartedAt,
            CliLogFilePath = AppHostInfo.CliLogFilePath
        });
    }
 
    public Task<WaitForAppHostReadyResponse?> WaitForAppHostReadyAsync(CancellationToken cancellationToken = default)
    {
        if (!SupportsV3)
        {
            return Task.FromResult<WaitForAppHostReadyResponse?>(null);
        }
 
        if (WaitForAppHostReadyHandler is not null)
        {
            return WaitForAppHostReadyHandler(cancellationToken);
        }
 
        return Task.FromResult(WaitForAppHostReadyResponse);
    }
 
    public Task<List<ResourceSnapshot>> GetResourceSnapshotsAsync(bool includeHidden, CancellationToken cancellationToken = default)
    {
        Interlocked.Increment(ref _getResourceSnapshotsCallCount);
        Interlocked.Exchange(ref _lastGetResourceSnapshotsIncludeHidden, includeHidden ? 1 : 0);
 
        if (GetResourceSnapshotsHandler is not null)
        {
            return GetResourceSnapshotsHandler(cancellationToken);
        }
 
        var snapshots = includeHidden
            ? ResourceSnapshots
            : ResourceSnapshots.Where(s => !ResourceSnapshotMapper.IsHiddenResource(s)).ToList();
        return Task.FromResult(snapshots);
    }
 
    public async IAsyncEnumerable<ResourceSnapshot> WatchResourceSnapshotsAsync(bool includeHidden, [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        if (WatchResourceSnapshotsHandler is not null)
        {
            await foreach (var snapshot in WatchResourceSnapshotsHandler(includeHidden, cancellationToken).WithCancellation(cancellationToken).ConfigureAwait(false))
            {
                yield return snapshot;
            }
            yield break;
        }
 
        foreach (var snapshot in ResourceSnapshots)
        {
            if (!includeHidden && ResourceSnapshotMapper.IsHiddenResource(snapshot))
            {
                continue;
            }
 
            yield return snapshot;
        }
        await Task.CompletedTask;
    }
 
    public async IAsyncEnumerable<ResourceLogLine> GetResourceLogsAsync(
        string? resourceName = null,
        bool follow = false,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        if (GetResourceLogsHandler is not null)
        {
            await foreach (var line in GetResourceLogsHandler(resourceName, follow, cancellationToken).WithCancellation(cancellationToken).ConfigureAwait(false))
            {
                yield return line;
            }
            yield break;
        }
 
        var lines = resourceName is null
            ? LogLines
            : LogLines.Where(l => string.Equals(l.ResourceName, resourceName, StringComparison.OrdinalIgnoreCase)
                                || l.ResourceName.StartsWith(resourceName + "-", StringComparison.OrdinalIgnoreCase));
 
        foreach (var line in lines)
        {
            yield return line;
        }
        await Task.CompletedTask;
    }
 
    public async IAsyncEnumerable<ResourceLogLine> GetConsoleLogsAsync(
        GetConsoleLogsRequest request,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(request);
 
        var logLines = SupportsV2 && GetConsoleLogsHandler is not null
            ? GetConsoleLogsHandler(request, cancellationToken)
            : GetResourceLogsAsync(request.ResourceName, request.Follow, cancellationToken);
 
        await foreach (var line in logLines.WithCancellation(cancellationToken).ConfigureAwait(false))
        {
            yield return line;
        }
    }
 
    public async IAsyncEnumerable<ResourceLogBatch> GetConsoleLogBatchesAsync(
        GetConsoleLogsRequest request,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(request);
 
        if (SupportsV3 && GetConsoleLogBatchesHandler is not null)
        {
            await foreach (var batch in GetConsoleLogBatchesHandler(request, cancellationToken).WithCancellation(cancellationToken).ConfigureAwait(false))
            {
                yield return batch;
            }
            yield break;
        }
 
        var logLines = request.ResourceName is null && !SupportsV3
            ? GetResourceLogsAsync(resourceName: null, follow: request.Follow, cancellationToken)
            : GetConsoleLogsAsync(request, cancellationToken);
 
        var maxBatchSize = request.Follow ? 1 : 256;
        var lines = new List<ResourceLogLine>(maxBatchSize);
        await foreach (var line in logLines.WithCancellation(cancellationToken).ConfigureAwait(false))
        {
            lines.Add(line);
 
            if (lines.Count == maxBatchSize)
            {
                yield return new ResourceLogBatch { Lines = lines.ToArray() };
                lines.Clear();
            }
        }
 
        if (lines.Count > 0)
        {
            yield return new ResourceLogBatch { Lines = lines.ToArray() };
        }
    }
 
    public Task<bool> StopAppHostAsync(CancellationToken cancellationToken = default)
    {
        return Task.FromResult(StopAppHostResult);
    }
 
    /// <summary>
    /// Gets or sets the result to return from ExecuteResourceCommandAsync.
    /// </summary>
    public ExecuteResourceCommandResponse ExecuteResourceCommandResult { get; set; } = new ExecuteResourceCommandResponse { Success = true };
 
    public JsonNode? ExecuteResourceCommandArguments { get; private set; }
 
    public ExecuteResourceCommandOptions? ExecuteResourceCommandOptions { get; private set; }
 
    public int ExecuteResourceCommandCallCount { get; private set; }
 
    public Task<ExecuteResourceCommandResponse> ExecuteResourceCommandAsync(
        string resourceName,
        string commandName,
        ExecuteResourceCommandOptions? options = null,
        CancellationToken cancellationToken = default)
    {
        ExecuteResourceCommandCallCount++;
        ExecuteResourceCommandOptions = options;
        ExecuteResourceCommandArguments = options?.Arguments;
        return Task.FromResult(ExecuteResourceCommandResult);
    }
 
    /// <summary>
    /// Gets or sets the result to return from WaitForResourceAsync.
    /// </summary>
    public WaitForResourceResponse WaitForResourceResult { get; set; } = new WaitForResourceResponse { Success = true, State = "Running" };
 
    public Func<string, string, int, CancellationToken, Task<WaitForResourceResponse>>? WaitForResourceHandler { get; set; }
 
    public Task<WaitForResourceResponse> WaitForResourceAsync(
        string resourceName,
        string status,
        int timeoutSeconds,
        CancellationToken cancellationToken = default)
    {
        if (WaitForResourceHandler is not null)
        {
            return WaitForResourceHandler(resourceName, status, timeoutSeconds, cancellationToken);
        }
 
        return Task.FromResult(WaitForResourceResult);
    }
 
    public Task<CallToolResult> CallResourceMcpToolAsync(
        string resourceName,
        string toolName,
        IReadOnlyDictionary<string, JsonElement>? arguments,
        CancellationToken cancellationToken = default)
    {
        if (CallResourceMcpToolHandler is not null)
        {
            return CallResourceMcpToolHandler(resourceName, toolName, arguments, cancellationToken);
        }
 
        return Task.FromResult(new CallToolResult
        {
            Content = [new TextContentBlock { Text = $"Mock result for {resourceName}/{toolName}" }]
        });
    }
 
    /// <summary>
    /// Gets or sets the dashboard info response to return from GetDashboardInfoV2Async.
    /// </summary>
    public GetDashboardInfoResponse? DashboardInfoResponse { get; set; }
 
    public Task<GetDashboardInfoResponse?> GetDashboardInfoV2Async(CancellationToken cancellationToken = default)
    {
        return Task.FromResult(DashboardInfoResponse);
    }
 
    /// <summary>
    /// Gets or sets the terminal info response to return from GetTerminalInfoAsync.
    /// </summary>
    public GetTerminalInfoResponse TerminalInfoResponse { get; set; } = new GetTerminalInfoResponse { IsAvailable = false };
 
    public Task<GetTerminalInfoResponse> GetTerminalInfoAsync(string resourceName, CancellationToken cancellationToken = default)
    {
        return Task.FromResult(TerminalInfoResponse);
    }
 
    /// <summary>
    /// Gets or sets the response returned by ListTerminalsAsync. Defaults to an empty list so
    /// existing tests that don't care about the new RPC don't have to set anything.
    /// </summary>
    public ListTerminalsResponse ListTerminalsResponse { get; set; } = new ListTerminalsResponse { Terminals = Array.Empty<TerminalSummary>() };
 
    public Task<ListTerminalsResponse> ListTerminalsAsync(CancellationToken cancellationToken = default)
    {
        return Task.FromResult(ListTerminalsResponse);
    }
 
    public void Dispose()
    {
        // Nothing to dispose in the test implementation
    }
}