283 lines
11 KiB
C#
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);
|
|
}
|