Files
DodoSSH/tests/DodoSSH.Client.Terminal.Tests/TerminalDataPlaneTests.cs
T
jaap-janandClaude Opus 5 c00e5dbc5c
ci / build and test (push) Successful in 1m12s
ci / android head (push) Failing after 4s
ci / api image (push) Successful in 24s
Let the terminal's text be made bigger, and remember how big
Taking pinch-zoom off the phone left nothing in its place, and there was nothing on the desktop
either. This is the replacement, and it is deliberately not the thing that was removed: zoom scales
what has already been drawn, so the remote goes on wrapping to a width that is no longer on screen.
Changing the font size refits the grid and reports the new column count, so the far end is told it
has fewer columns. That round trip is the feature.

The size is one number, owned by the shell. It has to be, for two reasons that pull the same way: it
must survive a relaunch, and it must be reachable from a phone that has no Ctrl key to press. So the
page asks and the host decides — a signed step over a new client opcode, answered with a size over a
new server opcode. The phone's buttons and the desktop's chords arrive at the same place, and a size
set by either is the size both remember.

Stored in settings.json beside the cache rather than in it, and that is not laziness about a
migration. The cache is encrypted and unreadable until a vault is unlocked, and the first terminal of
a locked launch needs the size already. Nothing secret may go in that file; ClientSettings says so
out loud, because the next person to add a preference is the one who needs to read it.

Where it is reachable from differs per head, and only here. The phone gets A− and A+ on the
connection line — not in the accessory row, which scrolls, and a control that fixes unreadable text
must never be the thing that is off-screen. The desktop gets the three chords every terminal
emulator has, answered by the page while a terminal has focus and by the window when it does not,
plus a row in preferences that shows the current value and names the chords rather than replacing
them. Someone whose terminal is too small to read is not in a position to go looking.

Clamped 8 to 32. Below eight a monospace grid stops being legible and becomes a texture, and every
column of it is still a column the remote is being told exists; above thirty-two a phone in portrait
has too few columns to hold a prompt. The buttons disable at the ends rather than accepting presses
that do nothing, which on a terminal reads as the application having stopped responding.

The preferences screen's header comment claimed none of the design's terminal settings could be
saved, and listed the three things that were missing to make one work. All three now exist, so it
says which one is real and why the other five still are not.

Verified with the protocol suite — including that the step byte round-trips signed, since read
unsigned a step down arrives as 255 and clamps to the largest font, making "smaller" do the most
dramatic available version of "larger" — a data-plane test that the chord is heard with no session
registered, and five shell tests: the default matches the renderer's, both clamps hold, reset works,
and a size chosen in one shell is there in a second one over the same profile directory. Layout
suite and both heads build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 13:15:11 +02:00

418 lines
13 KiB
C#

using System.Globalization;
using System.Net;
using System.Net.WebSockets;
using System.Text;
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Terminal.Tests;
/// <summary>
/// The loopback transport end to end, with a real WebSocket client.
/// </summary>
/// <remarks>
/// Uses <see cref="ClientWebSocket"/> against the real listener rather than a stubbed transport,
/// because the hand-rolled HTTP upgrade is the part that could be subtly wrong — and a handshake that
/// no real client accepts would pass any test that skipped it.
/// </remarks>
public sealed class TerminalDataPlaneTests : IAsyncDisposable
{
private const uint SessionId = 3;
private static readonly byte[] PageTemplate = Encoding.UTF8.GetBytes(
$"<html><body data-token=\"{TerminalDataPlane.TokenPlaceholder}\" "
+ $"data-socket=\"{TerminalDataPlane.SocketUrlPlaceholder}\"></body></html>");
private readonly TerminalDataPlane plane = new(new InMemoryTerminalAssetProvider(
new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)
{
[TerminalDataPlane.PagePath] = new("text/html; charset=utf-8", PageTemplate),
["/xterm.js"] = new("text/javascript; charset=utf-8", "console.log(1)"u8.ToArray()),
}));
private readonly CancellationTokenSource lifetime = new();
private Task? server;
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await lifetime.CancelAsync();
await plane.DisposeAsync();
if (server is not null)
{
try
{
await server;
}
catch (OperationCanceledException)
{
// Expected: the accept loop is stopped by cancelling it.
}
}
lifetime.Dispose();
}
// ---- Serving the page ----
[Fact]
public async Task ThePage_IsServedWithTheTokenAndSocketUrlSubstituted()
{
// Substituted at serve time rather than written into the file, so the token never touches disk
// and never appears in a URL that a log or a browser history could keep.
Start();
using var client = new HttpClient();
var body = await client.GetStringAsync(plane.PageUrl, TestContext.Current.CancellationToken);
body.ShouldContain(plane.Token);
body.ShouldContain(
string.Create(CultureInfo.InvariantCulture, $"ws://127.0.0.1:{plane.Port}/socket"));
body.ShouldNotContain(TerminalDataPlane.TokenPlaceholder);
body.ShouldNotContain(TerminalDataPlane.SocketUrlPlaceholder);
}
[Fact]
public async Task ThePage_IsNotCached()
{
// It carries a connection token.
Start();
using var client = new HttpClient();
using var response = await client.GetAsync(plane.PageUrl, TestContext.Current.CancellationToken);
response.Headers.CacheControl!.NoStore.ShouldBeTrue();
}
[Fact]
public async Task AnUnknownPath_Is404()
{
Start();
using var client = new HttpClient();
using var response = await client.GetAsync(
new Uri($"http://127.0.0.1:{plane.Port}/nope"),
TestContext.Current.CancellationToken);
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
// ---- Attaching ----
[Fact]
public async Task TheRenderer_Attaches()
{
Start();
using var socket = await ConnectAsync();
socket.State.ShouldBe(WebSocketState.Open);
socket.SubProtocol.ShouldBe(TerminalDataPlane.SubProtocol);
await plane.RendererAttached;
}
[Fact]
public async Task AConnectionWithoutTheToken_IsRejected()
{
Start();
await Should.ThrowAsync<WebSocketException>(async () =>
await ConnectAsync(token: null));
}
[Fact]
public async Task AConnectionWithTheWrongToken_IsRejected()
{
Start();
await Should.ThrowAsync<WebSocketException>(async () =>
await ConnectAsync(token: "not-the-token"));
}
[Fact]
public async Task AConnectionFromAnotherOrigin_IsRejected()
{
// The threat this actually addresses: a page open in the user's browser can attempt WebSocket
// connections to loopback ports, and would otherwise reach a terminal.
Start();
await Should.ThrowAsync<WebSocketException>(async () =>
await ConnectAsync(origin: "https://evil.example"));
}
[Fact]
public async Task ASecondRenderer_IsRejected()
{
Start();
using var first = await ConnectAsync();
first.State.ShouldBe(WebSocketState.Open);
await Should.ThrowAsync<WebSocketException>(async () => await ConnectAsync());
}
// ---- Frames ----
[Fact]
public async Task Output_ReachesTheRenderer()
{
Start();
using var socket = await ConnectAsync();
await using var session = new FakeShellSession(bytesToProduce: 128);
await using var pump = CreatePump(session);
plane.Register(SessionId, pump);
var run = pump.RunAsync(TestContext.Current.CancellationToken);
// SessionOpened first, then the output.
var opened = await ReceiveAsync(socket);
opened.Opcode.ShouldBe((byte)TerminalServerOpcode.SessionOpened);
opened.SessionId.ShouldBe(SessionId);
var output = await ReceiveAsync(socket);
output.Opcode.ShouldBe((byte)TerminalServerOpcode.Output);
output.Payload.Length.ShouldBe(128);
// Acknowledge it, exactly as the page does from term.write's callback.
await SendAsync(
socket,
(byte)TerminalClientOpcode.Acknowledge,
TerminalFrame.CreateAcknowledgementPayload((uint)output.Payload.Length));
await run;
}
[Fact]
public async Task Input_ReachesTheRemote()
{
Start();
using var socket = await ConnectAsync();
await using var session = new FakeShellSession();
await using var pump = CreatePump(session);
plane.Register(SessionId, pump);
await SendAsync(socket, (byte)TerminalClientOpcode.Input, "whoami\r"u8.ToArray());
await WaitUntilAsync(() => session.Written.Length > 0);
Encoding.UTF8.GetString(session.Written).ShouldBe("whoami\r");
}
[Fact]
public async Task AResize_ReachesTheRemote()
{
Start();
using var socket = await ConnectAsync();
await using var session = new FakeShellSession();
await using var pump = CreatePump(session);
plane.Register(SessionId, pump);
await SendAsync(
socket,
(byte)TerminalClientOpcode.Resize,
TerminalFrame.CreateResizePayload(new TerminalSize(132, 43, 1320, 1075)));
await WaitUntilAsync(() => session.ResizeCount > 0);
session.LastResize.ShouldBe(new TerminalSize(132, 43, 1320, 1075));
}
[Fact]
public async Task AnAcknowledgement_ReturnsCredit()
{
Start();
using var socket = await ConnectAsync();
await using var session = new FakeShellSession();
await using var pump = CreatePump(session);
plane.Register(SessionId, pump);
pump.Credits.TryReserve(1024);
pump.Credits.Outstanding.ShouldBe(1024);
await SendAsync(
socket,
(byte)TerminalClientOpcode.Acknowledge,
TerminalFrame.CreateAcknowledgementPayload(1024));
await WaitUntilAsync(() => pump.Credits.Outstanding == 0);
}
/// <remarks>
/// The point of this one is the <em>absence</em> of a registered session. Every other client frame is
/// about a terminal and is dropped once its session has gone; a text-size chord is about the person
/// reading, and the page sends whichever id it had to hand — often one whose shell has just ended,
/// which is exactly when somebody may be squinting at the message it left behind. Dispatching this
/// opcode before the session lookup is what makes that work.
/// </remarks>
[Fact]
public async Task AFontSizeStep_IsHeardWithNoSessionRegistered()
{
Start();
using var socket = await ConnectAsync();
var steps = new List<int>();
plane.FontSizeStepRequested += (_, e) => steps.Add(e.Step);
await SendAsync(
socket,
(byte)TerminalClientOpcode.FontSizeStep,
TerminalFrame.CreateFontSizeStepPayload(-1),
sessionId: 0);
await WaitUntilAsync(() => steps.Count > 0);
steps[0].ShouldBe(-1);
}
[Fact]
public async Task AFrameForAnUnregisteredSession_IsIgnored()
{
// Ordinary during teardown: the page can still have frames in flight for a session that just
// ended. Dropping them must not disturb anything else.
Start();
using var socket = await ConnectAsync();
await using var session = new FakeShellSession();
await using var pump = CreatePump(session);
plane.Register(SessionId, pump);
// A session id that is never registered. Using the registered one and relying on ordering
// would be a race: frames are dispatched on the receive loop, so the orphan can land after
// registration and legitimately be delivered.
await SendAsync(socket, (byte)TerminalClientOpcode.Input, "orphan"u8.ToArray(), sessionId: 999);
await SendAsync(socket, (byte)TerminalClientOpcode.Input, "real"u8.ToArray());
await WaitUntilAsync(() => session.Written.Length > 0);
Encoding.UTF8.GetString(session.Written).ShouldBe("real");
}
[Fact]
public async Task AMalformedFrame_IsIgnored()
{
Start();
using var socket = await ConnectAsync();
// Shorter than the header.
await socket.SendAsync(
new byte[] { 1, 2 },
WebSocketMessageType.Binary,
endOfMessage: true,
TestContext.Current.CancellationToken);
await using var session = new FakeShellSession();
await using var pump = CreatePump(session);
plane.Register(SessionId, pump);
await SendAsync(socket, (byte)TerminalClientOpcode.Input, "still here"u8.ToArray());
await WaitUntilAsync(() => session.Written.Length > 0);
Encoding.UTF8.GetString(session.Written).ShouldBe("still here");
}
// ---- Helpers ----
private void Start() => server = plane.RunAsync(lifetime.Token);
private TerminalSessionPump CreatePump(FakeShellSession session) =>
new(
SessionId,
session,
plane,
TimeProvider.System,
new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(10) });
private async Task<ClientWebSocket> ConnectAsync(
string? token = "",
string? origin = null)
{
var socket = new ClientWebSocket();
socket.Options.AddSubProtocol(TerminalDataPlane.SubProtocol);
if (token is not null)
{
socket.Options.AddSubProtocol($"token.{(token.Length == 0 ? plane.Token : token)}");
}
socket.Options.SetRequestHeader(
"Origin",
origin ?? string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{plane.Port}"));
try
{
await socket.ConnectAsync(
new Uri($"ws://127.0.0.1:{plane.Port}{TerminalDataPlane.SocketPath}"),
TestContext.Current.CancellationToken);
}
catch
{
socket.Dispose();
throw;
}
return socket;
}
private static async Task SendAsync(
ClientWebSocket socket,
byte opcode,
byte[] payload,
uint sessionId = SessionId) =>
await socket.SendAsync(
TerminalFrame.Create(opcode, sessionId, payload),
WebSocketMessageType.Binary,
endOfMessage: true,
TestContext.Current.CancellationToken);
private static async Task<(byte Opcode, uint SessionId, byte[] Payload)> ReceiveAsync(
ClientWebSocket socket)
{
var buffer = new byte[64 * 1024];
var result = await socket.ReceiveAsync(
buffer.AsMemory(),
TestContext.Current.CancellationToken);
TerminalFrame.TryRead(buffer.AsSpan(0, result.Count), out var opcode, out var sessionId, out var payload)
.ShouldBeTrue();
return (opcode, sessionId, payload.ToArray());
}
/// <remarks>
/// Polls rather than awaiting a signal: inbound frames are dispatched on the transport's receive
/// loop, which has no completion for the caller to await. The bound keeps a genuine failure a
/// failure rather than a hang.
/// </remarks>
private static async Task WaitUntilAsync(Func<bool> condition)
{
var deadline = TimeProvider.System.GetUtcNow() + TimeSpan.FromSeconds(5);
while (TimeProvider.System.GetUtcNow() < deadline)
{
if (condition())
{
return;
}
await Task.Delay(10, TestContext.Current.CancellationToken);
}
throw new TimeoutException("The expected state was not reached within 5 seconds.");
}
}