| 1 | import assert from "node:assert/strict"; |
| 2 | import { JSDOM } from "jsdom"; |
| 3 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 4 | |
| 5 | const dom = new JSDOM("<!doctype html><html><body></body></html>"); |
| 6 | const previousWindow = globalThis.window; |
| 7 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 8 | |
| 9 | const { |
| 10 | clampTerminalSelectionPointToHost, |
| 11 | createTerminalSelectionLifecycle, |
| 12 | handleTerminalCopyKey, |
| 13 | readTerminalClipboardText, |
| 14 | terminalSelectionPointFromHost, |
| 15 | } = await import("../lib/terminalSelection"); |
| 16 | |
| 17 | function mockRect( |
| 18 | target: Element, |
| 19 | rect: { left: number; top: number; right: number; bottom: number; width: number; height: number }, |
| 20 | ): void { |
| 21 | Object.defineProperty(target, "getBoundingClientRect", { configurable: true, value: () => rect }); |
| 22 | } |
| 23 | |
| 24 | function keyEvent(overrides: Partial<ConstructorParameters<typeof KeyboardEvent>[1]> & { key: string }) { |
| 25 | return { ctrlKey: false, metaKey: false, altKey: false, shiftKey: false, ...overrides }; |
| 26 | } |
| 27 | |
| 28 | // handleTerminalCopyKey: Ctrl+C copies a live selection and swallows the chord; |
| 29 | // without a selection it stays SIGINT (the PTY sees the key). |
| 30 | { |
| 31 | assert.deepEqual( |
| 32 | handleTerminalCopyKey({ ...keyEvent({ key: "c", ctrlKey: true }), hasSelection: () => true, getSelection: () => " ls\n" }), |
| 33 | { intercepted: true, text: " ls\n" }, |
| 34 | "Ctrl+C preserves selection boundaries and intercepts", |
| 35 | ); |
| 36 | assert.deepEqual( |
| 37 | handleTerminalCopyKey({ ...keyEvent({ key: "c", ctrlKey: true }), hasSelection: () => false, getSelection: () => "" }), |
| 38 | { intercepted: false, text: "" }, |
| 39 | "Ctrl+C without selection passes through (SIGINT)", |
| 40 | ); |
| 41 | assert.deepEqual( |
| 42 | handleTerminalCopyKey({ ...keyEvent({ key: "c", ctrlKey: true, shiftKey: true }), hasSelection: () => true, getSelection: () => "err" }), |
| 43 | { intercepted: true, text: "err" }, |
| 44 | "Ctrl+Shift+C with selection copies", |
| 45 | ); |
| 46 | assert.deepEqual( |
| 47 | handleTerminalCopyKey({ ...keyEvent({ key: "c", metaKey: true }), hasSelection: () => true, getSelection: () => "x" }), |
| 48 | { intercepted: true, text: "x" }, |
| 49 | "Cmd+C (macOS) with selection copies", |
| 50 | ); |
| 51 | assert.deepEqual( |
| 52 | handleTerminalCopyKey({ ...keyEvent({ key: "c", ctrlKey: true, altKey: true }), hasSelection: () => true, getSelection: () => "x" }), |
| 53 | { intercepted: false, text: "" }, |
| 54 | "Ctrl+Alt+C is not a copy chord", |
| 55 | ); |
| 56 | assert.deepEqual( |
| 57 | handleTerminalCopyKey({ ...keyEvent({ key: "v", ctrlKey: true }), hasSelection: () => true, getSelection: () => "x" }), |
| 58 | { intercepted: false, text: "" }, |
| 59 | "Ctrl+V is not a copy chord", |
| 60 | ); |
| 61 | assert.deepEqual( |
| 62 | handleTerminalCopyKey({ ...keyEvent({ key: "c", ctrlKey: true }), hasSelection: () => true, getSelection: () => " " }), |
| 63 | { intercepted: true, text: " " }, |
| 64 | "whitespace-only selection is copied instead of becoming SIGINT", |
| 65 | ); |
| 66 | assert.deepEqual( |
| 67 | handleTerminalCopyKey({ ...keyEvent({ key: "c", ctrlKey: true }), hasSelection: () => true, getSelection: () => " \u001b[31merror\u001b[0m " }), |
| 68 | { intercepted: true, text: " \u001b[31merror\u001b[0m " }, |
| 69 | "copy preserves escape-looking selection text exactly", |
| 70 | ); |
| 71 | } |
| 72 | |
| 73 | // Async clipboard operations stay bound to the terminal that started them. |
| 74 | { |
| 75 | const lifecycle = createTerminalSelectionLifecycle<object>(); |
| 76 | const firstTerminal = {}; |
| 77 | const secondTerminal = {}; |
| 78 | const firstOperation = lifecycle.activate(firstTerminal); |
| 79 | assert.equal(lifecycle.isCurrent(firstOperation), true, "new operation starts current"); |
| 80 | const secondOperation = lifecycle.activate(secondTerminal); |
| 81 | assert.equal(lifecycle.isCurrent(firstOperation), false, "session switch rejects old clipboard completion"); |
| 82 | assert.equal(lifecycle.isCurrent(secondOperation), true, "replacement terminal owns new operations"); |
| 83 | const firstSelection = lifecycle.captureSelection(); |
| 84 | assert.ok(firstSelection, "active terminal exposes a selection snapshot"); |
| 85 | let settleCopy: (() => void) | undefined; |
| 86 | const delayedCopy = new Promise<void>((resolve) => { settleCopy = resolve; }); |
| 87 | const staleCopyCompletion = delayedCopy.then(() => lifecycle.isCurrentSelection(firstSelection)); |
| 88 | lifecycle.noteSelectionChange(); |
| 89 | const replacementSelection = lifecycle.captureSelection(); |
| 90 | assert.ok(replacementSelection, "changed selection exposes a replacement snapshot"); |
| 91 | settleCopy?.(); |
| 92 | assert.equal(await staleCopyCompletion, false, "same-terminal selection change rejects delayed copy cleanup"); |
| 93 | assert.equal(lifecycle.isCurrentSelection(replacementSelection), true, "replacement selection owns new copy cleanup"); |
| 94 | assert.equal(lifecycle.isCurrent(secondOperation), true, "selection changes do not invalidate terminal-scoped paste"); |
| 95 | lifecycle.deactivate(firstTerminal); |
| 96 | assert.equal(lifecycle.isCurrent(secondOperation), true, "stale cleanup cannot invalidate the replacement terminal"); |
| 97 | lifecycle.deactivate(secondTerminal); |
| 98 | assert.equal(lifecycle.isCurrent(secondOperation), false, "unmount rejects pending clipboard completion"); |
| 99 | } |
| 100 | |
| 101 | // terminalSelectionPointFromHost anchors the toolbar to the painted selection. |
| 102 | { |
| 103 | const host = dom.window.document.createElement("div"); |
| 104 | mockRect(host, { left: 0, top: 0, right: 800, bottom: 300, width: 800, height: 300 }); |
| 105 | assert.equal(terminalSelectionPointFromHost(host), null, "missing selection paint returns null"); |
| 106 | } |
| 107 | { |
| 108 | const host = dom.window.document.createElement("div"); |
| 109 | mockRect(host, { left: 100, top: 200, right: 900, bottom: 500, width: 800, height: 300 }); |
| 110 | const layer = dom.window.document.createElement("div"); |
| 111 | layer.className = "xterm-selection"; |
| 112 | const paint = dom.window.document.createElement("div"); |
| 113 | mockRect(paint, { left: 140, top: 280, right: 220, bottom: 296, width: 80, height: 16 }); |
| 114 | layer.appendChild(paint); |
| 115 | host.appendChild(layer); |
| 116 | assert.deepEqual( |
| 117 | terminalSelectionPointFromHost(host), |
| 118 | { left: 224, top: 302 }, |
| 119 | "anchors just past the painted selection end inside the terminal", |
| 120 | ); |
| 121 | } |
| 122 | { |
| 123 | const host = dom.window.document.createElement("div"); |
| 124 | mockRect(host, { left: 0, top: 0, right: 800, bottom: 300, width: 800, height: 300 }); |
| 125 | const layer = dom.window.document.createElement("div"); |
| 126 | layer.className = "xterm-selection"; |
| 127 | const spacer = dom.window.document.createElement("div"); |
| 128 | const paint = dom.window.document.createElement("div"); |
| 129 | mockRect(spacer, { left: 0, top: 20, right: 800, bottom: 36, width: 800, height: 16 }); |
| 130 | mockRect(paint, { left: 40, top: 36, right: 120, bottom: 52, width: 80, height: 16 }); |
| 131 | layer.append(spacer, paint); |
| 132 | host.appendChild(layer); |
| 133 | assert.deepEqual( |
| 134 | terminalSelectionPointFromHost(host), |
| 135 | { left: 124, top: 58 }, |
| 136 | "near-full-width spacer paints are ignored so the toolbar stays by the real selection", |
| 137 | ); |
| 138 | } |
| 139 | |
| 140 | // clampTerminalSelectionPointToHost keeps the toolbar inside the panel. |
| 141 | { |
| 142 | const host = dom.window.document.createElement("div"); |
| 143 | mockRect(host, { left: 50, top: 80, right: 450, bottom: 280, width: 400, height: 200 }); |
| 144 | assert.deepEqual( |
| 145 | clampTerminalSelectionPointToHost({ left: 10, top: 10 }, host, 160, 40), |
| 146 | { left: 58, top: 88 }, |
| 147 | "points outside the terminal clamp to the panel inset", |
| 148 | ); |
| 149 | assert.deepEqual( |
| 150 | clampTerminalSelectionPointToHost({ left: 900, top: 900 }, host, 160, 40), |
| 151 | { left: 282, top: 232 }, |
| 152 | "points past the terminal bottom-right clamp inside the panel", |
| 153 | ); |
| 154 | } |
| 155 | |
| 156 | // readTerminalClipboardText prefers the async Clipboard API and falls back to |
| 157 | // the desktop bridge when the webview denies permission. |
| 158 | { |
| 159 | const originalClipboard = Object.getOwnPropertyDescriptor(globalThis.navigator, "clipboard"); |
| 160 | const originalRuntime = (globalThis.window as unknown as { runtime?: unknown }).runtime; |
| 161 | try { |
| 162 | Object.defineProperty(globalThis.navigator, "clipboard", { |
| 163 | configurable: true, |
| 164 | value: { readText: async () => "from-navigator" }, |
| 165 | }); |
| 166 | assert.equal(await readTerminalClipboardText(), "from-navigator", "Clipboard API is preferred"); |
| 167 | |
| 168 | Object.defineProperty(globalThis.navigator, "clipboard", { |
| 169 | configurable: true, |
| 170 | value: { readText: async () => { throw new Error("denied"); } }, |
| 171 | }); |
| 172 | const clipboardStub = installDesktopHostStub({}, { clipboardReadText: "from-runtime" }); |
| 173 | assert.equal(await readTerminalClipboardText(), "from-runtime", "bridge is the permission fallback"); |
| 174 | |
| 175 | clipboardStub.uninstall(); |
| 176 | assert.equal(await readTerminalClipboardText(), "", "no clipboard source returns empty"); |
| 177 | } finally { |
| 178 | if (originalClipboard) Object.defineProperty(globalThis.navigator, "clipboard", originalClipboard); |
| 179 | else delete (globalThis.navigator as unknown as Record<string, unknown>).clipboard; |
| 180 | (globalThis.window as unknown as { runtime?: unknown }).runtime = originalRuntime; |
| 181 | if (previousWindow === undefined) delete (globalThis as unknown as Record<string, unknown>).window; |
| 182 | else globalThis.window = previousWindow; |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | console.log("terminal-selection: ok"); |
| 187 |