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(attached, new JsonSerializerOptions(JsonSerializerDefaults.Web)); Assert.NotNull(attachedPayload); Assert.Equal("attached", attachedPayload!.Type); Assert.Equal(session.SessionId, attachedPayload.SessionId); _ = await fixture.ReceiveTextAsync(socket, TimeSpan.FromSeconds(20)); 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); } } [Fact] public async Task BuiltAgentExe_Reconnects_Existing_Terminal_And_Accepts_Input() { if (!OperatingSystem.IsWindows()) { return; } await using var fixture = new BuiltAgentFixture(); await fixture.StartAsync(); try { var session = await fixture.CreateSessionAsync("smoke-reconnect"); using (var firstSocket = await fixture.ConnectTerminalAsync(session.SessionId)) { _ = await fixture.ReceiveTextAsync(firstSocket, TimeSpan.FromSeconds(20)); _ = await fixture.ReceiveTextAsync(firstSocket, TimeSpan.FromSeconds(20)); await fixture.SendTextAsync(firstSocket, JsonSerializer.Serialize(new { type = "input", input = "Write-Output first\r" })); _ = await fixture.ReceiveTextContainingAsync(firstSocket, "first", TimeSpan.FromSeconds(20)); } using var secondSocket = await fixture.ConnectTerminalAsync(session.SessionId); var attached = await fixture.ReceiveTextAsync(secondSocket, TimeSpan.FromSeconds(20)); var attachedPayload = JsonSerializer.Deserialize(attached, new JsonSerializerOptions(JsonSerializerDefaults.Web)); Assert.NotNull(attachedPayload); Assert.Equal("attached", attachedPayload!.Type); Assert.Equal(session.SessionId, attachedPayload.SessionId); _ = await fixture.ReceiveTextAsync(secondSocket, TimeSpan.FromSeconds(20)); await fixture.SendTextAsync(secondSocket, JsonSerializer.Serialize(new { type = "input", input = "Write-Output second\r" })); var output = await fixture.ReceiveTextContainingAsync(secondSocket, "second", TimeSpan.FromSeconds(20)); Assert.Contains("second", 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(new JsonSerializerOptions(JsonSerializerDefaults.Web)).ConfigureAwait(false); if (payload is null) { throw new InvalidOperationException("Missing session payload."); } return (payload.SessionId, payload.Name); } public async Task 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 ReceiveTextAsync(WebSocket socket, TimeSpan timeout) { return await ReceiveUntilAsync(socket, static _ => true, timeout).ConfigureAwait(false); } public async Task 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 ReceiveUntilAsync(WebSocket socket, Func 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); }