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; } }