TermRemoteCtl/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/SessionFlowTests.cs

221 lines
8.4 KiB
C#

using System.Net.WebSockets;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using TermRemoteCtl.Agent.Terminal;
namespace TermRemoteCtl.Agent.IntegrationTests;
public sealed class SessionFlowTests
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
[Fact]
public async Task Create_Attach_Reconnect_Sequence_Returns_Consistent_Session_Metadata()
{
await using var fixture = new AgentFixture();
using var client = fixture.CreateClient();
var createResponse = await client.PostAsJsonAsync("/api/sessions", new { name = "codex-main" });
createResponse.EnsureSuccessStatusCode();
var created = await createResponse.Content.ReadFromJsonAsync<SessionResponse>(JsonOptions);
Assert.NotNull(created);
var listedSessions = await client.GetFromJsonAsync<List<SessionResponse>>("/api/sessions", JsonOptions);
Assert.NotNull(listedSessions);
Assert.Contains(listedSessions!, session => session.SessionId == created!.SessionId && session.Name == created.Name && session.Status == created.Status && session.CreatedAtUtc == created.CreatedAtUtc && session.UpdatedAtUtc == created.UpdatedAtUtc);
var initialSnapshot = created;
using (var firstSocket = await fixture.ConnectTerminalAsync(created.SessionId))
{
var attached = await ReceiveTextAsync(firstSocket);
var attachedPayload = JsonSerializer.Deserialize<TerminalAttachResponse>(attached, JsonOptions);
Assert.NotNull(attachedPayload);
Assert.Equal(created.SessionId, attachedPayload!.SessionId);
Assert.Equal("attached", attachedPayload.Type);
}
using (var secondSocket = await fixture.ConnectTerminalAsync(created.SessionId))
{
var reattached = await ReceiveTextAsync(secondSocket);
var reattachedPayload = JsonSerializer.Deserialize<TerminalAttachResponse>(reattached, JsonOptions);
Assert.NotNull(reattachedPayload);
Assert.Equal(initialSnapshot.SessionId, reattachedPayload!.SessionId);
Assert.Equal("attached", reattachedPayload.Type);
}
var replayedSessions = await client.GetFromJsonAsync<List<SessionResponse>>("/api/sessions", JsonOptions);
Assert.NotNull(replayedSessions);
Assert.Contains(replayedSessions!, session => session.SessionId == initialSnapshot.SessionId && session.Name == initialSnapshot.Name && session.Status == initialSnapshot.Status && session.CreatedAtUtc == initialSnapshot.CreatedAtUtc && session.UpdatedAtUtc == initialSnapshot.UpdatedAtUtc);
Assert.Equal(1, fixture.SessionHost.StartCountFor(initialSnapshot.SessionId));
}
[Fact]
public async Task Delete_Removes_Session_From_List_And_Stops_Host()
{
await using var fixture = new AgentFixture();
using var client = fixture.CreateClient();
var createResponse = await client.PostAsJsonAsync("/api/sessions", new { name = "codex-main" });
createResponse.EnsureSuccessStatusCode();
var created = await createResponse.Content.ReadFromJsonAsync<SessionResponse>(JsonOptions);
Assert.NotNull(created);
using (var socket = await fixture.ConnectTerminalAsync(created!.SessionId))
{
_ = await ReceiveTextAsync(socket);
}
var deleteResponse = await client.DeleteAsync($"/api/sessions/{created.SessionId}");
deleteResponse.EnsureSuccessStatusCode();
var listedSessions = await client.GetFromJsonAsync<List<SessionResponse>>("/api/sessions", JsonOptions);
Assert.NotNull(listedSessions);
Assert.DoesNotContain(listedSessions!, session => session.SessionId == created.SessionId);
Assert.Equal(1, fixture.SessionHost.StopCountFor(created.SessionId));
}
private static async Task<string> ReceiveTextAsync(WebSocket socket)
{
var buffer = new byte[4096];
using var stream = new MemoryStream();
while (true)
{
var result = await socket.ReceiveAsync(buffer, CancellationToken.None);
if (result.MessageType == WebSocketMessageType.Close)
{
throw new InvalidOperationException("Socket closed before a text frame was received.");
}
stream.Write(buffer, 0, result.Count);
if (result.EndOfMessage)
{
return Encoding.UTF8.GetString(stream.ToArray());
}
}
}
private sealed class AgentFixture : WebApplicationFactory<Program>
{
private readonly string _dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N"));
private readonly RecordingSessionHost _sessionHost = new();
public RecordingSessionHost SessionHost => _sessionHost;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
builder.ConfigureAppConfiguration((_, configBuilder) =>
{
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["Agent:DataRoot"] = _dataRoot,
["Agent:BindAddress"] = "127.0.0.1",
["Agent:HttpsPort"] = "9443",
["Agent:WebSocketFrameFlushMilliseconds"] = "33",
["Agent:RingBufferLineLimit"] = "4000"
});
});
builder.ConfigureServices(services =>
{
services.RemoveAll<ISessionHost>();
services.AddSingleton<ISessionHost>(_sessionHost);
});
}
public async Task<WebSocket> ConnectTerminalAsync(string sessionId)
{
return await Server.CreateWebSocketClient()
.ConnectAsync(new Uri($"ws://localhost/ws/terminal?sessionId={sessionId}"), CancellationToken.None);
}
public new async ValueTask DisposeAsync()
{
await base.DisposeAsync();
if (Directory.Exists(_dataRoot))
{
Directory.Delete(_dataRoot, true);
}
}
}
private sealed class RecordingSessionHost : ISessionHost
{
private readonly Dictionary<string, int> _startCounts = new(StringComparer.Ordinal);
private readonly Dictionary<string, int> _stopCounts = new(StringComparer.Ordinal);
private readonly HashSet<string> _startedSessions = new(StringComparer.Ordinal);
public event EventHandler<TerminalOutputEventArgs>? OutputReceived
{
add { }
remove { }
}
public Task StartAsync(string sessionId, CancellationToken cancellationToken)
{
if (_startedSessions.Add(sessionId))
{
_startCounts[sessionId] = 1;
}
return Task.CompletedTask;
}
public Task WriteInputAsync(string sessionId, string input, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task ResizeAsync(string sessionId, int columns, int rows, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task StopAsync(string sessionId, CancellationToken cancellationToken)
{
if (_stopCounts.TryGetValue(sessionId, out var count))
{
_stopCounts[sessionId] = count + 1;
}
else
{
_stopCounts[sessionId] = 1;
}
_startedSessions.Remove(sessionId);
return Task.CompletedTask;
}
public int StartCountFor(string sessionId)
{
return _startCounts.TryGetValue(sessionId, out var count) ? count : 0;
}
public int StopCountFor(string sessionId)
{
return _stopCounts.TryGetValue(sessionId, out var count) ? count : 0;
}
}
private sealed record SessionResponse(
string SessionId,
string Name,
string Status,
DateTimeOffset CreatedAtUtc,
DateTimeOffset UpdatedAtUtc);
private sealed record TerminalAttachResponse(string SessionId, string Type);
}