Files
jaap-jan 74341d41e0 Merge branch 'claude/distracted-ritchie-53fc70'
Bounds the renderer wait, so a WebView2 that never initialises reports itself
instead of hanging Connect with the busy flag stuck.

Conflict resolution, all of it in the App test suite, which main had changed
under the branch when sleepy-chebyshev landed:

- The workspace fixture keeps main's fake SSH factory and its FakeRenderer-aware
  page, and takes the branch's RendererTimeout on top. One second rather than
  the branch's 250 ms, because the timeout now also bounds FakeRenderer's own
  wait for the attach it just made.
- FakeRenderer arrived on main after the branch was cut and still called the
  no-argument WaitForRendererAsync. Both sides merged cleanly and left the build
  broken; it now passes its own token.
- ConnectingWithNoRenderer's remark claimed the suite never starts the workspace
  and never attaches a renderer. Both are false here, so it now says what is
  true of the test: it is the one connect test that attaches no renderer.
2026-07-29 15:40:25 +02:00

108 lines
3.8 KiB
C#

using System.Globalization;
using System.Net.WebSockets;
using System.Text.RegularExpressions;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// Stands in for the terminal page, so the connect path can be exercised without a WebView.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// Attaching is what completes <c>TerminalWorkspace.WaitForRendererAsync</c>, 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 <c>SessionOpened</c> frame.
/// </para>
/// </remarks>
internal sealed partial class FakeRenderer : IAsyncDisposable
{
private readonly ClientWebSocket socket;
private FakeRenderer(ClientWebSocket socket) => this.socket = socket;
/// <summary>Fetches the page and attaches a socket, as the real renderer would.</summary>
internal static async Task<FakeRenderer> 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;
}
/// <inheritdoc />
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));
}