TermRemoteCtl/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalSmokeCheckTests.cs

283 lines
11 KiB
C#

using System.Diagnostics;
using System.Net;
using System.Net.Http.Json;
using System.Net.WebSockets;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
namespace TermRemoteCtl.Agent.IntegrationTests.Realtime;
public sealed class TerminalSmokeCheckTests
{
[Fact]
public async Task BuiltAgentExe_Starts_And_Attaches_To_Terminal_WebSocket()
{
if (!OperatingSystem.IsWindows())
{
return;
}
await using var fixture = new BuiltAgentFixture();
await fixture.StartAsync();
try
{
var session = await fixture.CreateSessionAsync("smoke-shell");
using var socket = await fixture.ConnectTerminalAsync(session.SessionId);
var attached = await fixture.ReceiveTextAsync(socket, TimeSpan.FromSeconds(20));
var attachedPayload = JsonSerializer.Deserialize<TerminalAttachResponse>(attached, new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.NotNull(attachedPayload);
Assert.Equal("attached", attachedPayload!.Type);
Assert.Equal(session.SessionId, attachedPayload.SessionId);
await fixture.SendTextAsync(socket, JsonSerializer.Serialize(new { type = "input", input = "Write-Output smoke\r" }));
var output = await fixture.ReceiveTextContainingAsync(socket, "smoke", TimeSpan.FromSeconds(20));
Assert.Contains("smoke", output, StringComparison.OrdinalIgnoreCase);
}
catch (Exception ex)
{
throw new InvalidOperationException($"{ex.Message}{Environment.NewLine}{fixture.GetDiagnostics()}", ex);
}
}
private sealed class BuiltAgentFixture : IAsyncDisposable
{
private readonly string _projectRoot;
private readonly string _projectFile;
private readonly string _exePath;
private readonly string _dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Smoke", Guid.NewGuid().ToString("N"));
private readonly int _httpsPort = GetFreePort();
private Process? _process;
public BuiltAgentFixture()
{
var sourceRoot = Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory,
"..",
"..",
"..",
"..",
"..",
"src",
"TermRemoteCtl.Agent"));
_projectRoot = sourceRoot;
_projectFile = Path.Combine(_projectRoot, "TermRemoteCtl.Agent.csproj");
_exePath = Path.Combine(_projectRoot, "bin", "Debug", "net8.0", "TermRemoteCtl.Agent.exe");
}
public async Task StartAsync()
{
await BuildAsync().ConfigureAwait(false);
var startInfo = new ProcessStartInfo(_exePath)
{
WorkingDirectory = _projectRoot,
UseShellExecute = false
};
startInfo.Environment["ASPNETCORE_ENVIRONMENT"] = "Development";
startInfo.Environment["Agent__DataRoot"] = _dataRoot;
startInfo.Environment["Agent__BindAddress"] = "127.0.0.1";
startInfo.Environment["Agent__HttpsPort"] = _httpsPort.ToString();
startInfo.Environment["Agent__WebSocketFrameFlushMilliseconds"] = "33";
startInfo.Environment["Agent__RingBufferLineLimit"] = "4000";
_process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start agent process.");
await WaitForHealthAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false);
}
public async Task<(string SessionId, string Name)> CreateSessionAsync(string name)
{
using var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
};
using var client = new HttpClient(handler)
{
BaseAddress = new Uri($"https://127.0.0.1:{_httpsPort}")
};
using var response = await client.PostAsJsonAsync("/api/sessions", new { name }).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<SessionResponse>(new JsonSerializerOptions(JsonSerializerDefaults.Web)).ConfigureAwait(false);
if (payload is null)
{
throw new InvalidOperationException("Missing session payload.");
}
return (payload.SessionId, payload.Name);
}
public async Task<ClientWebSocket> ConnectTerminalAsync(string sessionId)
{
var socket = new ClientWebSocket();
socket.Options.RemoteCertificateValidationCallback = (_, _, _, _) => true;
await socket.ConnectAsync(new Uri($"wss://127.0.0.1:{_httpsPort}/ws/terminal?sessionId={sessionId}"), CancellationToken.None).ConfigureAwait(false);
return socket;
}
public async Task SendTextAsync(WebSocket socket, string payload)
{
var bytes = Encoding.UTF8.GetBytes(payload);
await socket.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None).ConfigureAwait(false);
}
public async Task<string> ReceiveTextAsync(WebSocket socket, TimeSpan timeout)
{
return await ReceiveUntilAsync(socket, static _ => true, timeout).ConfigureAwait(false);
}
public async Task<string> ReceiveTextContainingAsync(WebSocket socket, string expected, TimeSpan timeout)
{
return await ReceiveUntilAsync(socket, value => value.Contains(expected, StringComparison.OrdinalIgnoreCase), timeout).ConfigureAwait(false);
}
public async ValueTask DisposeAsync()
{
if (_process is not null && !_process.HasExited)
{
try
{
_process.Kill(entireProcessTree: true);
}
catch
{
}
await _process.WaitForExitAsync().ConfigureAwait(false);
}
if (Directory.Exists(_dataRoot))
{
Directory.Delete(_dataRoot, true);
}
}
private async Task WaitForHealthAsync(TimeSpan timeout)
{
using var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
};
using var client = new HttpClient(handler)
{
BaseAddress = new Uri($"https://127.0.0.1:{_httpsPort}")
};
var deadline = DateTimeOffset.UtcNow.Add(timeout);
while (DateTimeOffset.UtcNow < deadline)
{
try
{
using var response = await client.GetAsync("/health").ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.OK)
{
return;
}
}
catch
{
}
if (_process is not null && _process.HasExited)
{
throw new InvalidOperationException($"Agent exited early with code {_process.ExitCode}.");
}
await Task.Delay(250).ConfigureAwait(false);
}
throw new TimeoutException("Agent did not become healthy in time.");
}
public string GetDiagnostics()
{
return _process is null
? "Agent process was not started."
: $"Agent process state: HasExited={_process.HasExited}, ExitCode={(_process.HasExited ? _process.ExitCode : 0)}";
}
private async Task<string> ReceiveUntilAsync(WebSocket socket, Func<string, bool> predicate, TimeSpan timeout)
{
var buffer = new byte[4096];
using var aggregate = new MemoryStream();
var deadline = DateTimeOffset.UtcNow.Add(timeout);
while (DateTimeOffset.UtcNow < deadline)
{
var remaining = deadline - DateTimeOffset.UtcNow;
var receiveTask = socket.ReceiveAsync(buffer, CancellationToken.None);
var completed = await Task.WhenAny(receiveTask, Task.Delay(remaining)).ConfigureAwait(false);
if (completed != receiveTask)
{
break;
}
var result = await receiveTask.ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close)
{
break;
}
aggregate.Write(buffer, 0, result.Count);
if (result.EndOfMessage)
{
var text = Encoding.UTF8.GetString(aggregate.ToArray());
if (predicate(text))
{
return text;
}
aggregate.SetLength(0);
}
}
throw new TimeoutException("Expected terminal frame was not received.");
}
private static int GetFreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
private async Task BuildAsync()
{
var startInfo = new ProcessStartInfo("dotnet")
{
WorkingDirectory = _projectRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
startInfo.ArgumentList.Add("build");
startInfo.ArgumentList.Add(_projectFile);
using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start agent build.");
var stdout = await process.StandardOutput.ReadToEndAsync().ConfigureAwait(false);
var stderr = await process.StandardError.ReadToEndAsync().ConfigureAwait(false);
await process.WaitForExitAsync().ConfigureAwait(false);
if (process.ExitCode != 0)
{
throw new InvalidOperationException($"Agent build failed.{Environment.NewLine}STDOUT:{Environment.NewLine}{stdout}{Environment.NewLine}STDERR:{Environment.NewLine}{stderr}");
}
}
}
private sealed record SessionResponse(string SessionId, string Name);
private sealed record TerminalAttachResponse(string SessionId, string Type);
}