返回 DeepSeek-Reasonix
composer-context-menu-clipboard.test.tsx
根目录 / desktop / frontend / src / __tests__ / composer-context-menu-clipboard.test.tsx
1 // Run: tsx src/__tests__/composer-context-menu-clipboard.test.tsx
2 //
3 // Regression coverage for the composer edit context menu's clipboard guards:
4 // - Undo/redo must expose the same fixed shortcuts as the composer keyboard path.
5 // - Cut must not delete the selection when every clipboard path failed.
6 // - Paste must not replace the selection when the clipboard has no text.
7 // - Shortcut hints must use the platform modifier (Ctrl outside macOS).
8
9 import { JSDOM } from "jsdom";
10 import React from "react";
11 import { act } from "react";
12 import { createRoot } from "react-dom/client";
13 import { Composer } from "../components/Composer";
14 import { LocaleProvider } from "../lib/i18n";
15 import { ToastProvider } from "../lib/toast";
16 import type { CollaborationMode, TokenMode, ToolApprovalMode } from "../lib/types";
17
18 let passed = 0;
19 let failed = 0;
20
21 function ok(value: boolean, label: string) {
22 if (value) {
23 process.stdout.write(` PASS ${label}\n`);
24 passed += 1;
25 } else {
26 process.stdout.write(` FAIL ${label}\n`);
27 failed += 1;
28 }
29 }
30
31 function eq(actual: unknown, expected: unknown, label: string) {
32 if (actual === expected) ok(true, label);
33 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
34 }
35
36 function flushTimers(): Promise<void> {
37 return new Promise((resolve) => setTimeout(resolve, 0));
38 }
39
40 class TestResizeObserver {
41 observe() {}
42 unobserve() {}
43 disconnect() {}
44 }
45
46 function installDom() {
47 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
48 pretendToBeVisual: true,
49 url: "http://localhost/",
50 });
51 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
52 globalThis.window = dom.window as unknown as Window & typeof globalThis;
53 globalThis.document = dom.window.document;
54 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
55 globalThis.Node = dom.window.Node;
56 globalThis.HTMLElement = dom.window.HTMLElement;
57 globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
58 globalThis.Event = dom.window.Event;
59 globalThis.CustomEvent = dom.window.CustomEvent;
60 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
61 globalThis.InputEvent = dom.window.InputEvent;
62 globalThis.MouseEvent = dom.window.MouseEvent;
63 globalThis.File = dom.window.File;
64 globalThis.FileReader = dom.window.FileReader;
65 globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
66 globalThis.MutationObserver = dom.window.MutationObserver;
67 globalThis.localStorage = dom.window.localStorage;
68 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
69 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
70 globalThis.ResizeObserver = TestResizeObserver;
71 Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
72 Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
73 Object.defineProperty(window, "matchMedia", {
74 configurable: true,
75 value: () => ({
76 matches: true,
77 media: "(prefers-reduced-motion: reduce)",
78 onchange: null,
79 addEventListener() {},
80 removeEventListener() {},
81 addListener() {},
82 removeListener() {},
83 dispatchEvent: () => false,
84 }),
85 });
86 return dom;
87 }
88
89 function installBridgeApp(methods: Record<string, unknown>) {
90 (window as unknown as { go: { main: { App: Record<string, unknown> } } }).go = {
91 go: undefined,
92 main: {
93 App: {
94 Commands: async () => [],
95 Models: async () => [],
96 ModelsForTab: async () => [],
97 ...methods,
98 },
99 },
100 } as never;
101 }
102
103 async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}) {
104 const rootEl = document.getElementById("root");
105 if (!rootEl) throw new Error("missing root");
106 const root = createRoot(rootEl);
107 const currentProps: Parameters<typeof Composer>[0] = {
108 running: false,
109 collaborationMode: "normal",
110 toolApprovalMode: "ask" as ToolApprovalMode,
111 tokenMode: "full" as TokenMode,
112 goal: "",
113 cwd: "/repo",
114 modelLabel: "DeepSeek-R1",
115 imageInputEnabled: true,
116 tabId: "context-menu-tab",
117 sessionKey: "session:project:/repo:topic-a:session-a",
118 onSend: () => {},
119 onCancel: () => undefined,
120 onCycleMode: () => {},
121 onSetMode: () => {},
122 onSetCollaborationMode: (_mode: CollaborationMode) => {},
123 onSetToolApprovalMode: () => {},
124 onToggleYoloApprovalMode: () => {},
125 onClearGoal: () => {},
126 onSwitchModel: () => {},
127 onSetEffort: () => {},
128 onSetTokenMode: () => {},
129 ready: true,
130 ...props,
131 };
132 await act(async () => {
133 root.render(
134 <LocaleProvider>
135 <ToastProvider>
136 <div className="chat-pane">
137 <Composer {...currentProps} />
138 </div>
139 </ToastProvider>
140 </LocaleProvider>,
141 );
142 await flushTimers();
143 });
144 return { root };
145 }
146
147 function textarea(): HTMLTextAreaElement {
148 const node = document.querySelector("textarea") as HTMLTextAreaElement | null;
149 if (!node) throw new Error("composer textarea did not render");
150 return node;
151 }
152
153 // textPasteEvent seeds composer text through its own paste handler — the same
154 // pattern composer-session-draft.test.tsx uses — because the controlled
155 // textarea's state is not reachable through synthetic input events here.
156 function textPasteEvent(text: string): Event {
157 const event = new window.Event("paste", { bubbles: true, cancelable: true });
158 Object.defineProperty(event, "clipboardData", {
159 configurable: true,
160 value: {
161 files: [],
162 items: [],
163 types: ["text/plain"],
164 getData: (kind: string) => (kind === "text" || kind === "text/plain" ? text : ""),
165 },
166 });
167 return event;
168 }
169
170 async function typeAndSelect(value: string, from: number, to: number) {
171 const node = textarea();
172 await act(async () => {
173 node.focus();
174 node.setSelectionRange(0, node.value.length);
175 node.dispatchEvent(textPasteEvent(value));
176 await flushTimers();
177 });
178 if (textarea().value !== value) throw new Error(`composer text = ${JSON.stringify(textarea().value)}, want ${JSON.stringify(value)}`);
179 // Drain the paste handler's deferred focusInputRange (rAF + timers) before
180 // pinning the selection under test, or it would reset the caret afterwards.
181 await act(async () => {
182 await new Promise((resolve) => window.requestAnimationFrame(() => resolve(null)));
183 await flushTimers();
184 });
185 textarea().focus();
186 textarea().setSelectionRange(from, to);
187 }
188
189 async function openInputMenu(): Promise<HTMLButtonElement[]> {
190 await act(async () => {
191 textarea().dispatchEvent(
192 new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 20, clientY: 20 }),
193 );
194 await flushTimers();
195 });
196 const items = Array.from(document.querySelectorAll(".context-menu__item")) as HTMLButtonElement[];
197 if (items.length !== 6) throw new Error(`expected 6 edit menu items, got ${items.length}`);
198 return items;
199 }
200
201 async function clickMenuItem(item: HTMLButtonElement) {
202 await act(async () => {
203 item.dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true }));
204 await flushTimers();
205 await flushTimers();
206 });
207 }
208
209 function stubClipboard(overrides: Partial<{ writeText: unknown; readText: unknown; read: unknown }>) {
210 Object.defineProperty(window.navigator, "clipboard", {
211 configurable: true,
212 value: {
213 writeText: () => Promise.reject(new Error("clipboard denied")),
214 readText: () => Promise.resolve(""),
215 read: () => Promise.reject(new Error("clipboard.read unsupported")),
216 ...overrides,
217 },
218 });
219 }
220
221 async function main() {
222 installDom();
223 installBridgeApp({
224 // The empty-paste path probes the native clipboard for an image; a reject
225 // must stay silent (notifyOnError=false) and never touch the draft text.
226 SaveClipboardImage: async () => {
227 throw new Error("no native clipboard image");
228 },
229 });
230
231 // JSDOM reports a non-mac platform, so the menu must advertise Ctrl, not ⌘.
232 await renderComposer();
233
234 // --- shortcut hints use the platform modifier ---
235 {
236 await typeAndSelect("hello world", 0, 5);
237 const items = await openInputMenu();
238 const hints = items.map((item) => item.querySelector(".context-menu__shortcut")?.textContent ?? "");
239 eq(hints[0], "Ctrl+Z", "undo hint uses the platform modifier");
240 eq(hints[1], "Ctrl+Shift+Z", "redo hint uses the platform modifier");
241 eq(hints[2], "Ctrl+X", "cut hint uses the platform modifier");
242 eq(hints[4], "Ctrl+V", "paste hint uses the platform modifier");
243 ok(hints.every((hint) => !hint.includes("⌘")), "no hardcoded mac glyph on a non-mac platform");
244 // Close the menu without acting.
245 await act(async () => {
246 document.body.dispatchEvent(new window.MouseEvent("pointerdown", { bubbles: true }));
247 await flushTimers();
248 });
249 }
250
251 // --- context-menu undo and redo share the programmatic edit history ---
252 {
253 await typeAndSelect("menu undo", 9, 9);
254 const undoItems = await openInputMenu();
255 ok(!undoItems[0].disabled, "undo is enabled at a programmatic edit boundary");
256 ok(undoItems[1].disabled, "redo is disabled before an undo");
257 await clickMenuItem(undoItems[0]);
258 eq(textarea().value, "hello world", "context-menu undo restores the previous composer text");
259
260 const redoItems = await openInputMenu();
261 ok(redoItems[0].disabled === false, "older programmatic history remains undoable");
262 ok(!redoItems[1].disabled, "redo is enabled after context-menu undo");
263 await clickMenuItem(redoItems[1]);
264 eq(textarea().value, "menu undo", "context-menu redo restores the undone composer edit");
265 }
266
267 // --- cut keeps the draft when every clipboard path fails ---
268 {
269 stubClipboard({});
270 (document as Document & { execCommand?: () => boolean }).execCommand = () => false;
271 await typeAndSelect("hello world", 0, 5);
272 const items = await openInputMenu();
273 await clickMenuItem(items[2]);
274 eq(textarea().value, "hello world", "failed cut must not delete the selection");
275 }
276
277 // --- cut removes the selection once a clipboard path succeeds ---
278 {
279 stubClipboard({ writeText: () => Promise.resolve() });
280 await typeAndSelect("hello world", 0, 6);
281 const items = await openInputMenu();
282 await clickMenuItem(items[2]);
283 eq(textarea().value, "world", "successful cut removes the selected text");
284 }
285
286 // --- paste with an empty clipboard keeps the selection ---
287 {
288 stubClipboard({ readText: () => Promise.resolve("") });
289 await typeAndSelect("hello world", 0, 5);
290 const items = await openInputMenu();
291 await clickMenuItem(items[4]);
292 eq(textarea().value, "hello world", "empty-clipboard paste must not erase the selection");
293 }
294
295 // --- paste with text replaces the selection ---
296 {
297 stubClipboard({ readText: () => Promise.resolve("bye") });
298 await typeAndSelect("hello world", 0, 5);
299 const items = await openInputMenu();
300 await clickMenuItem(items[4]);
301 eq(textarea().value, "bye world", "text paste replaces the selection");
302 }
303
304 process.stdout.write(`\n${passed} passed, ${failed} failed, ${passed + failed} total\n`);
305 if (failed > 0) process.exit(1);
306 }
307
308 void main();
309
309 lines Plain Text