using System.Globalization; using System.Net.WebSockets; using System.Text.RegularExpressions; using DodoSSH.Client.Terminal; namespace DodoSSH.Client.App.Tests; /// /// Stands in for the terminal page, so the connect path can be exercised without a WebView. /// /// /// /// It attaches the way the real renderer does rather than reaching for the workspace's internals: /// fetch the served page, read the token and socket URL the host substituted into it, then open the /// WebSocket with the same two subprotocols. Anything cheaper — handing it the token directly — would /// stop testing the part of the handshake that has actually been got wrong before. /// /// /// Attaching is what completes TerminalWorkspace.WaitForRendererAsync, and that await is the /// real gate on a first connection: the data plane drops frames when nothing is attached rather than /// queueing them, so a session opened before this exists would lose its SessionOpened frame. /// /// internal sealed partial class FakeRenderer : IAsyncDisposable { private readonly ClientWebSocket socket; private FakeRenderer(ClientWebSocket socket) => this.socket = socket; /// Fetches the page and attaches a socket, as the real renderer would. internal static async Task AttachAsync( TerminalWorkspace workspace, CancellationToken cancellationToken) { using var http = new HttpClient(); var page = await http .GetStringAsync(workspace.PageUrl, cancellationToken) .ConfigureAwait(false); var token = Attribute(page, "data-token"); var socketUrl = Attribute(page, "data-socket"); var attached = new ClientWebSocket(); attached.Options.AddSubProtocol(TerminalDataPlane.SubProtocol); attached.Options.AddSubProtocol($"token.{token}"); // The listener requires the page's own origin, which is what makes a page in the user's // browser unable to reach this socket. attached.Options.SetRequestHeader( "Origin", string.Create( CultureInfo.InvariantCulture, $"{workspace.PageUrl.Scheme}://{workspace.PageUrl.Authority}")); try { await attached.ConnectAsync(new Uri(socketUrl), cancellationToken).ConfigureAwait(false); } catch { attached.Dispose(); throw; } var renderer = new FakeRenderer(attached); await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(false); return renderer; } /// Sends a client frame, as the page would. /// /// Raw rather than through a helper per opcode: the two this exists for carry a session id and either /// UTF-8 or nothing at all, and a method each would be three lines of ceremony around /// . /// internal async Task SendAsync( TerminalClientOpcode opcode, uint sessionId, ReadOnlyMemory payload, CancellationToken cancellationToken) => await socket .SendAsync( TerminalFrame.Create((byte)opcode, sessionId, payload.Span), WebSocketMessageType.Binary, endOfMessage: true, cancellationToken) .ConfigureAwait(false); /// Reads frames until one carries that opcode, and answers with it. /// /// /// Skipping rather than expecting the next frame to be the interesting one, because a live session's /// socket is not quiet: the shell's opening frame, its activation and whatever the remote said are all /// on the way, and which of them have arrived by the time a test looks is a scheduling accident. /// /// /// Bounded only by the caller's token, which in this suite is the test's own — so a frame that never /// comes fails the test on its timeout rather than hanging the run. /// /// internal async Task WaitForAsync( TerminalServerOpcode opcode, CancellationToken cancellationToken) { var buffer = new byte[64 * 1024]; while (true) { var result = await socket .ReceiveAsync(buffer.AsMemory(), cancellationToken) .ConfigureAwait(false); if (result.MessageType == WebSocketMessageType.Close) { throw new InvalidOperationException( $"The host closed the socket before sending a {opcode} frame."); } if (TerminalFrame.TryRead(buffer.AsSpan(0, result.Count), out var read, out _, out var payload) && read == (byte)opcode) { return payload.ToArray(); } } } /// public async ValueTask DisposeAsync() { if (socket.State == WebSocketState.Open) { try { await socket .CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None) .ConfigureAwait(false); } catch (WebSocketException) { // The host may have gone first; there is nothing to salvage either way. } } socket.Dispose(); } private static string Attribute(string page, string name) { var match = AttributeValue(name).Match(page); return match.Success ? match.Groups[1].Value : throw new InvalidOperationException( $"The served page carried no {name}. The host substitutes it at serve time, so " + $"either the placeholder is missing from the test's page asset or substitution broke."); } private static Regex AttributeValue(string name) => new($"{Regex.Escape(name)}=\"([^\"]*)\"", RegexOptions.None, TimeSpan.FromSeconds(1)); }