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().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));
}