返回 DeepSeek-Reasonix
terminal-selection.test.ts
根目录 / desktop / frontend / src / __tests__ / terminal-selection.test.ts
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
187 lines TYPESCRIPT