TermRemoteCtl/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/PowerShellSessionHostTests.cs

282 lines
9.7 KiB
C#

using Microsoft.Extensions.Options;
using TermRemoteCtl.Agent.Configuration;
using TermRemoteCtl.Agent.History;
using TermRemoteCtl.Agent.Sessions;
using TermRemoteCtl.Agent.Terminal;
namespace TermRemoteCtl.Agent.Tests.Terminal;
public class PowerShellSessionHostTests
{
[Fact]
public async Task ResizeAsync_Forwards_To_ConPty_Session()
{
var factory = new FakeConPtySessionFactory();
using var harness = HostHarness.Create(factory);
await using var host = harness.Host;
var session = harness.Registry.Create("alpha", DateTimeOffset.UtcNow);
await host.StartAsync(session.SessionId, CancellationToken.None);
await host.ResizeAsync(session.SessionId, 120, 40, CancellationToken.None);
Assert.Equal((120, 40), factory.Session.ResizeCalls.Single());
}
[Fact]
public async Task StartAsync_Forwards_WorkingDirectory_To_SessionFactory()
{
var factory = new FakeConPtySessionFactory();
using var harness = HostHarness.Create(factory);
await using var host = harness.Host;
var session = harness.Registry.Create(
"alpha",
DateTimeOffset.UtcNow,
workingDirectory: @"C:\repo\termremotectl");
await host.StartAsync(session.SessionId, CancellationToken.None);
Assert.Equal(@"C:\repo\termremotectl", factory.LastWorkingDirectory);
}
[Fact]
public async Task Session_Output_Is_Captured_In_Registry_History()
{
var factory = new FakeConPtySessionFactory();
using var harness = HostHarness.Create(factory, lineLimit: 3);
var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow);
await using var host = harness.Host;
using var received = new ManualResetEventSlim(false);
host.OutputReceived += (_, _) => received.Set();
await host.StartAsync(session.SessionId, CancellationToken.None);
factory.Session.EmitOutput(session.SessionId, "one\ntwo\nthree\n");
Assert.True(received.Wait(TimeSpan.FromSeconds(2)));
var history = await harness.Registry.GetHistoryAsync(session.SessionId, 2, CancellationToken.None);
Assert.Equal(["two", "three"], history.Lines);
Assert.True(history.HasMoreAbove);
}
[Fact]
public async Task Session_Output_Events_Are_Published_In_Order()
{
var factory = new FakeConPtySessionFactory();
using var harness = HostHarness.Create(factory);
var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow);
await using var host = harness.Host;
var outputs = new List<TerminalOutputEventArgs>();
using var received = new ManualResetEventSlim(false);
host.OutputReceived += (_, args) =>
{
lock (outputs)
{
outputs.Add(args);
if (outputs.Count == 2)
{
received.Set();
}
}
};
await host.StartAsync(session.SessionId, CancellationToken.None);
factory.Session.EmitOutput(session.SessionId, "first");
factory.Session.EmitOutput(session.SessionId, "second");
Assert.True(received.Wait(TimeSpan.FromSeconds(2)));
Assert.Equal(["first", "second"], outputs.Select(static output => output.Chunk).ToArray());
Assert.Equal([1L, 2L], outputs.Select(static output => output.Sequence).ToArray());
}
[Fact]
public async Task StartAsync_Recreates_Unhealthy_Existing_Session()
{
var factory = new FakeConPtySessionFactory();
using var harness = HostHarness.Create(factory);
await using var host = harness.Host;
var session = harness.Registry.Create("alpha", DateTimeOffset.UtcNow);
await host.StartAsync(session.SessionId, CancellationToken.None);
var firstSession = factory.CreatedSessions.Single();
firstSession.IsAlive = false;
await host.StartAsync(session.SessionId, CancellationToken.None);
Assert.Equal(2, factory.CreatedSessions.Count);
Assert.True(firstSession.DisposeCount > 0);
Assert.True(factory.CreatedSessions.Last().StartCount > 0);
}
[Fact]
public async Task Output_Failure_From_One_Session_Does_Not_Block_Other_Sessions()
{
var factory = new FakeConPtySessionFactory();
using var harness = HostHarness.Create(factory);
await using var host = harness.Host;
var blockedSession = harness.Registry.Create("blocked", DateTimeOffset.UtcNow);
var healthySession = harness.Registry.Create("healthy", DateTimeOffset.UtcNow);
await host.StartAsync(blockedSession.SessionId, CancellationToken.None);
await host.StartAsync(healthySession.SessionId, CancellationToken.None);
var blockedLogPath = Path.Combine(
harness.DataRoot,
"sessions",
$"{blockedSession.SessionId}.log");
Directory.CreateDirectory(Path.GetDirectoryName(blockedLogPath)!);
await using var blockingStream = new FileStream(
blockedLogPath,
FileMode.OpenOrCreate,
FileAccess.ReadWrite,
FileShare.None);
using var received = new ManualResetEventSlim(false);
var outputs = new List<TerminalOutputEventArgs>();
host.OutputReceived += (_, args) =>
{
lock (outputs)
{
outputs.Add(args);
if (args.SessionId == healthySession.SessionId)
{
received.Set();
}
}
};
factory.CreatedSessions[0].EmitOutput(blockedSession.SessionId, "blocked-output");
factory.CreatedSessions[1].EmitOutput(healthySession.SessionId, "healthy-output");
Assert.True(received.Wait(TimeSpan.FromSeconds(2)));
Assert.Contains(
outputs,
item => item.SessionId == healthySession.SessionId &&
item.Chunk == "healthy-output");
}
private sealed class HostHarness : IDisposable
{
private HostHarness(string dataRoot, SessionRegistry registry, PowerShellSessionHost host)
{
DataRoot = dataRoot;
Registry = registry;
Host = host;
}
public string DataRoot { get; }
public SessionRegistry Registry { get; }
public PowerShellSessionHost Host { get; }
public static HostHarness Create(FakeConPtySessionFactory factory, int lineLimit = 4000)
{
var dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dataRoot);
var options = Options.Create(new AgentOptions
{
DataRoot = dataRoot,
RingBufferLineLimit = lineLimit,
});
var registry = new SessionRegistry(
new SessionHistoryStore(dataRoot),
new SessionIoJournalStore(dataRoot),
options);
var host = new PowerShellSessionHost(factory, registry);
return new HostHarness(dataRoot, registry, host);
}
public void Dispose()
{
if (Directory.Exists(DataRoot))
{
for (var attempt = 0; attempt < 5; attempt += 1)
{
try
{
Directory.Delete(DataRoot, true);
break;
}
catch (IOException) when (attempt < 4)
{
Thread.Sleep(50);
}
catch (UnauthorizedAccessException) when (attempt < 4)
{
Thread.Sleep(50);
}
}
}
}
}
private sealed class FakeConPtySessionFactory : IConPtySessionFactory
{
public List<FakeConPtySession> CreatedSessions { get; } = [];
public string? LastWorkingDirectory { get; private set; }
public FakeConPtySession Session => CreatedSessions.Last();
public IConPtySession Create(string sessionId, string? workingDirectory = null)
{
var session = new FakeConPtySession
{
SessionId = sessionId,
};
CreatedSessions.Add(session);
LastWorkingDirectory = workingDirectory;
return session;
}
}
private sealed class FakeConPtySession : IConPtySession
{
public string SessionId { get; set; } = string.Empty;
public bool IsAlive { get; set; } = true;
public int StartCount { get; private set; }
public int DisposeCount { get; private set; }
public event EventHandler<TerminalOutputEventArgs>? OutputReceived;
public List<(int Columns, int Rows)> ResizeCalls { get; } = new();
public Task StartAsync(CancellationToken cancellationToken)
{
StartCount += 1;
return Task.CompletedTask;
}
public ValueTask<bool> IsAliveAsync(CancellationToken cancellationToken)
{
return ValueTask.FromResult(IsAlive);
}
public Task WriteInputAsync(string input, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task ResizeAsync(int columns, int rows, CancellationToken cancellationToken)
{
ResizeCalls.Add((columns, rows));
return Task.CompletedTask;
}
public void EmitOutput(string sessionId, string chunk)
{
OutputReceived?.Invoke(this, new TerminalOutputEventArgs(sessionId, chunk));
}
public ValueTask DisposeAsync()
{
DisposeCount += 1;
return ValueTask.CompletedTask;
}
}
}