using System.Net.WebSockets; using System.Text; using NATS.E2E.Tests.Infrastructure; namespace NATS.E2E.Tests; [Collection("E2E-WebSocket")] public class WebSocketTests(WebSocketServerFixture fixture) { [Fact] public async Task WebSocket_ConnectAndReceiveInfo() { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); using var ws = new ClientWebSocket(); await ws.ConnectAsync(new Uri($"ws://127.0.0.1:{fixture.WsPort}"), cts.Token); ws.State.ShouldBe(WebSocketState.Open); var buffer = new byte[4096]; var result = await ws.ReceiveAsync(buffer, cts.Token); var info = Encoding.ASCII.GetString(buffer, 0, result.Count); info.ShouldStartWith("INFO"); await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, cts.Token); } [Fact] public async Task WebSocket_PubSub_RoundTrip() { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); using var ws = new ClientWebSocket(); await ws.ConnectAsync(new Uri($"ws://127.0.0.1:{fixture.WsPort}"), cts.Token); var reader = new WsLineReader(ws); // Read the initial INFO frame await reader.ReadLineAsync(cts.Token); await WsSend(ws, "CONNECT {\"verbose\":false,\"protocol\":1}\r\n", cts.Token); await WsSend(ws, "SUB e2e.ws.test 1\r\n", cts.Token); await WsSend(ws, "PING\r\n", cts.Token); var pong = await reader.ReadLineAsync(cts.Token); pong.ShouldBe("PONG"); await using var natsClient = fixture.CreateNatsClient(); await natsClient.ConnectAsync(); await natsClient.PublishAsync("e2e.ws.test", "ws-hello"); await natsClient.PingAsync(); var msgLine = await reader.ReadLineAsync(cts.Token); msgLine.ShouldStartWith("MSG e2e.ws.test 1"); var payload = await reader.ReadLineAsync(cts.Token); payload.ShouldBe("ws-hello"); await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, cts.Token); } private static async Task WsSend(ClientWebSocket ws, string data, CancellationToken ct) { var bytes = Encoding.ASCII.GetBytes(data); await ws.SendAsync(bytes, WebSocketMessageType.Binary, true, ct); } /// /// Buffers incoming WebSocket frames and returns one NATS protocol line at a time. /// Handles the case where a single WebSocket frame contains multiple protocol lines /// (e.g., MSG header + payload delivered in one frame). /// private sealed class WsLineReader(ClientWebSocket ws) { private readonly byte[] _recvBuffer = new byte[4096]; private readonly StringBuilder _pending = new(); public async Task ReadLineAsync(CancellationToken ct) { while (true) { var full = _pending.ToString(); var crlfIdx = full.IndexOf("\r\n", StringComparison.Ordinal); if (crlfIdx >= 0) { var line = full[..crlfIdx]; _pending.Clear(); _pending.Append(full[(crlfIdx + 2)..]); return line; } var result = await ws.ReceiveAsync(_recvBuffer, ct); if (result.MessageType == WebSocketMessageType.Close) throw new InvalidOperationException("WebSocket closed unexpectedly while reading"); var chunk = Encoding.ASCII.GetString(_recvBuffer, 0, result.Count); _pending.Append(chunk); } } } }