Files
DodoSSH/tests/DodoSSH.Client.Terminal.Tests/TerminalFrameTests.cs
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

152 lines
4.8 KiB
C#

using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Terminal.Tests;
/// <summary>
/// The wire format between the host and the renderer page.
/// </summary>
/// <remarks>
/// Byte order is asserted explicitly rather than only round-tripped. Both ends of this protocol are
/// ours today, but the JavaScript side reads the header with a <c>DataView</c>, whose default is
/// big-endian — a round-trip-only test would pass just as happily with a little-endian header that the
/// page then misreads.
/// </remarks>
public sealed class TerminalFrameTests
{
[Fact]
public void AFrame_RoundTrips()
{
var frame = TerminalFrame.Create((byte)TerminalServerOpcode.Output, 42, "hello"u8);
TerminalFrame.TryRead(frame, out var opcode, out var sessionId, out var payload).ShouldBeTrue();
opcode.ShouldBe((byte)TerminalServerOpcode.Output);
sessionId.ShouldBe(42u);
payload.ToArray().ShouldBe("hello"u8.ToArray());
}
[Fact]
public void TheSessionId_IsBigEndian()
{
var frame = TerminalFrame.Create((byte)TerminalServerOpcode.Output, 0x01020304, []);
frame[1].ShouldBe((byte)0x01);
frame[2].ShouldBe((byte)0x02);
frame[3].ShouldBe((byte)0x03);
frame[4].ShouldBe((byte)0x04);
}
[Fact]
public void AnEmptyPayload_IsLegal()
{
// SessionOpened carries nothing.
var frame = TerminalFrame.Create((byte)TerminalServerOpcode.SessionOpened, 1, []);
frame.Length.ShouldBe(TerminalFrame.HeaderLength);
TerminalFrame.TryRead(frame, out _, out _, out var payload).ShouldBeTrue();
payload.Length.ShouldBe(0);
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(4)]
public void AFrameShorterThanTheHeader_IsRejected(int length)
{
// These arrive from a WebView page, so a malformed frame is untrusted input to drop rather
// than an exceptional condition.
TerminalFrame.TryRead(new byte[length], out _, out _, out _).ShouldBeFalse();
}
[Fact]
public void Write_RejectsATooSmallDestination()
{
Should.Throw<ArgumentException>(() =>
TerminalFrame.Write(new byte[4], 1, 1, "payload"u8));
}
[Fact]
public void AnAcknowledgement_RoundTrips()
{
var payload = TerminalFrame.CreateAcknowledgementPayload(123_456);
TerminalFrame.TryReadAcknowledgement(payload, out var rendered).ShouldBeTrue();
rendered.ShouldBe(123_456u);
}
[Theory]
[InlineData(0)]
[InlineData(3)]
[InlineData(5)]
public void AnAcknowledgementOfTheWrongLength_IsRejected(int length)
{
TerminalFrame.TryReadAcknowledgement(new byte[length], out _).ShouldBeFalse();
}
[Fact]
public void AResize_RoundTrips()
{
var size = new TerminalSize(132, 43, 1320, 1075);
var payload = TerminalFrame.CreateResizePayload(size);
TerminalFrame.TryReadResize(payload, out var decoded).ShouldBeTrue();
decoded.ShouldBe(size);
}
[Theory]
[InlineData(0)]
[InlineData(7)]
[InlineData(9)]
public void AResizeOfTheWrongLength_IsRejected(int length)
{
TerminalFrame.TryReadResize(new byte[length], out _).ShouldBeFalse();
}
[Fact]
public void AResizePayload_OrdersColumnsBeforeRows()
{
// The page builds this by hand, and columns-before-rows is the SSH convention. Swapping them
// produces a terminal that is 24 columns by 80 rows, which looks like a rendering bug.
var payload = TerminalFrame.CreateResizePayload(new TerminalSize(80, 24));
payload[0].ShouldBe((byte)0);
payload[1].ShouldBe((byte)80);
payload[2].ShouldBe((byte)0);
payload[3].ShouldBe((byte)24);
}
/// <remarks>
/// The step byte is signed, and this is what says so. Read as unsigned, a step down arrives as 255 —
/// which the shell clamps to the largest font it will set, so getting this wrong makes "smaller" do
/// the most dramatic available version of "larger".
/// </remarks>
[Theory]
[InlineData(1)]
[InlineData(-1)]
[InlineData(0)]
[InlineData(-8)]
public void AFontSizeStep_RoundTripsWithItsSign(int step)
{
var payload = TerminalFrame.CreateFontSizeStepPayload(step);
TerminalFrame.TryReadFontSizeStep(payload, out var decoded).ShouldBeTrue();
decoded.ShouldBe(step);
}
[Theory]
[InlineData(0)]
[InlineData(2)]
public void AFontSizeStepOfTheWrongLength_IsRejected(int length)
{
TerminalFrame.TryReadFontSizeStep(new byte[length], out _).ShouldBeFalse();
}
[Fact]
public void AFontSize_IsOneUnsignedByte()
{
// Unsigned, unlike the step: a size is a size, and the byte is what the page reads straight into
// xterm's fontSize option.
TerminalFrame.CreateFontSizePayload(20).ShouldBe([(byte)20]);
}
}