namespace DodoSSH.Client.Terminal.Tests;
///
/// The renderer page's right button: a copy, a paste, and never a menu.
///
///
///
/// 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.
///
///
/// 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 not asserted is anything about
/// xterm itself — whether paste() 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 docs/manual-checks.md 1.11 picks up.
///
///
public sealed class RendererClipboardTests
{
/// The session every single-pane test here opens.
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");
}
///
/// 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.
///
[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/");
}
///
/// 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".
///
[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/");
}
///
///
/// preventDefault is the WebView's own context menu, which is what this whole change is about.
///
///
/// stopPropagation is xterm's, and it is the half that is easy to leave out. xterm registers its
/// own contextmenu 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 addEventListener would still
/// suppress the menu and would fail here.
///
///
[Fact]
public void ARightClick_TakesTheEventFromTheBrowserAndFromXterm()
{
var page = Attached();
page.Do("rightClick(7)");
page.Prevented.ShouldBe(1);
page.Stopped.ShouldBe(1);
}
///
/// 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.
///
[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);
}
///
/// 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.
///
[Fact]
public void ARightClickOverASelection_DoesNotAlsoAskForTheClipboard()
{
var page = Attached();
page.Do("select(7, 'uname -a')");
page.Do("rightClick(7)");
page.Sent.ShouldNotContain("6/7/");
}
///
/// 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.
///
[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");
}
///
/// The other half of the round trip. paste() rather than input(), 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".
///
[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();
}
///
/// 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.
///
[Fact]
public void TheClipboardComingBack_DoesNotPressEnter()
{
var page = Attached();
page.Do("deliverClipboard(7, 'rm -rf /var/tmp/build')");
page.Typed(SessionId).ShouldBeEmpty();
}
/// The caret follows the paste, so the next keystroke lands where the text did.
[Fact]
public void TheClipboardComingBack_LeavesTheCaretInThatPane()
{
var page = Attached();
var before = page.Focuses(SessionId);
page.Do("deliverClipboard(7, 'whoami')");
page.Focuses(SessionId).ShouldBe(before + 1);
}
///
/// Multi-byte, because both directions run through the page's own TextEncoder and
/// TextDecoder 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.
///
[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° · λ");
}
///
/// The distinction the new opcode exists to keep, asserted from the other side: a snippet off the sidebar
/// is still typed 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.
///
[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();
}
///
[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();
}
///
/// And the Enter goes through the typed route even for a multi-line snippet, deliberately outside the
/// bracketing: a \r inside the wrapper arrives at the shell as a literal carriage return and
/// nothing runs.
///
[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");
}
/// A page with an open socket and one pane, which is where every test here starts.
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;
}
}