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