316 lines
13 KiB
C#
316 lines
13 KiB
C#
using TermRemoteCtl.Agent.Sessions;
|
|
using Microsoft.Extensions.Options;
|
|
using TermRemoteCtl.Agent.Configuration;
|
|
using TermRemoteCtl.Agent.History;
|
|
|
|
namespace TermRemoteCtl.Agent.Tests.Sessions;
|
|
|
|
public class SessionRegistryTests
|
|
{
|
|
[Fact]
|
|
public void Create_Returns_Record_And_List_Is_Ordered_By_Name()
|
|
{
|
|
using var harness = SessionRegistryHarness.Create();
|
|
var registry = harness.Registry;
|
|
var now = new DateTimeOffset(2026, 03, 27, 03, 00, 00, TimeSpan.Zero);
|
|
|
|
var zebra = registry.Create("Zebra", now);
|
|
var alpha = registry.Create("Alpha", now.AddMinutes(1));
|
|
|
|
Assert.Equal("Zebra", zebra.Name);
|
|
Assert.Equal("Alpha", alpha.Name);
|
|
Assert.Equal(["Alpha", "Zebra"], registry.List().Select(record => record.Name).ToArray());
|
|
}
|
|
|
|
[Fact]
|
|
public void Create_Sets_Record_Metadata()
|
|
{
|
|
using var harness = SessionRegistryHarness.Create();
|
|
var registry = harness.Registry;
|
|
var now = new DateTimeOffset(2026, 03, 27, 03, 15, 00, TimeSpan.Zero);
|
|
|
|
var record = registry.Create("Shell", now);
|
|
|
|
Assert.False(string.IsNullOrWhiteSpace(record.SessionId));
|
|
Assert.Equal("Shell", record.Name);
|
|
Assert.Equal("created", record.Status);
|
|
Assert.Equal(now, record.CreatedAtUtc);
|
|
Assert.Equal(now, record.UpdatedAtUtc);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AppendOutputAsync_Stores_Recent_Scrollback_Lines()
|
|
{
|
|
using var harness = SessionRegistryHarness.Create(lineLimit: 3);
|
|
var registry = harness.Registry;
|
|
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
|
|
|
|
await registry.AppendOutputAsync(session.SessionId, "one\ntwo\nthree\n", CancellationToken.None);
|
|
|
|
var history = await registry.GetHistoryAsync(session.SessionId, 2, CancellationToken.None);
|
|
|
|
Assert.Equal(["two", "three"], history.Lines);
|
|
Assert.True(history.HasMoreAbove);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AppendOutputAsync_Persists_History_To_Log_File()
|
|
{
|
|
using var harness = SessionRegistryHarness.Create();
|
|
var registry = harness.Registry;
|
|
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
|
|
|
|
await registry.AppendOutputAsync(session.SessionId, "dir\r\n", CancellationToken.None);
|
|
|
|
var logPath = Path.Combine(harness.DataRoot, "sessions", $"{session.SessionId}.log");
|
|
var content = await File.ReadAllTextAsync(logPath);
|
|
|
|
Assert.Equal("dir\r\n", content);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AppendOutputAsync_Stores_Recent_Replay_Output()
|
|
{
|
|
using var harness = SessionRegistryHarness.Create();
|
|
var registry = harness.Registry;
|
|
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
|
|
|
|
await registry.AppendOutputAsync(session.SessionId, "prompt> ", CancellationToken.None);
|
|
await registry.AppendOutputAsync(session.SessionId, "dir\r\n", CancellationToken.None);
|
|
|
|
var replay = registry.GetReplaySnapshot(session.SessionId);
|
|
|
|
Assert.Equal("prompt> dir\r\n", replay);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AppendIoEventAsync_Persists_Input_And_Output_In_Order()
|
|
{
|
|
using var harness = SessionRegistryHarness.Create();
|
|
var store = new SessionIoJournalStore(harness.DataRoot);
|
|
|
|
await store.AppendAsync(
|
|
new SessionIoEvent("session-1", 1, "input", "dir", DateTimeOffset.UtcNow),
|
|
CancellationToken.None);
|
|
await store.AppendAsync(
|
|
new SessionIoEvent("session-1", 2, "output", "dir\r\n", DateTimeOffset.UtcNow),
|
|
CancellationToken.None);
|
|
|
|
var lines = await File.ReadAllLinesAsync(
|
|
Path.Combine(harness.DataRoot, "sessions", "session-1.io.jsonl"));
|
|
|
|
Assert.Equal(2, lines.Length);
|
|
Assert.Contains("\"kind\":\"input\"", lines[0]);
|
|
Assert.Contains("\"kind\":\"output\"", lines[1]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetJournalAsync_Returns_Durable_Events_In_Sequence_Order()
|
|
{
|
|
using var harness = SessionRegistryHarness.Create();
|
|
var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow);
|
|
|
|
await harness.Registry.RecordAttachAsync(session.SessionId, CancellationToken.None);
|
|
await harness.Registry.RecordInputAsync(session.SessionId, "git status\r", CancellationToken.None);
|
|
await harness.Registry.RecordOutputAsync(session.SessionId, "PS> git status\r\n", CancellationToken.None);
|
|
await harness.Registry.RecordResizeAsync(session.SessionId, 120, 32, CancellationToken.None);
|
|
await harness.Registry.RecordDetachAsync(session.SessionId, CancellationToken.None);
|
|
|
|
var page = await harness.Registry.GetJournalAsync(
|
|
session.SessionId,
|
|
beforeSequence: null,
|
|
afterSequence: null,
|
|
limit: 10,
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(session.SessionId, page.SessionId);
|
|
Assert.Equal(["attach", "input", "output", "resize", "detach"], page.Items.Select(item => item.Kind).ToArray());
|
|
Assert.Equal([1L, 2L, 3L, 4L, 5L], page.Items.Select(item => item.Sequence).ToArray());
|
|
Assert.Equal(5L, page.CurrentSequence);
|
|
Assert.False(page.HasMoreBefore);
|
|
Assert.False(page.HasMoreAfter);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetJournalAsync_Can_Page_Backward_From_A_Sequence_Cursor()
|
|
{
|
|
using var harness = SessionRegistryHarness.Create();
|
|
var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow);
|
|
|
|
await harness.Registry.RecordInputAsync(session.SessionId, "first", CancellationToken.None);
|
|
await harness.Registry.RecordOutputAsync(session.SessionId, "first\r\n", CancellationToken.None);
|
|
await harness.Registry.RecordInputAsync(session.SessionId, "second", CancellationToken.None);
|
|
await harness.Registry.RecordOutputAsync(session.SessionId, "second\r\n", CancellationToken.None);
|
|
|
|
var page = await harness.Registry.GetJournalAsync(
|
|
session.SessionId,
|
|
beforeSequence: 4,
|
|
afterSequence: null,
|
|
limit: 2,
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal([2L, 3L], page.Items.Select(item => item.Sequence).ToArray());
|
|
Assert.Equal(["output", "input"], page.Items.Select(item => item.Kind).ToArray());
|
|
Assert.True(page.HasMoreBefore);
|
|
Assert.True(page.HasMoreAfter);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RecordInputEcho_Includes_Visible_User_Input_In_Replay_Snapshot()
|
|
{
|
|
using var harness = SessionRegistryHarness.Create();
|
|
var registry = harness.Registry;
|
|
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
|
|
|
|
await registry.RecordInputAsync(session.SessionId, "dir", CancellationToken.None);
|
|
|
|
var replay = registry.GetReplaySnapshot(session.SessionId);
|
|
|
|
Assert.Equal("dir", replay);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AppendOutputAsync_Clears_Pending_Input_After_Command_Is_Echoed()
|
|
{
|
|
using var harness = SessionRegistryHarness.Create();
|
|
var registry = harness.Registry;
|
|
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
|
|
|
|
await registry.RecordInputAsync(session.SessionId, "dir\r", CancellationToken.None);
|
|
await registry.AppendOutputAsync(session.SessionId, "prompt> dir\r\nnext> ", CancellationToken.None);
|
|
|
|
var replay = registry.GetReplaySnapshot(session.SessionId);
|
|
|
|
Assert.Equal("prompt> dir\r\nnext> ", replay);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AppendOutputAsync_Clears_Pending_Input_When_Echo_Arrives_Across_Multiple_Chunks()
|
|
{
|
|
using var harness = SessionRegistryHarness.Create();
|
|
var registry = harness.Registry;
|
|
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
|
|
|
|
await registry.RecordInputAsync(session.SessionId, "dir\r", CancellationToken.None);
|
|
await registry.AppendOutputAsync(session.SessionId, "prompt> d", CancellationToken.None);
|
|
await registry.AppendOutputAsync(session.SessionId, "ir\r\nnext> ", CancellationToken.None);
|
|
|
|
var replay = registry.GetReplaySnapshot(session.SessionId);
|
|
|
|
Assert.Equal("prompt> dir\r\nnext> ", replay);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetRestoreSnapshot_Includes_Pending_Visible_Input()
|
|
{
|
|
using var harness = SessionRegistryHarness.Create();
|
|
var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow);
|
|
|
|
await harness.Registry.RecordInputAsync(session.SessionId, "git status", CancellationToken.None);
|
|
|
|
var snapshot = harness.Registry.GetRestoreSnapshot(session.SessionId);
|
|
|
|
Assert.Equal(string.Empty, snapshot.ScreenText);
|
|
Assert.Equal("git status", snapshot.PendingInput);
|
|
Assert.True(snapshot.Sequence > 0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetRestoreSnapshot_Does_Not_Duplicate_Acknowledged_Input()
|
|
{
|
|
using var harness = SessionRegistryHarness.Create();
|
|
var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow);
|
|
|
|
await harness.Registry.RecordInputAsync(session.SessionId, "dir\r", CancellationToken.None);
|
|
await harness.Registry.AppendOutputAsync(session.SessionId, "PS> dir\r\nnext> ", CancellationToken.None);
|
|
|
|
var snapshot = harness.Registry.GetRestoreSnapshot(session.SessionId);
|
|
|
|
Assert.Equal("PS> dir\r\nnext> ", snapshot.ScreenText);
|
|
Assert.Equal(string.Empty, snapshot.PendingInput);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Experiment_GetScreenSnapshot_Returns_Authoritative_Primary_Buffer_State()
|
|
{
|
|
using var harness = SessionRegistryHarness.Create(enableBackendScreenProtocol: true);
|
|
var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow);
|
|
|
|
await harness.Registry.RecordResizeAsync(session.SessionId, 40, 10, CancellationToken.None);
|
|
await harness.Registry.RecordOutputAsync(session.SessionId, "first line\r\nsecond line", CancellationToken.None);
|
|
await harness.Registry.RecordOutputAsync(session.SessionId, "\u001b[2J\u001b[Hprompt> ", CancellationToken.None);
|
|
|
|
var snapshot = harness.Registry.GetScreenSnapshot(session.SessionId);
|
|
|
|
Assert.Equal(session.SessionId, snapshot.SessionId);
|
|
Assert.Equal(10, snapshot.Rows);
|
|
Assert.Equal(40, snapshot.Columns);
|
|
Assert.Equal(0, snapshot.CursorRow);
|
|
Assert.Equal(8, snapshot.CursorColumn);
|
|
Assert.Equal(3L, snapshot.SourceSequence);
|
|
Assert.True(snapshot.ScreenVersion > 0);
|
|
Assert.Equal("primary", snapshot.ActiveBuffer);
|
|
Assert.StartsWith("prompt> ", snapshot.PrimaryBuffer.Viewport[0].Text, StringComparison.Ordinal);
|
|
Assert.DoesNotContain(
|
|
snapshot.PrimaryBuffer.Viewport,
|
|
line => line.Text.Contains("first line", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Delete_Removes_Session_Record_And_History_Log()
|
|
{
|
|
using var harness = SessionRegistryHarness.Create();
|
|
var registry = harness.Registry;
|
|
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
|
|
await registry.AppendOutputAsync(session.SessionId, "dir\r\n", CancellationToken.None);
|
|
|
|
await registry.DeleteAsync(session.SessionId, CancellationToken.None);
|
|
|
|
Assert.Empty(registry.List());
|
|
Assert.False(registry.TryGet(session.SessionId, out _));
|
|
Assert.False(File.Exists(Path.Combine(harness.DataRoot, "sessions", $"{session.SessionId}.log")));
|
|
await Assert.ThrowsAsync<KeyNotFoundException>(() => registry.GetHistoryAsync(session.SessionId, 10, CancellationToken.None));
|
|
}
|
|
|
|
private sealed class SessionRegistryHarness : IDisposable
|
|
{
|
|
private SessionRegistryHarness(string dataRoot, SessionRegistry registry)
|
|
{
|
|
DataRoot = dataRoot;
|
|
Registry = registry;
|
|
}
|
|
|
|
public string DataRoot { get; }
|
|
|
|
public SessionRegistry Registry { get; }
|
|
|
|
public static SessionRegistryHarness Create(
|
|
int lineLimit = 4000,
|
|
bool enableBackendScreenProtocol = false)
|
|
{
|
|
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,
|
|
EnableBackendScreenProtocol = enableBackendScreenProtocol,
|
|
});
|
|
var historyStore = new SessionHistoryStore(dataRoot);
|
|
var journalStore = new SessionIoJournalStore(dataRoot);
|
|
var registry = new SessionRegistry(historyStore, journalStore, options);
|
|
|
|
return new SessionRegistryHarness(dataRoot, registry);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(DataRoot))
|
|
{
|
|
Directory.Delete(DataRoot, true);
|
|
}
|
|
}
|
|
}
|
|
}
|