| 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 |