返回 DeepSeek-Reasonix
local-path-links.test.tsx
根目录 / desktop / frontend / src / __tests__ / local-path-links.test.tsx
1 // Run: tsx src/__tests__/local-path-links.test.tsx
2 //
3 // Recognition matrix for chat markdown local-path linkification (issue
4 // #7426): Windows drive paths, UNC paths and file:/// URLs become clickable
5 // links; sentence punctuation and non-path lookalikes must not be captured.
6 // Also covers the file:/// href round-trip used by RichMarkdownLink.
7
8 import { linkifyLocalPaths, localPathHref, remarkLocalPathLinks } from "../lib/localPathLinks";
9 import { localPathFromHref } from "../components/githubLink";
10
11 let passed = 0;
12 let failed = 0;
13
14 function eq(actual: unknown, expected: unknown, label: string) {
15 if (JSON.stringify(actual) === JSON.stringify(expected)) {
16 process.stdout.write(` PASS ${label}\n`);
17 passed += 1;
18 } else {
19 process.stdout.write(
20 ` FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n`,
21 );
22 failed += 1;
23 }
24 }
25
26 function ok(value: unknown, label: string) {
27 eq(Boolean(value), true, label);
28 }
29
30 function firstPath(text: string): string | undefined {
31 return linkifyLocalPaths(text).find((s) => s.path !== undefined)?.path;
32 }
33
34 function pathCount(text: string): number {
35 return linkifyLocalPaths(text).filter((s) => s.path !== undefined).length;
36 }
37
38 console.log("\nlinkifyLocalPaths — drive paths");
39
40 eq(firstPath("文件在 D:\\Project\\Jhtj\\20250804_000000_001_中停时分析\\05-静态验收.md 已生成"),
41 "D:\\Project\\Jhtj\\20250804_000000_001_中停时分析\\05-静态验收.md",
42 "issue case: Chinese dirs and underscores survive");
43 eq(firstPath("see D:\\a\\b c.md note"), "D:\\a\\b", "unescaped space still ends the path");
44 eq(firstPath("see D:\\a\\b\\ c.md"), "D:\\a\\b c.md", "escaped space unescapes to the real path");
45 eq(firstPath("saved C:/Users/me/AppData/Local/x.md here"), "C:/Users/me/AppData/Local/x.md",
46 "forward-slash drive path");
47 eq(firstPath("see D:\\x\\y.md. done"), "D:\\x\\y.md", "trailing period stripped");
48 eq(firstPath("path D:\\x\\y.md, ok"), "D:\\x\\y.md", "trailing comma stripped");
49 eq(firstPath("see D:\\x\\y.md) done"), "D:\\x\\y.md", "trailing closing paren stripped");
50 eq(firstPath("see D:\\x\\y.md... done"), "D:\\x\\y.md", "trailing ellipsis stripped");
51 eq(firstPath("see D:\\x\\y.md。打开它"), "D:\\x\\y.md", "CJK period stops the path");
52 eq(firstPath("见 D:\\x\\y.md,查看详情"), "D:\\x\\y.md", "CJK comma stops the path");
53 eq(firstPath("打开 D:\\x\\y.md; 完成"), "D:\\x\\y.md", "semicolon stops the path");
54 eq(firstPath("见 D:\\x\\y.md(已生成)"), "D:\\x\\y.md", "full-width parens stop the path");
55 eq(firstPath("报告 D:\\x\\y.md(终版)完成"), "D:\\x\\y.md", "full-width paren group after path");
56
57 console.log("\nlinkifyLocalPaths — file:/// and UNC");
58
59 eq(firstPath("see file:///D:/Project/report/05-final.md"), "file:///D:/Project/report/05-final.md",
60 "file URL matched whole, drive part not double-matched");
61 eq(firstPath("见 file:///D:/Project/中停时分析/05-静态验收.md"), "file:///D:/Project/中停时分析/05-静态验收.md",
62 "file URL with CJK dirs");
63 eq(firstPath("see file:///D:/x/y.md. done"), "file:///D:/x/y.md", "file URL trailing period stripped");
64 eq(firstPath("see file://nas/share/report.md"), "file://nas/share/report.md",
65 "authority-form UNC text becomes a local link");
66 eq(pathCount("see file:///D:/x/%zz"), 0, "malformed file URL is not linkified");
67 eq(pathCount("see file://./PhysicalDrive0"), 0, "device-authority file URL is not linkified");
68 eq(pathCount("see file:///C:/safe.txt:payload"), 0, "alternate data stream file URL is not linkified");
69 eq(pathCount("see file:///tmp/report.md?download=1"), 0, "file URL with a query is not partially linkified");
70 eq(firstPath("see \\nas\\share\\docs\\report.md done"), "\\\\nas\\share\\docs\\report.md",
71 "UNC path matched whole and \\\\ prefix restored");
72
73 console.log("\nlinkifyLocalPaths — lookalikes and non-paths");
74
75 eq(pathCount("C: drive"), 0, "bare drive letter is not a path");
76 eq(pathCount("at 10:30 now"), 0, "clock time is not a path");
77 eq(pathCount("version 1.2.3 released"), 0, "version string is not a path");
78 eq(pathCount("http://example.com 正常"), 0, "http URL untouched (GFM handles it)");
79 eq(pathCount("profile://nas/share/report.md"), 0, "custom URI containing file:// is not partially linkified");
80 eq(pathCount("http://file:///tmp/report.md"), 0, "file:// inside another URI is not linkified");
81 eq(pathCount("foo://host?file:///tmp/report.md"), 0, "file:// in a URI query is not linkified");
82 eq(pathCount("foo://host#file:///tmp/report.md"), 0, "file:// in a URI fragment is not linkified");
83 eq(pathCount("foo://host&file:///tmp/report.md"), 0, "file:// after an ampersand is not linkified");
84 eq(pathCount("foo://host=file:///tmp/report.md"), 0, "file:// after an equals sign is not linkified");
85 eq(pathCount("prefixD:\\x\\y.md"), 0, "drive path after a letter is rejected without lookbehind");
86 eq(pathCount("file:///D:/x/y.md"), 1, "file URL drive segment is not double-matched");
87 eq(pathCount("a\\b\\c.md 讨论"), 0, "share-less backslash path in prose is not a UNC path");
88 eq(pathCount("C:\\nas\\share\\x.md"), 1, "drive path does not also become a UNC match");
89 eq(firstPath("见 \\nas\\share\\x.md"), "\\\\nas\\share\\x.md", "real UNC path still matches");
90 eq(pathCount("see C:\\safe.txt:payload"), 0, "alternate data stream drive path stays inert");
91 eq(pathCount("see \\.\\PhysicalDrive0"), 0, "device namespace path stays inert");
92 eq(pathCount("see C:\\docs\\NUL.txt"), 0, "reserved DOS device path stays inert");
93 eq(pathCount("see C:\\docs\\COM1.log"), 0, "numbered DOS device path stays inert");
94 eq(firstPath("see C:\\docs\\nullifier.txt"), "C:\\docs\\nullifier.txt", "ordinary device-like name remains linkable");
95 eq(firstPath("见 C:\\Program\\ Files\\ (x86) 完成"), "C:\\Program Files (x86)", "escaped-space path with (x86) keeps its closing paren");
96
97 console.log("\nsegment alternation");
98
99 const segs = linkifyLocalPaths("见 D:\\x\\y.md 然后 file:///C:/a/b.txt 完成");
100 eq(segs.length, 5, "text/link/text/link/text alternation");
101 eq(segs[0], { text: "见 " }, "leading plain text");
102 eq(segs[1].path, "D:\\x\\y.md", "first link segment");
103 eq(segs[2], { text: " 然后 " }, "middle plain text");
104 eq(segs[3].path, "file:///C:/a/b.txt", "second link segment");
105 eq(segs[4], { text: " 完成" }, "trailing plain text");
106
107 console.log("\nlocalPathHref round-trip");
108
109 eq(localPathHref("D:\\x\\y.md"), "file:///D:/x/y.md", "backslashes become forward slashes");
110 eq(localPathHref("D:\\Project\\中停时分析\\05-静态验收.md"), "file:///D:/Project/%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",
111 "CJK percent-encoded");
112 eq(localPathHref("D:\\a\\b#c.md"), "file:///D:/a/b%23c.md", "hash escaped for URL safety");
113 eq(localPathHref("file:///C:/a/b.txt"), "file:///C:/a/b.txt", "already-absolute file URL is not double-prefixed");
114 eq(localPathHref("file:///D:/a%20b.txt"), "file:///D:/a%20b.txt", "literal %xx in a file URL is not double-encoded");
115 eq(localPathHref("file://nas/share/report.md"), "file://nas/share/report.md",
116 "authority-form UNC URL is preserved");
117 eq(localPathHref("file:////nas/share/report.md"), "file:////nas/share/report.md",
118 "slash-form UNC URL is preserved");
119 eq(localPathFromHref("file:///D:/a%20b.txt"), "D:/a b.txt", "decoded %20 becomes a real space");
120
121 eq(localPathFromHref("file:///D:/x/y.md"), "D:/x/y.md", "decodes plain path");
122 eq(localPathFromHref("file:///Users/liangkang/notes/readme.md"), "/Users/liangkang/notes/readme.md", "preserves the leading slash for Unix paths");
123 eq(localPathFromHref("file://nas/share/report.md"), "//nas/share/report.md", "parses authority-form UNC URLs");
124 eq(localPathFromHref("file:////nas/share/report.md"), "//nas/share/report.md", "normalizes four-slash UNC URLs");
125 eq(localPathFromHref("file://///nas/share/report.md"), "//nas/share/report.md", "normalizes generated five-slash UNC URLs");
126 eq(localPathFromHref("file://./PhysicalDrive0"), null, "rejects device-namespace authority");
127 eq(localPathFromHref("file:////./PhysicalDrive0"), null, "rejects slash-form device namespace");
128 eq(localPathFromHref("file:////%2E/PhysicalDrive0"), null, "rejects encoded slash-form device namespace");
129 eq(localPathFromHref("file:////?/C:/Windows"), null, "rejects query-form device namespace");
130 eq(localPathFromHref("file:////%3F/C:/Windows"), null, "rejects encoded query-form device namespace");
131 eq(localPathFromHref("file:///C:/safe.txt:payload"), null, "rejects alternate data stream file URL");
132 eq(localPathFromHref("file:///C:/docs/NUL.txt"), null, "rejects reserved DOS device file URL");
133 eq(localPathFromHref("file:///C:/docs/COM1.log"), null, "rejects numbered DOS device file URL");
134 eq(localPathFromHref("file:///tmp/NUL.txt"), "/tmp/NUL.txt", "keeps Unix files named like DOS devices");
135 eq(localPathFromHref("file:///tmp/report.md?download=1"), null, "rejects file URL query strings");
136 eq(localPathFromHref("file:///tmp/report.md#section"), null, "rejects file URL fragments");
137 eq(localPathFromHref("file:///D:/Project/%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"),
138 "D:/Project/中停时分析/05-静态验收.md", "decodes percent-encoded CJK");
139 eq(localPathFromHref("file:///D:/x/%zz"), null, "malformed escapes yield null");
140 eq(localPathFromHref("FILE:///Users/a.md"), null, "uppercase file scheme is not a local URL");
141 eq(localPathFromHref("https://example.com/x"), null, "http URL is not local");
142 eq(localPathFromHref(undefined), null, "undefined href is not local");
143
144 console.log("\nremark plugin via react-markdown render");
145
146 import React from "react";
147 import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
148 import { renderToStaticMarkup } from "react-dom/server";
149
150 // Mirrors MarkdownRenderer: valid local file anchors survive the default
151 // sanitizer, including authority-form UNC links.
152 const markdownUrlTransform = (value: string) =>
153 localPathFromHref(value) !== null ? value : defaultUrlTransform(value);
154
155 const html = renderToStaticMarkup(
156 <ReactMarkdown remarkPlugins={[remarkLocalPathLinks]} urlTransform={markdownUrlTransform}>
157 {"文件在 D:\\Project\\x\\05-静态验收.md 已生成"}
158 </ReactMarkdown>,
159 );
160 ok(html.includes('href="file:///D:/Project/x/05-%E9%9D%99%E6%80%81%E9%AA%8C%E6%94%B6.md"'),
161 "plain drive path renders as a file:/// anchor");
162 ok(html.includes("05-静态验收.md"), "anchor label keeps the original text");
163
164 const mixed = renderToStaticMarkup(
165 <ReactMarkdown remarkPlugins={[remarkLocalPathLinks]} urlTransform={markdownUrlTransform}>
166 {"见 D:\\x\\y.md,然后 file:///C:/a/b.txt 完成"}
167 </ReactMarkdown>,
168 );
169 ok(mixed.includes('href="file:///D:/x/y.md"'), "CJK comma boundary renders drive link");
170 ok(mixed.includes('href="file:///C:/a/b.txt"'), "explicit file URL renders its own anchor");
171
172 const unc = renderToStaticMarkup(
173 <ReactMarkdown remarkPlugins={[remarkLocalPathLinks]} urlTransform={markdownUrlTransform}>
174 {"[共享盘](file://nas/share/report.md)"}
175 </ReactMarkdown>,
176 );
177 ok(unc.includes('href="file://nas/share/report.md"'), "authority-form UNC link survives URL sanitization");
178
179 const plain = renderToStaticMarkup(
180 <ReactMarkdown remarkPlugins={[remarkLocalPathLinks]} urlTransform={markdownUrlTransform}>
181 {"no paths here"}
182 </ReactMarkdown>,
183 );
184 ok(!plain.includes("<a"), "plain text without paths renders no anchors");
185
186 const nested = renderToStaticMarkup(
187 <ReactMarkdown remarkPlugins={[remarkLocalPathLinks]} urlTransform={markdownUrlTransform}>
188 {"[D:\\x\\y.md](https://example.com)"}
189 </ReactMarkdown>,
190 );
191 ok(!nested.includes("file:///"), "explicit link text is not re-linkified (no nested anchors)");
192
193 const uppercase = renderToStaticMarkup(
194 <ReactMarkdown remarkPlugins={[remarkLocalPathLinks]} urlTransform={markdownUrlTransform}>
195 {"see FILE:///C:/x/y.md"}
196 </ReactMarkdown>,
197 );
198 ok(!uppercase.includes("file:///"), "uppercase FILE:/// stays inert (case-sensitive allowlist)");
199
200 process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
201 if (failed > 0) process.exit(1);
202
202 lines Plain Text