返回 DeepSeek-Reasonix
local-path-click-e2e.test.tsx
根目录 / desktop / frontend / src / __tests__ / local-path-click-e2e.test.tsx
1 // Run: tsx src/__tests__/local-path-click-e2e.test.tsx
2 //
3 // Headless end-to-end of the click-to-open chain from issue #7426: chat
4 // markdown with a plain Windows path is rendered with the real production
5 // pipeline (remarkLocalPathLinks + urlTransform + RichMarkdownLink), the
6 // anchor is clicked in a JSDOM document, and the native OpenLocalPath binding
7 // must receive the decoded local path. A plain http link must instead go to
8 // the system browser. No UI is involved — the desktop bridge is stubbed via
9 // the desktop host stub.
10
11 import { JSDOM } from "jsdom";
12 import { installDesktopHostStub } from "./desktopHostStub";
13
14 const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: "https://reasonix.local/" });
15 const { window } = dom;
16
17 // Set globals BEFORE dynamically importing React DOM so the renderer sees a
18 // real document (ESM imports are hoisted, hence dynamic import below).
19 (globalThis as Record<string, unknown>).window = window;
20 (globalThis as Record<string, unknown>).document = window.document;
21 (globalThis as Record<string, unknown>).HTMLElement = window.HTMLElement;
22 (globalThis as Record<string, unknown>).MouseEvent = window.MouseEvent;
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: OpenLocalPath is what the click must call; BrowserOpenURL is
27 // what plain http links must call instead.
28 const opened: string[] = [];
29 const openedWith: Array<[string, string]> = [];
30 const browsed: string[] = [];
31 type MockOpeners = {
32 openers: Array<{ id: string; name: string; kind: "editor" | "file-manager" }>;
33 preferred: string;
34 };
35 let openerRaceMode = false;
36 let openerRaceCalls = 0;
37 let resolveStaleOpeners: ((value: MockOpeners) => void) | undefined;
38 installDesktopHostStub(({
39 main: {
40 App: {
41 OpenLocalPath: async (path: string) => {
42 opened.push(path);
43 },
44 ExternalOpeners: async (): Promise<MockOpeners> => {
45 if (openerRaceMode) {
46 openerRaceCalls += 1;
47 if (openerRaceCalls === 1) {
48 return new Promise<MockOpeners>((resolve) => {
49 resolveStaleOpeners = resolve;
50 });
51 }
52 return {
53 openers: [{ id: "xcode", name: "Xcode", kind: "editor" }],
54 preferred: "xcode",
55 };
56 }
57 return {
58 openers: [
59 { id: "vscode", name: "VS Code", kind: "editor" as const },
60 { id: "finder", name: "Finder", kind: "file-manager" as const },
61 ],
62 preferred: "vscode",
63 };
64 },
65 OpenLocalPathInExternalOpener: async (path: string, id: string) => {
66 openedWith.push([path, id]);
67 },
68 RevealPath: async () => {},
69 },
70 },
71 }).main.App, { externalOpens: browsed });
72
73 let passed = 0;
74 let failed = 0;
75 function ok(value: unknown, label: string) {
76 if (value) {
77 process.stdout.write(` PASS ${label}\n`);
78 passed += 1;
79 } else {
80 process.stdout.write(` FAIL ${label}\n`);
81 failed += 1;
82 }
83 }
84
85 const { createElement, StrictMode } = await import("react");
86 const { act } = await import("react");
87 const { createRoot } = await import("react-dom/client");
88 const { default: ReactMarkdown, defaultUrlTransform } = await import("react-markdown");
89 const { default: remarkGfm } = await import("remark-gfm");
90 const { remarkLocalPathLinks } = await import("../lib/localPathLinks");
91 const { localPathFromHref, RichMarkdownLink } = await import("../components/githubLink");
92
93 const markdownUrlTransform = (value: string) =>
94 localPathFromHref(value) !== null ? value : defaultUrlTransform(value);
95
96 const components = { a: RichMarkdownLink };
97
98 async function renderClick(markdown: string, strictMode = false): Promise<HTMLAnchorElement[]> {
99 const container = window.document.createElement("div");
100 window.document.body.appendChild(container);
101 const root = createRoot(container);
102 const markdownElement = createElement(
103 ReactMarkdown,
104 { remarkPlugins: [remarkGfm, remarkLocalPathLinks], urlTransform: markdownUrlTransform, components },
105 markdown,
106 );
107 await act(async () => {
108 root.render(strictMode ? createElement(StrictMode, null, markdownElement) : markdownElement);
109 });
110 return Array.from(container.querySelectorAll("a"));
111 }
112
113 console.log("\nheadless click-to-open e2e");
114
115 // 1. Issue #7426 scenario: plain Windows path in chat text, CJK dirs.
116 {
117 const anchors = await renderClick("文件在 D:\\Project\\Jhtj\\20250804_000000_001_中停时分析\\05-静态验收.md 已生成");
118 ok(anchors.length === 1, "exactly one anchor rendered");
119 const href = anchors[0]?.getAttribute("href") ?? "";
120 ok(href === "file:///D:/Project/Jhtj/20250804_000000_001_%E4%B8%AD%E5%81%9C%E6%97%B6%E5%88%86%E6%9E%90/05-%E9%9D%99%E6%80%81%E9%AA%8C%E6%94%B6.md",
121 `anchor href is the encoded file URL (${href})`);
122 await act(async () => {
123 anchors[0].dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true }));
124 });
125 ok(opened.length === 1, "click invoked OpenLocalPath once");
126 ok(opened[0] === "D:/Project/Jhtj/20250804_000000_001_中停时分析/05-静态验收.md",
127 `OpenLocalPath received the decoded CJK path (${opened[0]})`);
128 ok(browsed.length === 0, "system browser was not involved");
129 }
130
131 // 2. UNC path (markdown folds \\ into \, linkify restores it; the href uses
132 // forward slashes, which Windows accepts as a UNC form).
133 {
134 const anchors = await renderClick("共享盘 \\\\nas\\share\\docs\\report.md 已生成");
135 await act(async () => {
136 anchors[0].dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true }));
137 });
138 ok(opened[1] === "//nas/share/docs/report.md", `UNC path forwarded as slash form (${opened[1]})`);
139 }
140
141 // 3. Canonical authority-form UNC markdown links use the same native path.
142 {
143 const anchors = await renderClick("[共享盘](file://nas/share/docs/report.md)");
144 ok(anchors.length === 1, "canonical UNC markdown link renders an anchor");
145 await act(async () => {
146 anchors[0].dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true }));
147 });
148 ok(opened[2] === "//nas/share/docs/report.md", `canonical UNC path forwarded correctly (${opened[2]})`);
149 }
150
151 // 4. Explicit markdown link to a local file keeps working through the same path.
152 {
153 const anchors = await renderClick("[验收单](file:///D:/docs/acceptance.md)");
154 await act(async () => {
155 anchors[0].dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true }));
156 });
157 ok(opened[3] === "D:/docs/acceptance.md", "explicit file:/// markdown link opens locally");
158
159 await act(async () => {
160 anchors[0].dispatchEvent(new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 40, clientY: 40 }));
161 });
162 const contextMenu = window.document.querySelector('[role="menu"]');
163 ok(contextMenu !== null, "local path context menu opens on right click");
164 const vscodeItem = Array.from(contextMenu?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [])
165 .find((item) => item.textContent?.includes("VS Code"));
166 ok(!Array.from(contextMenu?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [])
167 .some((item) => item.textContent?.includes("使用 Finder 打开")), "file manager is not duplicated in the open-with list");
168 await act(async () => {
169 vscodeItem?.dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true }));
170 });
171 ok(JSON.stringify(openedWith) === JSON.stringify([["D:/docs/acceptance.md", "vscode"]]), "context menu opens the path with the selected application");
172 }
173
174 // 5. A browser may emit both contextmenu and right-button auxclick. Only the
175 // middle button is an open gesture; the right button must leave the menu open
176 // without also launching the local file.
177 {
178 const anchors = await renderClick("[右键](file:///D:/docs/right-click.md)");
179 const openedBefore = opened.length;
180 await act(async () => {
181 anchors[0].dispatchEvent(new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
182 anchors[0].dispatchEvent(new window.MouseEvent("auxclick", { button: 2, bubbles: true, cancelable: true }));
183 await Promise.resolve();
184 });
185 ok(opened.length === openedBefore, "right-button auxclick does not open a local path");
186 await act(async () => {
187 window.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape" }));
188 });
189 }
190
191 // 6. React StrictMode replays effect setup and cleanup in development. Opener
192 // discovery after that replay must still populate the context menu.
193 {
194 const anchors = await renderClick("[严格模式](file:///D:/strict.md)", true);
195 await act(async () => {
196 anchors[0].dispatchEvent(new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
197 await Promise.resolve();
198 });
199 const menus = window.document.querySelectorAll('[role="menu"]');
200 const strictMenu = menus[menus.length - 1];
201 ok(strictMenu?.textContent?.includes("VS Code") === true,
202 "StrictMode opener discovery populates the local-path menu");
203 await act(async () => {
204 window.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape" }));
205 });
206 }
207
208 // 7. Overlapping opener discovery keeps the latest response.
209 {
210 openerRaceMode = true;
211 const anchors = await renderClick("[竞态](file:///D:/race.md)");
212 await act(async () => {
213 anchors[0].dispatchEvent(new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 50, clientY: 50 }));
214 });
215 await act(async () => {
216 anchors[0].dispatchEvent(new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 50, clientY: 50 }));
217 await Promise.resolve();
218 });
219 const raceMenu = window.document.querySelector('[role="menu"]');
220 ok(openerRaceCalls === 2 && raceMenu?.textContent?.includes("Xcode") === true,
221 "latest opener discovery populates the local-path menu");
222 await act(async () => {
223 resolveStaleOpeners?.({
224 openers: [{ id: "stale", name: "Stale Editor", kind: "editor" }],
225 preferred: "stale",
226 });
227 await Promise.resolve();
228 });
229 ok(raceMenu?.textContent?.includes("Xcode") === true && !raceMenu?.textContent?.includes("Stale Editor"),
230 "stale opener discovery cannot replace the latest result");
231 }
232
233 // 8. Plain http link must NOT hit OpenLocalPath. A right-button auxclick must
234 // also leave it to the context-menu gesture instead of opening the browser.
235 {
236 const anchors = await renderClick("见 https://example.com/page 文档");
237 const browsedBefore = browsed.length;
238 await act(async () => {
239 anchors[0].dispatchEvent(new window.MouseEvent("auxclick", { button: 2, bubbles: true, cancelable: true }));
240 });
241 ok(browsed.length === browsedBefore, "right-button auxclick does not open an http link");
242 await act(async () => {
243 anchors[0].dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true }));
244 });
245 ok(browsed.length === 1 && browsed[0] === "https://example.com/page", "http link went to the system browser");
246 ok(opened.length === 4, "OpenLocalPath was not called for the http link");
247 }
248
249 // 9. Non-path text renders no anchors and no clicks.
250 {
251 const anchors = await renderClick("版本 1.2.3 已发布");
252 ok(anchors.length === 0, "no anchors for plain text");
253 }
254
255 process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
256 if (failed > 0) process.exit(1);
257
257 lines Plain Text