File: TestServices\TerminalTapeTestHost.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.Collections.Concurrent;
using System.IO.Pipelines;
using System.Net.Sockets;
using System.Text;
using Hex1b;
using Hex1b.Automation;
using Microsoft.AspNetCore.InternalTesting;
 
namespace Aspire.Cli.Tests.TestServices;
 
/// <summary>
/// A real HMP resource terminal backed by controllable streams, without a shell or child process.
/// </summary>
internal sealed class TerminalTapeTestHost : IAsyncDisposable
{
    // Keep the socket outside the deeper CLI workspace to fit macOS's Unix socket path limit.
    private readonly DirectoryInfo _directory = Directory.CreateTempSubdirectory("tape-");
    private readonly CancellationTokenSource _stopping = new();
    private readonly Stream _outputReader;
    private readonly Stream _inputWriter;
    private readonly Hex1bTerminal _terminal;
    private readonly Hmp1PresentationAdapter _presentation;
    private readonly Socket _listener = new(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
    private readonly ConcurrentBag<Hmp1ClientHandle> _clients = [];
    private readonly Task _runTask;
    private readonly Task _listenTask;
    private bool _disposed;
 
    private TerminalTapeTestHost(int width, int height)
    {
        SocketPath = Path.Combine(_directory.FullName, "h.sock");
        var output = new Pipe();
        var input = new Pipe();
        _outputReader = output.Reader.AsStream();
        Output = output.Writer.AsStream();
        Input = input.Reader.AsStream();
        _inputWriter = input.Writer.AsStream();
        _presentation = new Hmp1PresentationAdapter(width, height);
        _terminal = Hex1bTerminal.CreateBuilder()
            .WithWorkload(new StreamWorkloadAdapter(_outputReader, _inputWriter))
            .WithPresentation(_presentation)
            .Build();
        _runTask = _terminal.RunAsync(_stopping.Token);
        _listener.Bind(new UnixDomainSocketEndPoint(SocketPath));
        _listener.Listen();
        _listenTask = ListenAsync();
    }
 
    public string SocketPath { get; }
    public Stream Input { get; }
    public Stream Output { get; }
    public bool IsRunning => !_runTask.IsCompleted;
 
    public static Task<TerminalTapeTestHost> StartAsync(int width = 100, int height = 30)
    {
        return Task.FromResult(new TerminalTapeTestHost(width, height));
    }
 
    private async Task ListenAsync()
    {
        while (!_stopping.IsCancellationRequested)
        {
            var socket = await _listener.AcceptAsync(_stopping.Token);
            var client = await _presentation.AddClient(new NetworkStream(socket, ownsSocket: true), _stopping.Token);
            _clients.Add(client);
        }
    }
 
    public async Task<string> ReadInputAsync(int byteCount)
    {
        var bytes = new byte[byteCount];
        await Input.ReadExactlyAsync(bytes).AsTask().DefaultTimeout();
        return Encoding.UTF8.GetString(bytes);
    }
 
    public string GetScreenText()
    {
        using var snapshot = _terminal.CreateSnapshot();
        return snapshot.GetScreenText();
    }
 
    public (int Width, int Height) GetDimensions()
    {
        using var snapshot = _terminal.CreateSnapshot();
        return (snapshot.Width, snapshot.Height);
    }
 
    public async Task WriteAsync(string text)
    {
        await Output.WriteAsync(Encoding.UTF8.GetBytes(text));
        using var snapshot = await new Hex1bTerminalInputSequenceBuilder()
            .WaitUntil(snapshot => snapshot.ContainsText(text.Trim()), TimeSpan.FromSeconds(10), "Producer output was not applied.")
            .Build()
            .ApplyAsync(_terminal, CancellationToken.None)
            .DefaultTimeout();
    }
 
    public async ValueTask DisposeAsync()
    {
        if (_disposed)
        {
            return;
        }
        _disposed = true;
        await _stopping.CancelAsync();
        try
        {
            await Task.WhenAll(_runTask, _listenTask);
        }
        catch (OperationCanceledException) when (_stopping.IsCancellationRequested)
        {
            // The test owns this producer and stops it only during cleanup.
        }
        finally
        {
            _listener.Dispose();
            foreach (var client in _clients)
            {
                await client.DisposeAsync();
            }
            await _terminal.DisposeAsync();
            await Output.DisposeAsync();
            await Input.DisposeAsync();
            await _outputReader.DisposeAsync();
            await _inputWriter.DisposeAsync();
            _stopping.Dispose();
            _directory.Delete(recursive: true);
        }
    }
}