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; } /// 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)); }