282 lines
9.7 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|