Make the terminal's right button copy and paste, and never open a menu
ci / build and test (push) Successful in 5m43s
ci / android head (push) Successful in 4m4s
ci / desktop nightly (push) Successful in 1m28s
ci / api image (push) Successful in 26s

Selecting output and right-clicking it opened the WebView's own context menu with
Copy on it, which is correct behaviour for a web page and wrong for a terminal.
What a terminal's right button has meant since the Windows console got QuickEdit,
and since PuTTY shipped, is this: with something selected it copies and drops the
selection, with nothing selected it pastes. No menu, no second click.

The gesture can only live in the page. The selection is drawn into a canvas the
host never sees, and once a terminal has focus the host's window receives no input
events at all — the same reason the Ctrl+plus chords are handled there.

◆ THE CLIPBOARD IS THE HOST'S IN BOTH DIRECTIONS, AND THE READING IS WHY.

navigator.clipboard.readText() is gated behind the clipboard-read permission in
every Chromium, and an embedded WebView with no PermissionRequested handler
answers a permission request by putting a prompt on the screen. Trading a context
menu for a permission dialog is not this change. So the page asks and the host
answers, exactly as requestFontSizeStep does.

Writing does not go the other way either, although navigator.clipboard.writeText
would have worked from the page on Windows: a clipboard that is the host's in one
direction and the page's in the other is two mechanisms to keep working across
WebView2, WebKitGTK and Android's WebView. The host has owned a clipboard delegate
since the keychain grew its COPY buttons, so each App.axaml.cs grows a
ClipboardReader beside its ClipboardWriter and MainWindowViewModel takes it.

Three opcodes: client ClipboardCopy and ClipboardPaste, server Clipboard.

◆ Clipboard IS NOT Paste, AND THE DIFFERENCE IS THE BRACKETING.

Paste carries a snippet off the sidebar, and the page deliberately *types* a
single-line one rather than bracketing it, because bash marks a bracketed paste as
an active region and a snippet should read as though it had been typed. A clipboard
paste is the case those markers are for — readline uses them to decide it has been
pasted into — so Clipboard is always bracketed and never followed by an Enter. One
opcode for both would have needed a flag meaning "which of the two kinds of paste
is this", on a payload whose existing flag byte means something else.

Both client frames are dispatched before the session lookup, which is what keeps
the gesture working in a pane whose shell has just ended: squinting at the message
a remote left behind is exactly when somebody copies the line. Dispatch was past
MA0051's length once it grew a second such branch, so the frames no pump needs to
see moved into Announce() and the font-size chord went with them.

◆ CAPTURED AND STOPPED, RATHER THAN MERELY PREVENTED.

preventDefault alone suppresses the menu and still lets xterm's own contextmenu
handler run, which moves its helper textarea under the pointer at z-index 1000 and
leaves the selection sitting in it, selected. Both of those exist to serve the
native menu. Taking the event in the capture phase on the pane and stopping it
there is what keeps the gesture wholly the page's.

rightClickSelectsWord is now false, which on macOS is a change of default rather
than a restatement of it: xterm sets it true there alone, so a right-click would
have selected the word under the pointer and copied that instead of pasting —
wrong on one platform only, which is the worst way for it to be wrong.

The renderer harness grows the pane it has been inviting since
RendererReconnectionTests: a fake Terminal, the two addons, and a real UTF-8 codec,
because both directions run through the page's own TextEncoder and TextDecoder and
a Latin-1 stub would pass a test with an em dash in the selection. Its warning
about declaring names terminal.js declares now covers the half that does not
SyntaxError — a harness frame() was silently replaced by the page's frame() and
failed six tests on a type error a long way from the cause.

Manual checks 1.11 and 11.13 are what is left for a person: that a real contextmenu
event reaches a capture listener, that no native menu is drawn once it is
prevented, and that xterm's real paste() brackets. 11.13 also records what is
unverified — whether Android's WebView delivers a pointer's secondary click as a
contextmenu event at all. ClipboardReader is wired on that head regardless,
because the alternative is a head where the paste half silently does nothing and
nobody finds out; that head is not compiled here either, this machine having no
android workload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
laurensandClaude Opus 5 committed 2026-08-25 14:46:36 +02:00
1 parent 2810f7c12b
commit 1908189100
16 files changed
+1557 -22

No files matched your search

@@ -5,17 +5,36 @@
It fakes exactly what the page's transport touches and nothing else: a WebSocket whose every step is
driven from the test, a clock that only moves when a test moves it, and the two DOM objects the page
reads at load. There is no Terminal, no fit addon and no WebGL here, because nothing on the reconnection
path builds a pane — a test that opens a session will have to add them, and should, rather than this
file guessing now at what such a test would want.
reads at load.
◆ AND, SINCE RendererClipboardTests, A PANE.
Which the reconnection tests never needed and this file used to say so, inviting whoever first opened a
session to add the Terminal, the fit addon and the WebGL addon rather than guessing at them in advance.
They are below, and they are stubs with a memory: a fake terminal records what was pasted into it, what
was typed into it and what it was asked to focus, because those three are the whole of what the page does
to a pane and therefore the whole of what a test can ask about. The selection is settable from a test,
since a selection is made with a mouse in a canvas and there is no canvas here.
What a fake terminal is NOT is xterm. It cannot say whether paste() brackets its argument or whether
getSelection() trims a row's right padding — that is xterm's behaviour, exercised by xterm's own suite and
by docs/manual-checks.md 1.11 here. What these tests can prove is which of the two the page calls, with
what, and addressed to which session, and that is where every mistake in this area has actually been.
◆ NOTHING HERE MAY DECLARE A NAME terminal.js ALSO DECLARES.
Both files are evaluated as scripts into the same global scope, and the page's own `const root` against
a `var root` here is a SyntaxError before a line of either runs. That is why the page's two elements are
Both files are evaluated as scripts into the same global scope, and a collision goes one of two ways.
Against one of the page's `const`s — its own `root`, say — it is a SyntaxError before a line of either
runs, which is the good outcome: it is impossible to miss. That is why the page's two elements are
`pageRoot` and `pageBanner` below and reached through getElementById, which is how the page reaches them
anyway.
Against one of the page's *functions* there is no error at all. The page is evaluated second, so its
declaration silently replaces this file's and every test calling that name gets the page's function
instead — which is how `frame()` here, meant to read a sent frame back, quietly returned the ArrayBuffer
that terminal.js's own frame() builds instead, and failed six tests on a type error a long way from the
cause. Hence `sentFrames`/`sentFrame` below: prefixed on purpose, not for tidiness.
◆ AND THE FAKE SOCKET'S BEHAVIOUR IS THE PART TO GET RIGHT.
close() is the one method with a rule that is not obvious and that the tests lean on: a socket closed
@@ -30,6 +49,15 @@ var clock = { now: 0, next: 1, timers: {} };
/** Every socket the page has opened, in order, live or dead. */
var sockets = [];
/** Every frame the page has sent, in order, as arrays of byte values. */
var outbound = [];
/** Every fake terminal the page has opened on a pane, in the order it opened them. */
var terminals = [];
/** What the last right-click did about the browser's own menu. See rightClick(). */
var gesture = { prevented: 0, stopped: 0 };
function listeners(target) {
target.handlers = {};
@@ -41,11 +69,11 @@ function listeners(target) {
target.handlers[name].push(handler);
};
target.fire = function (name) {
target.fire = function (name, event) {
var handlers = target.handlers[name] || [];
for (var i = 0; i < handlers.length; i++) {
handlers[i]({});
handlers[i](event || {});
}
};
@@ -67,7 +95,22 @@ FakeSocket.OPEN = 1;
FakeSocket.CLOSING = 2;
FakeSocket.CLOSED = 3;
FakeSocket.prototype.send = function () {};
/**
* Records rather than discards, because half of what the page does on a right-click is a frame.
*
* Kept as raw bytes and decoded on the way out — see sentFrame() — so that a payload the page wrote as
* UTF-8 is read back as UTF-8 rather than as whatever a stringification would have made of it.
*/
FakeSocket.prototype.send = function (buffer) {
var bytes = new Uint8Array(buffer);
var copy = [];
for (var i = 0; i < bytes.length; i++) {
copy.push(bytes[i]);
}
outbound.push(copy);
};
/** What the page calls. See the note above on why a connecting socket goes quietly. */
FakeSocket.prototype.close = function () {
@@ -93,9 +136,31 @@ var pageRoot = listeners({
var document = listeners({
hidden: false,
getElementById: function (id) { return id === 'root' ? pageRoot : pageBanner; },
createElement: function () { return { dataset: {}, style: {}, remove: function () {} }; },
createElement: function () { return newElement(); },
});
/**
* A pane, as far as the page can tell.
*
* With real dimensions, and not because anything would fail without them — which is the point. The page's
* guard is `clientWidth < MINIMUM_FITTABLE_PIXELS`, and an undefined width compares false against every
* number, so a fake with no dimensions slips past a rule it ought to have been caught by and gets fitted
* anyway. A fake that passes for the wrong reason stops matching the page the day that guard is written
* differently.
*
* Listeners, because the right-click handler is registered on this and there is nowhere else to fire it
* from.
*/
function newElement() {
return listeners({
dataset: {},
style: {},
clientWidth: 800,
clientHeight: 600,
remove: function () {},
});
}
var window = listeners({});
// The page warns through this on paths no reconnection test reaches. Present so that a test which does
@@ -118,6 +183,142 @@ function ResizeObserver() {
this.observe = function () {};
}
// ── The pane's three globals, which the page reaches by the names the UMD builds attach ──────────────
/**
* A terminal that remembers what was done to it.
*
* `selection` is a plain field rather than anything computed, and settable from a test through select():
* a selection is made by dragging a mouse across a canvas, which is the one part of this there is no
* standing in for. What the tests are about is what the page does with whatever is selected.
*/
function FakeTerminal(options) {
this.options = options || {};
this.cols = 80;
this.rows = 24;
this.selection = '';
this.pastes = [];
this.inputs = [];
this.writes = [];
this.focuses = 0;
this.disposed = false;
this.pane = null;
terminals.push(this);
}
FakeTerminal.prototype.loadAddon = function () {};
FakeTerminal.prototype.open = function (pane) { this.pane = pane; };
FakeTerminal.prototype.attachCustomKeyEventHandler = function (handler) { this.keys = handler; };
FakeTerminal.prototype.onData = function (handler) { this.data = handler; };
FakeTerminal.prototype.onResize = function (handler) { this.resized = handler; };
FakeTerminal.prototype.focus = function () { this.focuses++; };
FakeTerminal.prototype.paste = function (text) { this.pastes.push(text); };
FakeTerminal.prototype.input = function (text) { this.inputs.push(text); };
FakeTerminal.prototype.getSelection = function () { return this.selection; };
FakeTerminal.prototype.clearSelection = function () { this.selection = ''; };
FakeTerminal.prototype.dispose = function () { this.disposed = true; };
/**
* The write callback is the acknowledgement, so it is called rather than kept.
*
* Synchronously, which a real xterm does not promise — it renders first. The page's contract is that the
* acknowledgement is sent from this callback and not before, and calling it straight away is what lets a
* test assert that without needing a frame clock.
*/
FakeTerminal.prototype.write = function (data, done) {
this.writes.push(data);
if (done) {
done();
}
};
var Terminal = FakeTerminal;
var FitAddon = {
FitAddon: function () {
this.fit = function () {};
},
};
var WebglAddon = {
WebglAddon: function () {
this.onContextLoss = function () {};
this.dispose = function () {};
},
};
// ── UTF-8, because the page encodes and decodes every payload through these ──────────────────────────
//
// Written out rather than stubbed as one byte per character. The page sends a selection as UTF-8 and reads
// a clipboard as UTF-8, so a harness that treated the two as Latin-1 would pass a test with an em dash in
// the selection and ship a page that mangled it.
function TextEncoder() {
this.encode = function (text) {
var bytes = [];
for (var i = 0; i < text.length; i++) {
var point = text.charCodeAt(i);
// A surrogate pair is one code point in two units. Combined here, because encoding each half on its
// own is exactly the mojibake this exists to catch.
if (point >= 0xd800 && point <= 0xdbff && i + 1 < text.length) {
var low = text.charCodeAt(i + 1);
if (low >= 0xdc00 && low <= 0xdfff) {
point = 0x10000 + ((point - 0xd800) << 10) + (low - 0xdc00);
i++;
}
}
if (point < 0x80) {
bytes.push(point);
} else if (point < 0x800) {
bytes.push(0xc0 | (point >> 6), 0x80 | (point & 0x3f));
} else if (point < 0x10000) {
bytes.push(0xe0 | (point >> 12), 0x80 | ((point >> 6) & 0x3f), 0x80 | (point & 0x3f));
} else {
bytes.push(
0xf0 | (point >> 18),
0x80 | ((point >> 12) & 0x3f),
0x80 | ((point >> 6) & 0x3f),
0x80 | (point & 0x3f));
}
}
return new Uint8Array(bytes);
};
}
function TextDecoder() {
this.decode = function (bytes) {
var text = '';
for (var i = 0; i < bytes.length;) {
var first = bytes[i];
var length = first < 0x80 ? 1 : first < 0xe0 ? 2 : first < 0xf0 ? 3 : 4;
var point = length === 1 ? first : first & (0x7f >> length);
for (var n = 1; n < length; n++) {
point = (point << 6) | (bytes[i + n] & 0x3f);
}
i += length;
if (point > 0xffff) {
point -= 0x10000;
text += String.fromCharCode(0xd800 + (point >> 10), 0xdc00 + (point & 0x3ff));
} else {
text += String.fromCharCode(point);
}
}
return text;
};
}
// ── What the tests drive the page with ──────────────────────────────────────────────────────────────
/**
@@ -165,6 +366,134 @@ function banner() {
return pageBanner.textContent;
}
/** How many frames the page has sent since it loaded. */
function sentFrames() {
return outbound.length;
}
/**
* One frame the page sent, as "opcode/session/payload", the payload decoded as UTF-8.
*
* One string rather than three values because it crosses into C# as one, and a test asserting on
* "5/7/whoami" reads as the frame it is. Resize frames land here too and read as rubbish; nothing asserts
* on those, and rendering them some other way would mean a second shape for a reader to learn.
*/
function sentFrame(index) {
var bytes = outbound[index];
var session = ((bytes[1] << 24) | (bytes[2] << 16) | (bytes[3] << 8) | bytes[4]) >>> 0;
return bytes[0] + '/' + session + '/' + new TextDecoder().decode(bytes.slice(5));
}
/** Hands the page a frame, on whichever socket is its current one. */
function deliverFrame(opcode, sessionId, payload) {
var bytes = new Uint8Array(5 + payload.length);
var header = new DataView(bytes.buffer);
header.setUint8(0, opcode);
header.setUint32(1, sessionId);
bytes.set(payload, 5);
sockets[sockets.length - 1].fire('message', { data: bytes.buffer });
}
/** SessionOpened, which is what makes the page build a pane. */
function openSession(sessionId) {
deliverFrame(2, sessionId, []);
}
/** Clipboard: the host's answer to a right-click paste. */
function deliverClipboard(sessionId, text) {
deliverFrame(8, sessionId, new TextEncoder().encode(text));
}
/** Paste: a snippet off the sidebar, which is deliberately not the same frame. */
function deliverSnippet(sessionId, text, execute) {
var body = new TextEncoder().encode(text);
var payload = new Uint8Array(1 + body.length);
payload[0] = execute ? 1 : 0;
payload.set(body, 1);
deliverFrame(6, sessionId, payload);
}
/** The pane behind one session, found the way the page labelled it. */
function terminalFor(sessionId) {
for (var i = 0; i < terminals.length; i++) {
if (terminals[i].pane && terminals[i].pane.dataset.sessionId === String(sessionId)) {
return terminals[i];
}
}
throw new Error('No pane for session ' + sessionId + '.');
}
/** Drags a selection across that pane, which is the one thing a fake terminal cannot do for itself. */
function select(sessionId, text) {
terminalFor(sessionId).selection = text;
}
/** What is selected in that pane now — how a test sees that a copy dropped it. */
function selection(sessionId) {
return terminalFor(sessionId).selection;
}
/**
* The right button, over that pane.
*
* The event carries only the two methods the page calls on it, and counts the calls: suppressing the
* browser's own menu is half of what this gesture has to do, and a handler that copied correctly and left
* the menu up would be a regression no assertion about frames could see.
*/
function rightClick(sessionId) {
gesture = { prevented: 0, stopped: 0 };
terminalFor(sessionId).pane.fire('contextmenu', {
preventDefault: function () { gesture.prevented++; },
stopPropagation: function () { gesture.stopped++; },
});
}
/**
* The right button somewhere that is not a pane — the status banner, in practice.
*
* Fired on the document rather than on an element, which is where the page's fallback rule lives: a pane
* stops the event before it gets here, so this is the path only a click that missed every pane takes.
*/
function rightClickOffAPane() {
gesture = { prevented: 0, stopped: 0 };
document.fire('contextmenu', {
preventDefault: function () { gesture.prevented++; },
stopPropagation: function () { gesture.stopped++; },
});
}
/** Whether the last right-click took the event away from the browser, and from xterm. */
function prevented() {
return gesture.prevented;
}
function stopped() {
return gesture.stopped;
}
/** Everything paste() was given for that pane, joined — the bracketed route. */
function pasted(sessionId) {
return terminalFor(sessionId).pastes.join('|');
}
/** Everything input() was given for that pane, joined — the typed route. */
function typed(sessionId) {
return terminalFor(sessionId).inputs.join('|');
}
/** How many times that pane has been asked for the caret. */
function focuses(sessionId) {
return terminalFor(sessionId).focuses;
}
/** Whether that attempt has been closed, by the page or by the far end. */
function isClosed(index) {
return sockets[index].readyState === FakeSocket.CLOSED;
@@ -0,0 +1,276 @@
namespace DodoSSH.Client.Terminal.Tests;
/// <summary>
/// The renderer page's right button: a copy, a paste, and never a menu.
/// </summary>
/// <remarks>
/// <para>
/// The gesture every terminal has had since the Windows console got QuickEdit — select and right-click to
/// copy, right-click with nothing selected to paste. It lives in the page because that is the only place it
/// can: the selection is drawn into a canvas the host never sees, and once a terminal has focus the host's
/// window receives no input events at all.
/// </para>
/// <para>
/// What is asserted here is which frame the page sends, addressed to which session, and which of xterm's two
/// insertion routes it takes on the way back. What is deliberately <em>not</em> asserted is anything about
/// xterm itself — whether <c>paste()</c> really brackets, whether a selection really loses its right
/// padding — because the fake terminal in the harness is a stub with a memory and not an emulator. That
/// boundary is where <c>docs/manual-checks.md</c> 1.11 picks up.
/// </para>
/// </remarks>
public sealed class RendererClipboardTests
{
/// <summary>The session every single-pane test here opens.</summary>
private const uint SessionId = 7;
[Fact]
public void ARightClickOverASelection_CopiesIt()
{
var page = Attached();
page.Do("select(7, 'error: nothing was written')");
page.Do("rightClick(7)");
page.Sent.ShouldContain("5/7/error: nothing was written");
}
/// <remarks>
/// The console's behaviour, and load-bearing rather than cosmetic. Dropping the selection is the only
/// acknowledgement of the copy there is — nothing else on screen moves — and it is what makes the next
/// right-click a paste rather than a second copy of something forgotten about.
/// </remarks>
[Fact]
public void ARightClickOverASelection_DropsTheSelectionAfterCopyingIt()
{
var page = Attached();
page.Do("select(7, 'systemctl status sshd')");
page.Do("rightClick(7)");
page.Selection(SessionId).ShouldBeEmpty();
}
[Fact]
public void ARightClickWithNothingSelected_AsksTheHostForTheClipboard()
{
var page = Attached();
page.Do("rightClick(7)");
// No payload: the page has nothing to send, which is the whole reason it has to ask.
page.Sent.ShouldContain("6/7/");
}
/// <remarks>
/// The composite property, and the one a user would describe: copy, then paste. It passes only if the
/// copy dropped the selection — a page that left it standing sends a second copy frame here and never a
/// paste request, which is the failure that reads as "paste is broken".
/// </remarks>
[Fact]
public void ASecondRightClick_PastesBecauseTheFirstOneCopied()
{
var page = Attached();
page.Do("select(7, 'df -h')");
page.Do("rightClick(7)");
page.Do("rightClick(7)");
page.Sent.ShouldContain("5/7/df -h");
page.Sent.ShouldContain("6/7/");
}
/// <remarks>
/// <para>
/// <c>preventDefault</c> is the WebView's own context menu, which is what this whole change is about.
/// </para>
/// <para>
/// <c>stopPropagation</c> is xterm's, and it is the half that is easy to leave out. xterm registers its
/// own <c>contextmenu</c> handler on the element inside the pane, which parks a 20-pixel textarea under
/// the pointer at z-index 1000 and leaves the selection sitting in it, selected — both of which exist to
/// serve the native menu and are litter without one. The page takes the event in the capture phase to
/// get ahead of it; a listener that forgot the third argument to <c>addEventListener</c> would still
/// suppress the menu and would fail here.
/// </para>
/// </remarks>
[Fact]
public void ARightClick_TakesTheEventFromTheBrowserAndFromXterm()
{
var page = Attached();
page.Do("rightClick(7)");
page.Prevented.ShouldBe(1);
page.Stopped.ShouldBe(1);
}
/// <remarks>
/// The panes cover the page — they are positioned at inset 0 — so the only right-click that misses one is
/// on the status banner, which is drawn over the bottom of the pane while it has something to say. That
/// used to be the one place in a terminal window that still opened a browser menu.
/// </remarks>
[Fact]
public void ARightClickThatMissesEveryPane_StillOpensNoMenu()
{
var page = Attached();
page.Do("rightClickOffAPane()");
page.Prevented.ShouldBe(1);
// And nothing is stopped, because there is nothing below the document to stop it from — nor any
// frame sent, because a click that is on no pane is a click about no session.
page.Stopped.ShouldBe(0);
}
/// <remarks>
/// A right-click over a pane with a selection sends one frame and not two: the branch is exclusive, and a
/// page that asked for the clipboard as well would paste over the selection it had just copied.
/// </remarks>
[Fact]
public void ARightClickOverASelection_DoesNotAlsoAskForTheClipboard()
{
var page = Attached();
page.Do("select(7, 'uname -a')");
page.Do("rightClick(7)");
page.Sent.ShouldNotContain("6/7/");
}
/// <remarks>
/// Two panes, because the session id on a clipboard frame is the whole of what puts the text where the
/// pointer was. They agree with the host's own idea of the active tab in every arrangement that exists
/// today; the page is what knows, so this is what proves the page says.
/// </remarks>
[Fact]
public void ARightClick_IsAddressedToThePaneItHappenedIn()
{
var page = Attached();
page.Do("openSession(9)");
page.Do("select(7, 'the first pane'); select(9, 'the second pane')");
page.Do("rightClick(9)");
page.Sent.ShouldContain("5/9/the second pane");
page.Sent.ShouldNotContain("5/7/the first pane");
}
/// <remarks>
/// The other half of the round trip. <c>paste()</c> rather than <c>input()</c>, because this text came
/// off the system clipboard and that is the case bracketed paste exists for: a shell that receives a
/// multi-line command inside the markers treats every newline as text rather than as "run this".
/// </remarks>
[Fact]
public void TheClipboardComingBack_IsPastedRatherThanTyped()
{
var page = Attached();
page.Do("deliverClipboard(7, 'sudo systemctl restart sshd')");
page.Pasted(SessionId).ShouldBe("sudo systemctl restart sshd");
page.Typed(SessionId).ShouldBeEmpty();
}
/// <remarks>
/// No Enter, ever. A clipboard paste inserts and stops; pressing Enter for somebody is what the sidebar's
/// RUN does, and that is a thing they asked for by name.
/// </remarks>
[Fact]
public void TheClipboardComingBack_DoesNotPressEnter()
{
var page = Attached();
page.Do("deliverClipboard(7, 'rm -rf /var/tmp/build')");
page.Typed(SessionId).ShouldBeEmpty();
}
/// <summary>The caret follows the paste, so the next keystroke lands where the text did.</summary>
[Fact]
public void TheClipboardComingBack_LeavesTheCaretInThatPane()
{
var page = Attached();
var before = page.Focuses(SessionId);
page.Do("deliverClipboard(7, 'whoami')");
page.Focuses(SessionId).ShouldBe(before + 1);
}
/// <remarks>
/// Multi-byte, because both directions run through the page's own <c>TextEncoder</c> and
/// <c>TextDecoder</c> and a character that straddles nothing is not a test of either. The harness's codec
/// is a real UTF-8 one for this reason; see its remark.
/// </remarks>
[Fact]
public void TheTextSurvivesTheRoundTrip_WhenItIsNotAscii()
{
var page = Attached();
page.Do("select(7, 'échec — 90° · λ')");
page.Do("rightClick(7)");
page.Do("deliverClipboard(7, 'échec — 90° · λ')");
page.Sent.ShouldContain("5/7/échec — 90° · λ");
page.Pasted(SessionId).ShouldBe("échec — 90° · λ");
}
/// <remarks>
/// The distinction the new opcode exists to keep, asserted from the other side: a snippet off the sidebar
/// is still <em>typed</em> when it is a single line, because bash marks a bracketed paste as an active
/// region and a snippet should read as though it had been typed. Sharing one opcode with the clipboard
/// would have meant choosing one of these two behaviours for both.
/// </remarks>
[Fact]
public void ASingleLineSnippet_IsStillTypedRatherThanPasted()
{
var page = Attached();
page.Do("deliverSnippet(7, 'tail -f /var/log/syslog', false)");
page.Typed(SessionId).ShouldBe("tail -f /var/log/syslog");
page.Pasted(SessionId).ShouldBeEmpty();
}
/// <inheritdoc cref="ASingleLineSnippet_IsStillTypedRatherThanPasted" />
[Fact]
public void AMultiLineSnippet_IsStillPastedRatherThanTyped()
{
var page = Attached();
page.Do("deliverSnippet(7, 'cd /srv\\nmake\\nmake install', false)");
page.Pasted(SessionId).ShouldBe("cd /srv\nmake\nmake install");
page.Typed(SessionId).ShouldBeEmpty();
}
/// <remarks>
/// And the Enter goes through the typed route even for a multi-line snippet, deliberately outside the
/// bracketing: a <c>\r</c> inside the wrapper arrives at the shell as a literal carriage return and
/// nothing runs.
/// </remarks>
[Fact]
public void ASnippetWithRun_PressesEnterOutsideTheBrackets()
{
var page = Attached();
page.Do("deliverSnippet(7, 'cd /srv\\nmake', true)");
page.Pasted(SessionId).ShouldBe("cd /srv\nmake");
page.Typed(SessionId).ShouldBe("\r");
}
/// <summary>A page with an open socket and one pane, which is where every test here starts.</summary>
private static RendererPage Attached()
{
var page = RendererPage.Load();
// The socket has to be open before anything is sent: send() drops a frame when it is not, which
// would make every assertion here pass or fail on the transport rather than on the gesture.
page.Do("accept(0)");
page.Do("openSession(7)");
return page;
}
}
@@ -44,6 +44,60 @@ internal sealed class RendererPage
/// <summary>What the status element says — the banner a user would be looking at.</summary>
public string Banner => engine.Evaluate("banner()").AsString();
/// <summary>
/// Every frame the page has sent, each as <c>opcode/session/payload</c>.
/// </summary>
/// <remarks>
/// Materialised on each read rather than cached, because the point of it is what the page did in answer
/// to the last <see cref="Do"/>. A session's own resize frames are in here too and read as rubbish; see
/// the harness's <c>sentFrame()</c> for why they are not rendered some other way.
/// </remarks>
public IReadOnlyList<string> Sent
{
get
{
var count = (int)engine.Evaluate("sentFrames()").AsNumber();
var frames = new List<string>(count);
for (var index = 0; index < count; index++)
{
frames.Add(
engine
.Evaluate(string.Create(CultureInfo.InvariantCulture, $"sentFrame({index})"))
.AsString());
}
return frames;
}
}
/// <summary>How many times the last right-click called <c>preventDefault</c>, and <c>stopPropagation</c>.</summary>
/// <remarks>
/// Both, because they answer different questions: the first is whether the WebView's own context menu
/// was suppressed, and the second is whether xterm's own right-click handling was — which is what keeps
/// a 20-pixel textarea from being parked under the pointer with the selection sitting in it.
/// </remarks>
public int Prevented => (int)engine.Evaluate("prevented()").AsNumber();
/// <inheritdoc cref="Prevented" />
public int Stopped => (int)engine.Evaluate("stopped()").AsNumber();
/// <summary>What is selected in that session's pane.</summary>
public string Selection(uint sessionId) =>
engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"selection({sessionId})")).AsString();
/// <summary>Everything <c>term.paste</c> was given for that session — the bracketed route.</summary>
public string Pasted(uint sessionId) =>
engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"pasted({sessionId})")).AsString();
/// <summary>Everything <c>term.input</c> was given for that session — the typed route.</summary>
public string Typed(uint sessionId) =>
engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"typed({sessionId})")).AsString();
/// <summary>How many times that session's pane has been asked for the caret.</summary>
public int Focuses(uint sessionId) =>
(int)engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"focuses({sessionId})")).AsNumber();
/// <summary>
/// Loads the harness and then the page, leaving the page exactly as it is a moment after the WebView
/// navigated to it: one socket opened and still connecting.
@@ -356,6 +356,82 @@ public sealed class TerminalDataPlaneTests : IAsyncDisposable
steps[0].ShouldBe(-1);
}
/// <remarks>
/// Dispatched before the session lookup for the reason the text-size chord is, and one of its own: a
/// clipboard is not a property of any terminal. The absence of a registered session is the point of the
/// test — copying the last thing a remote said before it hung up is an ordinary thing to want, and a
/// dead tab must not be the one place the gesture silently does nothing.
/// </remarks>
[Fact]
public async Task ACopy_IsHeardWithNoSessionRegistered()
{
Start();
using var socket = await ConnectAsync();
var copied = new List<string>();
plane.ClipboardCopyRequested += (_, e) => copied.Add(e.Text);
await SendAsync(
socket,
(byte)TerminalClientOpcode.ClipboardCopy,
Encoding.UTF8.GetBytes("error: nothing was written"));
await WaitUntilAsync(() => copied.Count > 0);
copied[0].ShouldBe("error: nothing was written");
}
/// <remarks>
/// An empty copy is dropped rather than forwarded. The clipboard is shared with everything else on the
/// machine, so "copy nothing" would be a gesture that quietly destroys whatever was on it — and the page
/// does not send one, which makes this a guard against a frame from something that is not the page.
/// </remarks>
[Fact]
public async Task AnEmptyCopy_IsDropped()
{
Start();
using var socket = await ConnectAsync();
var copies = 0;
plane.ClipboardCopyRequested += (_, _) => Interlocked.Increment(ref copies);
await SendAsync(socket, (byte)TerminalClientOpcode.ClipboardCopy, []);
// Followed by one that must be heard, so the assertion below is about ordering rather than about
// having waited long enough: the receive loop is sequential, so a copy arriving after this proves
// the empty one was seen and passed over.
var copied = new List<string>();
plane.ClipboardCopyRequested += (_, e) => copied.Add(e.Text);
await SendAsync(socket, (byte)TerminalClientOpcode.ClipboardCopy, "uptime"u8.ToArray());
await WaitUntilAsync(() => copied.Count > 0);
copies.ShouldBe(1, "only the second frame carried anything");
}
/// <remarks>
/// The session id is the whole payload of a paste request — it says which pane the pointer was over, and
/// therefore where the answer has to land. Heard with nothing registered for the reason a copy is.
/// </remarks>
[Fact]
public async Task APasteRequest_CarriesTheSessionItWasMadeIn()
{
Start();
using var socket = await ConnectAsync();
var asked = new List<uint>();
plane.ClipboardPasteRequested += (_, e) => asked.Add(e.SessionId);
await SendAsync(socket, (byte)TerminalClientOpcode.ClipboardPaste, [], sessionId: 4242);
await WaitUntilAsync(() => asked.Count > 0);
asked[0].ShouldBe(4242u);
}
[Fact]
public async Task AFrameForAnUnregisteredSession_IsIgnored()
{
@@ -268,6 +268,69 @@ public sealed class TerminalWorkspaceTests
.ShouldBeFalse("the tab it names is gone");
}
/// <remarks>
/// <para>
/// The right-click paste's answer, and it is a separate frame from the snippet one above rather than the
/// same frame with a flag: the page brackets this unconditionally and types a single-line snippet, which
/// is a difference of behaviour and not of degree. See <see cref="TerminalServerOpcode.Clipboard"/>.
/// </para>
/// <para>
/// The payload is the clipboard's text and nothing else — no leading flag byte, because there is no
/// Enter to ask for. A frame that grew one would put a stray character at the front of every paste, and
/// this is what would catch it.
/// </para>
/// </remarks>
[Fact]
public async Task TheClipboard_ReachesThePaneThatAskedForIt()
{
var connections = new FakeConnectionFactory(blockShellReads: true);
await using var workspace = CreateWorkspace(connections);
workspace.Start();
using var renderer = await ConnectRendererAsync(workspace);
var sessionId = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
// The session's opening frame, read and discarded so the clipboard frame below is the next one.
await ReceiveFrameAsync(renderer);
(await workspace.SendClipboardAsync(
sessionId, "sudo systemctl restart sshd", TestContext.Current.CancellationToken))
.ShouldBeTrue("the session is live");
var frame = await ReceiveFrameAsync(renderer);
frame.Opcode.ShouldBe((byte)TerminalServerOpcode.Clipboard);
frame.SessionId.ShouldBe(sessionId);
Encoding.UTF8.GetString(frame.Payload).ShouldBe("sudo systemctl restart sshd");
}
/// <remarks>
/// Refused rather than sent and dropped, for the reason <c>PasteAsync</c> is — with the difference that
/// nothing on screen would have shown it either way, so this answer exists to be honest rather than to
/// be reported. See <see cref="TerminalWorkspace.SendClipboardAsync"/>.
/// </remarks>
[Fact]
public async Task SendingTheClipboardToADeadSession_SaysSoRatherThanDroppingIt()
{
var connections = new FakeConnectionFactory();
await using var workspace = CreateWorkspace(connections);
var sessionId = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
(await workspace.SendClipboardAsync(9999, "uptime", TestContext.Current.CancellationToken))
.ShouldBeFalse("this workspace never issued that id");
await workspace.CloseSessionAsync(sessionId);
(await workspace.SendClipboardAsync(sessionId, "uptime", TestContext.Current.CancellationToken))
.ShouldBeFalse("the tab it names is gone");
}
/// <remarks>
/// The event the tab strip and the phone's keep-alive listen to, so a dot can go out — and a foreground
/// notification can come down — the moment a shell exits rather than at the next thing that happens to