返回 DeepSeek-Reasonix
transcript-selection-menu.test.tsx
根目录 / desktop / frontend / src / __tests__ / transcript-selection-menu.test.tsx
1 // Run: tsx src/__tests__/transcript-selection-menu.test.tsx
2 //
3 // Regression coverage for transcript selection actions. Selected message text
4 // exposes Add to Chat after pointer/keyboard selection, while the Wails shell
5 // also keeps its app-drawn right-click Copy menu:
6 // - a non-collapsed selection inside .msg__body opens the menu and Copy
7 // writes the selection through the runtime clipboard bridge
8 // - collapsed selections, non-message selections, editable targets, and
9 // plain-browser sessions (no window.runtime) never open the menu
10 // - a surviving message selection does not hijack right-clicks landing
11 // outside message bodies (project tree, tab bar, ... own those menus)
12 // - the target message must itself touch the selection: selecting message A
13 // and right-clicking message B offers nothing (Copy would copy A), while a
14 // selection spanning both accepts a right-click on either
15 // - Escape dismisses the floating action without clearing the selection, the
16 // trailing keyup does not re-open it, and a fresh pointer gesture does
17 // - the add-to-chat shortcut lives in the shared registry: rebinding it in
18 // settings remaps both the handler and the visible hint
19
20 import { JSDOM } from "jsdom";
21 import React from "react";
22 import { act } from "react";
23 import { createRoot } from "react-dom/client";
24 import { TranscriptSelectionMenu } from "../components/TranscriptSelectionMenu";
25 import { LocaleProvider } from "../lib/i18n";
26 import { resetCustomShortcuts, saveCustomShortcut } from "../lib/keyboardShortcuts";
27
28 let passed = 0;
29 let failed = 0;
30
31 function ok(value: boolean, label: string) {
32 if (value) {
33 process.stdout.write(` PASS ${label}\n`);
34 passed += 1;
35 } else {
36 process.stdout.write(` FAIL ${label}\n`);
37 failed += 1;
38 }
39 }
40
41 function eq(actual: unknown, expected: unknown, label: string) {
42 if (actual === expected) ok(true, label);
43 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
44 }
45
46 function flushTimers(): Promise<void> {
47 return new Promise((resolve) => setTimeout(resolve, 0));
48 }
49
50 async function drainFrame(): Promise<void> {
51 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
52 await flushTimers();
53 }
54
55 function installDom() {
56 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
57 pretendToBeVisual: true,
58 url: "http://localhost/",
59 });
60 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
61 globalThis.window = dom.window as unknown as Window & typeof globalThis;
62 globalThis.document = dom.window.document;
63 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
64 globalThis.Node = dom.window.Node;
65 globalThis.Element = dom.window.Element;
66 globalThis.HTMLElement = dom.window.HTMLElement;
67 globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
68 globalThis.Event = dom.window.Event;
69 globalThis.CustomEvent = dom.window.CustomEvent;
70 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
71 globalThis.MouseEvent = dom.window.MouseEvent;
72 globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
73 globalThis.MutationObserver = dom.window.MutationObserver;
74 globalThis.localStorage = dom.window.localStorage;
75 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
76 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
77 return dom;
78 }
79
80 function selectNodeText(node: Node) {
81 const range = document.createRange();
82 range.selectNodeContents(node);
83 const selection = document.getSelection();
84 selection?.removeAllRanges();
85 selection?.addRange(range);
86 }
87
88 async function dispatchContextMenu(target: Element, clientX = 120, clientY = 80): Promise<MouseEvent> {
89 const event = new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX, clientY });
90 await act(async () => {
91 target.dispatchEvent(event);
92 await flushTimers();
93 });
94 return event;
95 }
96
97 console.log("\ntranscript selection menu");
98
99 {
100 const dom = installDom();
101 const clipboard: string[] = [];
102 const additions: string[] = [];
103 (window as unknown as { runtime: { ClipboardSetText: (text: string) => Promise<boolean> } }).runtime = {
104 ClipboardSetText: async (text: string) => {
105 clipboard.push(text);
106 return true;
107 },
108 };
109
110 document.body.insertAdjacentHTML(
111 "beforeend",
112 "<div class=\"msg__body\">assistant reply text</div>" +
113 "<div class=\"msg__body\" id=\"second-message\">second reply text</div>" +
114 "<p id=\"plain\">plain page text</p>" +
115 "<div id=\"sidebar\">project tree area</div>" +
116 "<textarea id=\"editor\"></textarea>",
117 );
118 const msgBody = document.querySelector(".msg__body") as HTMLElement;
119 const secondMsg = document.querySelector("#second-message") as HTMLElement;
120 const plain = document.querySelector("#plain") as HTMLElement;
121 const sidebar = document.querySelector("#sidebar") as HTMLElement;
122 const editor = document.querySelector("#editor") as HTMLTextAreaElement;
123
124 const root = createRoot(document.getElementById("root") as HTMLElement);
125 await act(async () => {
126 root.render(
127 <LocaleProvider>
128 <TranscriptSelectionMenu onAddToChat={(text) => additions.push(text)} />
129 </LocaleProvider>,
130 );
131 await flushTimers();
132 });
133
134 // Message selection opens the menu and suppresses the (already dead) default.
135 selectNodeText(msgBody.firstChild as Node);
136 const openEvent = await dispatchContextMenu(msgBody);
137 eq(openEvent.defaultPrevented, true, "message selection right-click is claimed by the app menu");
138 const menu = document.querySelector(".context-menu");
139 ok(menu != null, "message selection right-click opens the transcript menu");
140 const copyItem = menu?.querySelector("[role=\"menuitem\"]") as HTMLButtonElement | null;
141 eq(copyItem?.textContent?.includes("Copy"), true, "transcript menu offers Copy");
142
143 await act(async () => {
144 copyItem?.click();
145 await flushTimers();
146 });
147 eq(clipboard[0], "assistant reply text", "Copy writes the selection through the clipboard bridge");
148 eq(document.querySelector(".context-menu"), null, "transcript menu closes after Copy");
149
150 // Releasing a pointer after selecting message text exposes the compact Add
151 // to Chat action. It adds the exact selection, clears the browser highlight,
152 // and closes without sending anything itself.
153 selectNodeText(msgBody.firstChild as Node);
154 await act(async () => {
155 msgBody.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, button: 0 }));
156 await drainFrame();
157 });
158 const addButton = document.querySelector(".transcript-selection-action button") as HTMLButtonElement | null;
159 eq(addButton?.textContent?.includes("Add to Chat"), true, "pointer selection exposes Add to Chat");
160 await act(async () => {
161 addButton?.click();
162 await flushTimers();
163 });
164 eq(additions[0], "assistant reply text", "Add to Chat forwards the exact selected text");
165 eq(document.getSelection()?.isCollapsed, true, "Add to Chat clears the browser selection");
166 eq(document.querySelector(".transcript-selection-action"), null, "Add to Chat closes the floating action");
167
168 // The shortcut is scoped to a live transcript selection, so it cannot steal
169 // Cmd/Ctrl+L during normal app navigation.
170 selectNodeText(msgBody.firstChild as Node);
171 await act(async () => {
172 msgBody.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, button: 0 }));
173 await drainFrame();
174 });
175 await act(async () => {
176 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "l", ctrlKey: true, bubbles: true, cancelable: true }));
177 await flushTimers();
178 });
179 eq(additions[1], "assistant reply text", "Cmd/Ctrl+L adds the active transcript selection");
180
181 // Escape dismisses the floating action while the browser selection survives;
182 // the trailing keyup must not re-open it, but a fresh pointer gesture does.
183 selectNodeText(msgBody.firstChild as Node);
184 await act(async () => {
185 msgBody.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, button: 0 }));
186 await drainFrame();
187 });
188 ok(document.querySelector(".transcript-selection-action") != null, "pointer selection re-exposes the floating action");
189 await act(async () => {
190 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
191 await flushTimers();
192 });
193 eq(document.querySelector(".transcript-selection-action"), null, "Escape dismisses the floating action");
194 eq(document.getSelection()?.isCollapsed, false, "Escape keeps the browser selection");
195 await act(async () => {
196 document.dispatchEvent(new window.KeyboardEvent("keyup", { key: "Escape", bubbles: true }));
197 await drainFrame();
198 });
199 eq(document.querySelector(".transcript-selection-action"), null, "the Escape keyup does not re-open the dismissed action");
200 await act(async () => {
201 msgBody.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, button: 0 }));
202 await drainFrame();
203 });
204 ok(document.querySelector(".transcript-selection-action") != null, "a fresh pointer gesture re-opens the dismissed action");
205
206 // Rebinding selection.addToChat through the shared shortcut registry remaps
207 // both the handler and the visible hint; the old combo stops firing.
208 await act(async () => {
209 saveCustomShortcut("selection.addToChat", { key: "m", ctrl: true });
210 await flushTimers();
211 });
212 eq(
213 document.querySelector(".transcript-selection-action kbd")?.textContent,
214 "Ctrl+M",
215 "the floating action hint tracks the rebound shortcut",
216 );
217 await act(async () => {
218 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "l", ctrlKey: true, bubbles: true, cancelable: true }));
219 await flushTimers();
220 });
221 eq(additions.length, 2, "the old combo no longer fires after a rebind");
222 await act(async () => {
223 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "m", ctrlKey: true, bubbles: true, cancelable: true }));
224 await flushTimers();
225 });
226 eq(additions[2], "assistant reply text", "the rebound combo adds the selection");
227 await act(async () => {
228 resetCustomShortcuts();
229 await flushTimers();
230 });
231
232 // A session/tab switch must discard the captured selection: the overlay only
233 // stores text while onAddToChat routes to the tab active at click time, so a
234 // surviving overlay could add session A's selection to session B. During
235 // placeholder hydration the previous transcript stays on screen, so the
236 // disabled window must also keep the shortcut keyup from re-summoning it.
237 selectNodeText(msgBody.firstChild as Node);
238 await act(async () => {
239 msgBody.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, button: 0 }));
240 await drainFrame();
241 });
242 ok(document.querySelector(".transcript-selection-action") != null, "selection opens the floating action before a tab switch");
243 await act(async () => {
244 root.render(
245 <LocaleProvider>
246 <TranscriptSelectionMenu onAddToChat={(text) => additions.push(text)} resetKey="tab-b" />
247 </LocaleProvider>,
248 );
249 await flushTimers();
250 });
251 eq(document.querySelector(".transcript-selection-action"), null, "a tab switch discards the captured selection action");
252 await act(async () => {
253 root.render(
254 <LocaleProvider>
255 <TranscriptSelectionMenu onAddToChat={(text) => additions.push(text)} resetKey="tab-b" enabled={false} />
256 </LocaleProvider>,
257 );
258 await flushTimers();
259 });
260 await act(async () => {
261 document.dispatchEvent(new window.KeyboardEvent("keyup", { key: "Meta", bubbles: true }));
262 await drainFrame();
263 });
264 eq(document.querySelector(".transcript-selection-action"), null, "keyup over a hydration placeholder cannot re-summon the action");
265
266 selectNodeText(msgBody.firstChild as Node);
267 await act(async () => {
268 root.render(
269 <LocaleProvider>
270 <TranscriptSelectionMenu onAddToChat={(text) => additions.push(text)} resetKey="tab-b" />
271 </LocaleProvider>,
272 );
273 await flushTimers();
274 });
275 await dispatchContextMenu(msgBody);
276 ok(document.querySelector(".context-menu") != null, "the copy menu opens before a tab switch");
277 await act(async () => {
278 root.render(
279 <LocaleProvider>
280 <TranscriptSelectionMenu onAddToChat={(text) => additions.push(text)} resetKey="tab-c" />
281 </LocaleProvider>,
282 );
283 await flushTimers();
284 });
285 eq(document.querySelector(".context-menu"), null, "a tab switch also discards the copy menu");
286
287 // Collapsed selection: no menu, default untouched.
288 document.getSelection()?.removeAllRanges();
289 const collapsedEvent = await dispatchContextMenu(msgBody);
290 eq(collapsedEvent.defaultPrevented, false, "collapsed selection leaves the event alone");
291 eq(document.querySelector(".context-menu"), null, "collapsed selection does not open the menu");
292
293 // Selection outside any message body: no menu.
294 selectNodeText(plain.firstChild as Node);
295 await dispatchContextMenu(plain);
296 eq(document.querySelector(".context-menu"), null, "non-message selection does not open the menu");
297
298 // Selecting message A and right-clicking message B must offer nothing:
299 // Copy would copy A's text, not what sits under the click.
300 selectNodeText(msgBody.firstChild as Node);
301 const otherMessageEvent = await dispatchContextMenu(secondMsg);
302 eq(otherMessageEvent.defaultPrevented, false, "right-click on a message outside the selection leaves the event alone");
303 eq(document.querySelector(".context-menu"), null, "selection in message A does not open the menu on message B");
304
305 // A selection spanning both messages accepts a right-click on either.
306 {
307 const range = document.createRange();
308 range.setStartBefore(msgBody.firstChild as Node);
309 range.setEndAfter(secondMsg.firstChild as Node);
310 const selection = document.getSelection();
311 selection?.removeAllRanges();
312 selection?.addRange(range);
313 }
314 await dispatchContextMenu(secondMsg);
315 ok(document.querySelector(".context-menu") != null, "cross-message selection opens the menu on either message");
316 await act(async () => {
317 window.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape" }));
318 await flushTimers();
319 });
320
321 // A surviving message selection must not hijack right-clicks landing outside
322 // message bodies — the project tree, tab bar, etc. own those context menus.
323 selectNodeText(msgBody.firstChild as Node);
324 const sidebarEvent = await dispatchContextMenu(sidebar);
325 eq(sidebarEvent.defaultPrevented, false, "right-click outside message bodies leaves the event alone");
326 eq(document.querySelector(".context-menu"), null, "message selection does not open the menu over other surfaces");
327
328 // Editable target keeps its native menu even while a message selection exists.
329 selectNodeText(msgBody.firstChild as Node);
330 const editableEvent = await dispatchContextMenu(editor);
331 eq(editableEvent.defaultPrevented, false, "editable targets keep the native menu");
332 eq(document.querySelector(".context-menu"), null, "editable targets do not open the transcript menu");
333
334 // Keyboard menu key fires at (0,0); the menu still opens, anchored to the selection.
335 selectNodeText(msgBody.firstChild as Node);
336 await dispatchContextMenu(msgBody, 0, 0);
337 ok(document.querySelector(".context-menu") != null, "keyboard-invoked menu opens without pointer coordinates");
338 await act(async () => {
339 window.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape" }));
340 await flushTimers();
341 });
342 eq(document.querySelector(".context-menu"), null, "Escape closes the transcript menu");
343
344 await act(async () => {
345 root.unmount();
346 });
347 dom.window.close();
348 }
349
350 {
351 // Plain browser (no window.runtime): the native menu owns right-click.
352 const dom = installDom();
353 document.body.insertAdjacentHTML("beforeend", "<div class=\"msg__body\">browser text</div>");
354 const msgBody = document.querySelector(".msg__body") as HTMLElement;
355
356 const root = createRoot(document.getElementById("root") as HTMLElement);
357 await act(async () => {
358 root.render(
359 <LocaleProvider>
360 <TranscriptSelectionMenu />
361 </LocaleProvider>,
362 );
363 await flushTimers();
364 });
365
366 selectNodeText(msgBody.firstChild as Node);
367 const browserEvent = await dispatchContextMenu(msgBody);
368 eq(browserEvent.defaultPrevented, false, "plain browser keeps the native selection menu");
369 eq(document.querySelector(".context-menu"), null, "plain browser never sees the app menu");
370
371 await act(async () => {
372 root.unmount();
373 });
374 dom.window.close();
375 }
376
377 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
378 if (failed > 0) process.exit(1);
379
379 lines Plain Text