返回 DeepSeek-Reasonix
rich-link-context-menu.test.tsx
根目录 / desktop / frontend / src / __tests__ / rich-link-context-menu.test.tsx
1 // Run: tsx src/__tests__/rich-link-context-menu.test.tsx
2 //
3 // Headless interaction tests for the context menu of web/mail rich links:
4 // right click (and the keyboard ContextMenu / Shift+F10 gesture) opens a menu
5 // whose actions transform information the link already carries — open in the
6 // browser, copy the original URL, copy a GitHub owner/repo reference, copy a
7 // mailto address without the scheme. Local file links keep their own menu and
8 // are covered by local-path-click-e2e.test.tsx.
9
10 import { JSDOM } from "jsdom";
11 import { installDesktopHostStub } from "./desktopHostStub";
12
13 const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: "https://reasonix.local/" });
14 const { window } = dom;
15
16 // Set globals BEFORE dynamically importing React DOM so the renderer sees a
17 // real document (ESM imports are hoisted, hence dynamic import below).
18 (globalThis as Record<string, unknown>).window = window;
19 (globalThis as Record<string, unknown>).document = window.document;
20 (globalThis as Record<string, unknown>).HTMLElement = window.HTMLElement;
21 (globalThis as Record<string, unknown>).MouseEvent = window.MouseEvent;
22 (globalThis as Record<string, unknown>).KeyboardEvent = window.KeyboardEvent;
23 // React 19 requires this flag for act() to flush renders/pass-through events.
24 (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
25
26 // Bridge spies: BrowserOpenURL is what "open"/"compose" must call;
27 // ClipboardSetText is what copy actions must reach through the clipboard lib.
28 const browsed: string[] = [];
29 const clipboardWrites: string[] = [];
30 let clipboardSucceeds = true;
31 installDesktopHostStub(({ main: { App: {} } }).main.App, {
32 externalOpens: browsed,
33 clipboardWrites,
34 clipboardWriteResult: () => clipboardSucceeds,
35 });
36
37 let passed = 0;
38 let failed = 0;
39 function ok(value: unknown, label: string) {
40 if (value) {
41 process.stdout.write(` PASS ${label}\n`);
42 passed += 1;
43 } else {
44 process.stdout.write(` FAIL ${label}\n`);
45 failed += 1;
46 }
47 }
48
49 const { createElement } = await import("react");
50 const { act } = await import("react");
51 const { createRoot } = await import("react-dom/client");
52 const { ToastProvider } = await import("../lib/toast");
53 const { LocaleProvider } = await import("../lib/i18n");
54 const { RichMarkdownLink } = await import("../components/githubLink");
55
56 // Every rendered root is unmounted before the summary so the toast
57 // auto-dismiss timers fire against dead trees instead of raising act warnings.
58 const roots: Array<{ unmount: () => void }> = [];
59
60 async function renderLink(href: string, label?: string): Promise<{ anchor: HTMLAnchorElement; container: HTMLElement }> {
61 const container = window.document.createElement("div");
62 window.document.body.appendChild(container);
63 const root = createRoot(container);
64 roots.push(root);
65 const link = createElement(RichMarkdownLink, { href, children: label ?? href });
66 await act(async () => {
67 root.render(createElement(LocaleProvider, null, createElement(ToastProvider, null, link)));
68 });
69 return { anchor: container.querySelector("a") as HTMLAnchorElement, container };
70 }
71
72 function menuItemLabels(): string[] {
73 return Array.from(window.document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'))
74 .map((item) => item.textContent ?? "");
75 }
76
77 async function openContextMenu(anchor: HTMLAnchorElement) {
78 await act(async () => {
79 anchor.dispatchEvent(new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 40, clientY: 40 }));
80 });
81 }
82
83 async function clickMenuItem(label: string) {
84 const item = Array.from(window.document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'))
85 .find((candidate) => candidate.textContent?.includes(label));
86 ok(item !== undefined, `menu contains a "${label}" item`);
87 await act(async () => {
88 item?.dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true }));
89 // Flush past the async clipboard write so the toast state update lands
90 // inside act() instead of leaking a warning after the assertion.
91 await new Promise<void>((resolve) => setTimeout(resolve, 0));
92 });
93 }
94
95 async function closeWithEscape() {
96 await act(async () => {
97 window.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape" }));
98 });
99 }
100
101 console.log("\nrich link context menu");
102
103 // 1. Bare GitHub pull request URL: open, copy link, copy owner/repo reference.
104 {
105 const href = "https://github.com/esengine/DeepSeek-Reasonix/pull/6976";
106 const { anchor } = await renderLink(href);
107 await openContextMenu(anchor);
108 const menu = window.document.querySelector('[role="menu"]');
109 ok(menu !== null, "web link context menu opens on right click");
110 const labels = menuItemLabels();
111 ok(labels.length === 3, `pull request menu has three items (${labels.length})`);
112 ok(labels.some((text) => text.includes("Open in browser")), "pull request menu can open in the browser");
113 ok(labels.some((text) => text.includes("Copy link")), "pull request menu can copy the link");
114 ok(labels.some((text) => text.includes("Copy reference")), "pull request menu can copy a reference");
115 await clickMenuItem("Copy reference");
116 ok(clipboardWrites[0] === "esengine/DeepSeek-Reasonix#6976",
117 `copy reference writes the owner/repo#number form (${clipboardWrites[0]})`);
118 ok(window.document.querySelector(".toast--info")?.textContent?.includes("Copied") === true,
119 "a success toast confirms the copy");
120 await closeWithEscape();
121 }
122
123 // 2. GitHub commit URL: the reference uses the short SHA form.
124 {
125 const href = "https://github.com/esengine/DeepSeek-Reasonix/commit/ca03d4c2ec47d7fc9726affbbda04d8616f6351b";
126 const { anchor } = await renderLink(href);
127 await openContextMenu(anchor);
128 await clickMenuItem("Copy reference");
129 ok(clipboardWrites[1] === "esengine/DeepSeek-Reasonix@ca03d4c",
130 `commit reference writes owner/repo@shortsha (${clipboardWrites[1]})`);
131 await closeWithEscape();
132 }
133
134 // 3. External web URL: no reference action; copy and open go to the right places.
135 {
136 const href = "https://example.com/a/very/long/documentation/path";
137 const { anchor } = await renderLink(href);
138 await openContextMenu(anchor);
139 const labels = menuItemLabels();
140 ok(labels.length === 2, `external menu has two items (${labels.length})`);
141 ok(labels.every((text) => !text.includes("Copy reference")), "external menu has no reference action");
142 await clickMenuItem("Copy link");
143 ok(clipboardWrites[2] === href, `copy link writes the original URL (${clipboardWrites[2]})`);
144 await openContextMenu(anchor);
145 await clickMenuItem("Open in browser");
146 ok(browsed[0] === href, "open in browser sends the URL to the system browser");
147 await closeWithEscape();
148 }
149
150 // 4. mailto link: compose opens the mail handler; copy strips the scheme.
151 {
152 const href = "mailto:support@example.com";
153 const { anchor } = await renderLink(href);
154 await openContextMenu(anchor);
155 const labels = menuItemLabels();
156 ok(labels.some((text) => text.includes("Compose email")), "mailto menu offers compose email");
157 ok(labels.some((text) => text.includes("Copy email address")), "mailto menu offers copy address");
158 await clickMenuItem("Copy email address");
159 ok(clipboardWrites[3] === "support@example.com",
160 `copy email address strips the mailto: scheme (${clipboardWrites[3]})`);
161 await openContextMenu(anchor);
162 await clickMenuItem("Compose email");
163 ok(browsed[1] === href, "compose email hands the mailto URL to the system handler");
164 await closeWithEscape();
165 }
166
167 // Copy only the decoded recipient address, not compose parameters.
168 for (const [href, expected] of [
169 ["mailto:support@example.com?subject=Help&body=Hello", "support@example.com"],
170 ["MAILTO:support@example.com", "support@example.com"],
171 ["mailto:hello%2Btag@example.com", "hello+tag@example.com"],
172 ["mailto:one@example.com,two@example.com?cc=other@example.com", "one@example.com,two@example.com"],
173 ["mailto:bad%ZZ@example.com?subject=Help", "bad%ZZ@example.com"],
174 ["mailto:?subject=Help", ""],
175 ]) {
176 const { anchor } = await renderLink(href);
177 await openContextMenu(anchor);
178 ok(menuItemLabels().some((text) => text.includes("Compose email")), "mail protocol classification is consistent");
179 await clickMenuItem("Copy email address");
180 ok(clipboardWrites.at(-1) === expected, `address copy for ${href}`);
181 await closeWithEscape();
182 }
183 clipboardWrites.splice(4);
184
185 // 5. Keyboard gesture: ContextMenu key and Shift+F10 open the menu, Escape closes.
186 {
187 const href = "https://github.com/esengine/DeepSeek-Reasonix/issues/6856";
188 const { anchor } = await renderLink(href);
189 anchor.focus();
190 await act(async () => {
191 anchor.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ContextMenu", bubbles: true, cancelable: true }));
192 });
193 ok(window.document.querySelector('[role="menu"]') !== null, "ContextMenu key opens the menu");
194 ok(window.document.activeElement?.textContent?.includes("Open in browser"), "keyboard menu focuses first action");
195 await closeWithEscape();
196 ok(window.document.querySelector('[role="menu"]') === null, "Escape closes the menu");
197 ok(window.document.activeElement === anchor, "Escape returns focus to link");
198 await act(async () => {
199 anchor.dispatchEvent(new window.KeyboardEvent("keydown", { key: "F10", shiftKey: true, bubbles: true, cancelable: true }));
200 });
201 ok(window.document.querySelector('[role="menu"]') !== null, "Shift+F10 opens the menu");
202 await closeWithEscape();
203 }
204
205 // 6. Explicit Markdown labels are irrelevant to the menu: the copy actions
206 // still write the original URL and the owner/repo reference.
207 {
208 const href = "https://github.com/esengine/DeepSeek-Reasonix/issues/6856";
209 const { anchor } = await renderLink(href, "Readable issue title");
210 await openContextMenu(anchor);
211 await clickMenuItem("Copy link");
212 ok(clipboardWrites[4] === href, "explicit label does not change what copy link writes");
213 await closeWithEscape();
214 }
215
216 // 7. Relative links (no icon kind) keep the plain anchor: no menu, no
217 // preventDefault, so the webview default behavior is untouched.
218 {
219 const { anchor } = await renderLink("./docs/GUIDE.md", "Guide");
220 let defaultPrevented = false;
221 await act(async () => {
222 const event = new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 10, clientY: 10 });
223 anchor.dispatchEvent(event);
224 defaultPrevented = event.defaultPrevented;
225 });
226 ok(defaultPrevented === false, "relative link right click is not intercepted");
227 ok(window.document.querySelector('[role="menu"]') === null, "relative link opens no context menu");
228 }
229
230 // 8. Clipboard failure surfaces an error toast instead of a success toast.
231 {
232 clipboardSucceeds = false;
233 const { anchor } = await renderLink("https://example.com/copy-failure");
234 await openContextMenu(anchor);
235 await clickMenuItem("Copy link");
236 ok(window.document.querySelector(".toast--error")?.textContent?.includes("Could not copy") === true,
237 "a failed copy shows an error toast");
238 await closeWithEscape();
239 clipboardSucceeds = true;
240 }
241
242 await act(async () => {
243 for (const root of roots) root.unmount();
244 });
245
246 process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
247 if (failed > 0) process.exit(1);
248
248 lines Plain Text