返回 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 Wails bridge is stubbed via
9 // window.go.main.App / window.runtime.
10
11 import { JSDOM } from "jsdom";
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 // React 19 requires this flag for act() to flush renders/pass-through events.
23 (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
24
25 // Bridge spies: OpenLocalPath is what the click must call; BrowserOpenURL is
26 // what plain http links must call instead.
27 const opened: string[] = [];
28 const browsed: string[] = [];
29 (window as unknown as Record<string, unknown>).go = {
30 main: {
31 App: {
32 OpenLocalPath: async (path: string) => {
33 opened.push(path);
34 },
35 },
36 },
37 };
38 (window as unknown as Record<string, unknown>).runtime = {
39 BrowserOpenURL: (url: string) => {
40 browsed.push(url);
41 },
42 };
43
44 let passed = 0;
45 let failed = 0;
46 function ok(value: unknown, label: string) {
47 if (value) {
48 process.stdout.write(` PASS ${label}\n`);
49 passed += 1;
50 } else {
51 process.stdout.write(` FAIL ${label}\n`);
52 failed += 1;
53 }
54 }
55
56 const { createElement } = await import("react");
57 const { act } = await import("react");
58 const { createRoot } = await import("react-dom/client");
59 const { default: ReactMarkdown, defaultUrlTransform } = await import("react-markdown");
60 const { default: remarkGfm } = await import("remark-gfm");
61 const { remarkLocalPathLinks } = await import("../lib/localPathLinks");
62 const { RichMarkdownLink } = await import("../components/githubLink");
63
64 const markdownUrlTransform = (value: string) =>
65 value.startsWith("file:///") ? value : defaultUrlTransform(value);
66
67 const components = { a: RichMarkdownLink };
68
69 async function renderClick(markdown: string): Promise<HTMLAnchorElement[]> {
70 const container = window.document.createElement("div");
71 window.document.body.appendChild(container);
72 const root = createRoot(container);
73 await act(async () => {
74 root.render(
75 createElement(
76 ReactMarkdown,
77 { remarkPlugins: [remarkGfm, remarkLocalPathLinks], urlTransform: markdownUrlTransform, components },
78 markdown,
79 ),
80 );
81 });
82 return Array.from(container.querySelectorAll("a"));
83 }
84
85 console.log("\nheadless click-to-open e2e");
86
87 // 1. Issue #7426 scenario: plain Windows path in chat text, CJK dirs.
88 {
89 const anchors = await renderClick("文件在 D:\\Project\\Jhtj\\20250804_000000_001_中停时分析\\05-静态验收.md 已生成");
90 ok(anchors.length === 1, "exactly one anchor rendered");
91 const href = anchors[0]?.getAttribute("href") ?? "";
92 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",
93 `anchor href is the encoded file URL (${href})`);
94 await act(async () => {
95 anchors[0].dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true }));
96 });
97 ok(opened.length === 1, "click invoked OpenLocalPath once");
98 ok(opened[0] === "D:/Project/Jhtj/20250804_000000_001_中停时分析/05-静态验收.md",
99 `OpenLocalPath received the decoded CJK path (${opened[0]})`);
100 ok(browsed.length === 0, "system browser was not involved");
101 }
102
103 // 2. UNC path (markdown folds \\ into \, linkify restores it; the href uses
104 // forward slashes, which Windows accepts as a UNC form).
105 {
106 const anchors = await renderClick("共享盘 \\\\nas\\share\\docs\\report.md 已生成");
107 await act(async () => {
108 anchors[0].dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true }));
109 });
110 ok(opened[1] === "//nas/share/docs/report.md", `UNC path forwarded as slash form (${opened[1]})`);
111 }
112
113 // 3. Explicit markdown link to a local file keeps working through the same path.
114 {
115 const anchors = await renderClick("[验收单](file:///D:/docs/acceptance.md)");
116 await act(async () => {
117 anchors[0].dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true }));
118 });
119 ok(opened[2] === "D:/docs/acceptance.md", "explicit file:/// markdown link opens locally");
120 }
121
122 // 4. Plain http link must NOT hit OpenLocalPath.
123 {
124 const anchors = await renderClick("见 https://example.com/page 文档");
125 await act(async () => {
126 anchors[0].dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true }));
127 });
128 ok(browsed.length === 1 && browsed[0] === "https://example.com/page", "http link went to the system browser");
129 ok(opened.length === 3, "OpenLocalPath was not called for the http link");
130 }
131
132 // 5. Non-path text renders no anchors and no clicks.
133 {
134 const anchors = await renderClick("版本 1.2.3 已发布");
135 ok(anchors.length === 0, "no anchors for plain text");
136 }
137
138 process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
139 if (failed > 0) process.exit(1);
140
140 lines Plain Text