返回 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 { RichMarkdownLink } = await import("../components/githubLink");
54
55 // Every rendered root is unmounted before the summary so the toast
56 // auto-dismiss timers fire against dead trees instead of raising act warnings.
57 const roots: Array<{ unmount: () => void }> = [];
58
59 async function renderLink(href: string, label?: string): Promise<{ anchor: HTMLAnchorElement; container: HTMLElement }> {
60 const container = window.document.createElement("div");
61 window.document.body.appendChild(container);
62 const root = createRoot(container);
63 roots.push(root);
64 const link = createElement(RichMarkdownLink, { href, children: label ?? href });
65 await act(async () => {
66 root.render(createElement(ToastProvider, null, link));
67 });
68 return { anchor: container.querySelector("a") as HTMLAnchorElement, container };
69 }
70
71 function menuItemLabels(): string[] {
72 return Array.from(window.document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'))
73 .map((item) => item.textContent ?? "");
74 }
75
76 async function openContextMenu(anchor: HTMLAnchorElement) {
77 await act(async () => {
78 anchor.dispatchEvent(new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 40, clientY: 40 }));
79 });
80 }
81
82 async function clickMenuItem(label: string) {
83 const item = Array.from(window.document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'))
84 .find((candidate) => candidate.textContent?.includes(label));
85 ok(item !== undefined, `menu contains a "${label}" item`);
86 await act(async () => {
87 item?.dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true }));
88 // Flush past the async clipboard write so the toast state update lands
89 // inside act() instead of leaking a warning after the assertion.
90 await new Promise<void>((resolve) => setTimeout(resolve, 0));
91 });
92 }
93
94 async function closeWithEscape() {
95 await act(async () => {
96 window.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape" }));
97 });
98 }
99
100 console.log("\nrich link context menu");
101
102 // 1. Bare GitHub pull request URL: open, copy link, copy owner/repo reference.
103 {
104 const href = "https://github.com/esengine/DeepSeek-Reasonix/pull/6976";
105 const { anchor } = await renderLink(href);
106 await openContextMenu(anchor);
107 const menu = window.document.querySelector('[role="menu"]');
108 ok(menu !== null, "web link context menu opens on right click");
109 const labels = menuItemLabels();
110 ok(labels.length === 3, `pull request menu has three items (${labels.length})`);
111 ok(labels.some((text) => text.includes("Open in browser")), "pull request menu can open in the browser");
112 ok(labels.some((text) => text.includes("Copy link")), "pull request menu can copy the link");
113 ok(labels.some((text) => text.includes("Copy reference")), "pull request menu can copy a reference");
114 await clickMenuItem("Copy reference");
115 ok(clipboardWrites[0] === "esengine/DeepSeek-Reasonix#6976",
116 `copy reference writes the owner/repo#number form (${clipboardWrites[0]})`);
117 ok(window.document.querySelector(".toast--info")?.textContent?.includes("Copied") === true,
118 "a success toast confirms the copy");
119 await closeWithEscape();
120 }
121
122 // 2. GitHub commit URL: the reference uses the short SHA form.
123 {
124 const href = "https://github.com/esengine/DeepSeek-Reasonix/commit/ca03d4c2ec47d7fc9726affbbda04d8616f6351b";
125 const { anchor } = await renderLink(href);
126 await openContextMenu(anchor);
127 await clickMenuItem("Copy reference");
128 ok(clipboardWrites[1] === "esengine/DeepSeek-Reasonix@ca03d4c",
129 `commit reference writes owner/repo@shortsha (${clipboardWrites[1]})`);
130 await closeWithEscape();
131 }
132
133 // 3. External web URL: no reference action; copy and open go to the right places.
134 {
135 const href = "https://example.com/a/very/long/documentation/path";
136 const { anchor } = await renderLink(href);
137 await openContextMenu(anchor);
138 const labels = menuItemLabels();
139 ok(labels.length === 2, `external menu has two items (${labels.length})`);
140 ok(labels.every((text) => !text.includes("Copy reference")), "external menu has no reference action");
141 await clickMenuItem("Copy link");
142 ok(clipboardWrites[2] === href, `copy link writes the original URL (${clipboardWrites[2]})`);
143 await openContextMenu(anchor);
144 await clickMenuItem("Open in browser");
145 ok(browsed[0] === href, "open in browser sends the URL to the system browser");
146 await closeWithEscape();
147 }
148
149 // 4. mailto link: compose opens the mail handler; copy strips the scheme.
150 {
151 const href = "mailto:support@example.com";
152 const { anchor } = await renderLink(href);
153 await openContextMenu(anchor);
154 const labels = menuItemLabels();
155 ok(labels.some((text) => text.includes("Compose email")), "mailto menu offers compose email");
156 ok(labels.some((text) => text.includes("Copy email address")), "mailto menu offers copy address");
157 await clickMenuItem("Copy email address");
158 ok(clipboardWrites[3] === "support@example.com",
159 `copy email address strips the mailto: scheme (${clipboardWrites[3]})`);
160 await openContextMenu(anchor);
161 await clickMenuItem("Compose email");
162 ok(browsed[1] === href, "compose email hands the mailto URL to the system handler");
163 await closeWithEscape();
164 }
165
166 // Copy only the decoded recipient address, not compose parameters.
167 for (const [href, expected] of [
168 ["mailto:support@example.com?subject=Help&body=Hello", "support@example.com"],
169 ["MAILTO:support@example.com", "support@example.com"],
170 ["mailto:hello%2Btag@example.com", "hello+tag@example.com"],
171 ["mailto:one@example.com,two@example.com?cc=other@example.com", "one@example.com,two@example.com"],
172 ["mailto:bad%ZZ@example.com?subject=Help", "bad%ZZ@example.com"],
173 ["mailto:?subject=Help", ""],
174 ]) {
175 const { anchor } = await renderLink(href);
176 await openContextMenu(anchor);
177 ok(menuItemLabels().some((text) => text.includes("Compose email")), "mail protocol classification is consistent");
178 await clickMenuItem("Copy email address");
179 ok(clipboardWrites.at(-1) === expected, `address copy for ${href}`);
180 await closeWithEscape();
181 }
182 clipboardWrites.splice(4);
183
184 // 5. Keyboard gesture: ContextMenu key and Shift+F10 open the menu, Escape closes.
185 {
186 const href = "https://github.com/esengine/DeepSeek-Reasonix/issues/6856";
187 const { anchor } = await renderLink(href);
188 anchor.focus();
189 await act(async () => {
190 anchor.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ContextMenu", bubbles: true, cancelable: true }));
191 });
192 ok(window.document.querySelector('[role="menu"]') !== null, "ContextMenu key opens the menu");
193 ok(window.document.activeElement?.textContent?.includes("Open in browser"), "keyboard menu focuses first action");
194 await closeWithEscape();
195 ok(window.document.querySelector('[role="menu"]') === null, "Escape closes the menu");
196 ok(window.document.activeElement === anchor, "Escape returns focus to link");
197 await act(async () => {
198 anchor.dispatchEvent(new window.KeyboardEvent("keydown", { key: "F10", shiftKey: true, bubbles: true, cancelable: true }));
199 });
200 ok(window.document.querySelector('[role="menu"]') !== null, "Shift+F10 opens the menu");
201 await closeWithEscape();
202 }
203
204 // 6. Explicit Markdown labels are irrelevant to the menu: the copy actions
205 // still write the original URL and the owner/repo reference.
206 {
207 const href = "https://github.com/esengine/DeepSeek-Reasonix/issues/6856";
208 const { anchor } = await renderLink(href, "Readable issue title");
209 await openContextMenu(anchor);
210 await clickMenuItem("Copy link");
211 ok(clipboardWrites[4] === href, "explicit label does not change what copy link writes");
212 await closeWithEscape();
213 }
214
215 // 7. Relative links (no icon kind) keep the plain anchor: no menu, no
216 // preventDefault, so the webview default behavior is untouched.
217 {
218 const { anchor } = await renderLink("./docs/GUIDE.md", "Guide");
219 let defaultPrevented = false;
220 await act(async () => {
221 const event = new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 10, clientY: 10 });
222 anchor.dispatchEvent(event);
223 defaultPrevented = event.defaultPrevented;
224 });
225 ok(defaultPrevented === false, "relative link right click is not intercepted");
226 ok(window.document.querySelector('[role="menu"]') === null, "relative link opens no context menu");
227 }
228
229 // 8. Clipboard failure surfaces an error toast instead of a success toast.
230 {
231 clipboardSucceeds = false;
232 const { anchor } = await renderLink("https://example.com/copy-failure");
233 await openContextMenu(anchor);
234 await clickMenuItem("Copy link");
235 ok(window.document.querySelector(".toast--error")?.textContent?.includes("Could not copy") === true,
236 "a failed copy shows an error toast");
237 await closeWithEscape();
238 clipboardSucceeds = true;
239 }
240
241 await act(async () => {
242 for (const root of roots) root.unmount();
243 });
244
245 process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
246 if (failed > 0) process.exit(1);
247
247 lines Plain Text