返回 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 \\nas\\share\\docs\\report.md done"), "\\\\nas\\share\\docs\\report.md",
65 "UNC path matched whole and \\\\ prefix restored");
66
67 console.log("\nlinkifyLocalPaths — lookalikes and non-paths");
68
69 eq(pathCount("C: drive"), 0, "bare drive letter is not a path");
70 eq(pathCount("at 10:30 now"), 0, "clock time is not a path");
71 eq(pathCount("version 1.2.3 released"), 0, "version string is not a path");
72 eq(pathCount("http://example.com 正常"), 0, "http URL untouched (GFM handles it)");
73 eq(pathCount("prefixD:\\x\\y.md"), 0, "drive path after a letter is rejected without lookbehind");
74 eq(pathCount("file:///D:/x/y.md"), 1, "file URL drive segment is not double-matched");
75 eq(pathCount("a\\b\\c.md 讨论"), 0, "share-less backslash path in prose is not a UNC path");
76 eq(pathCount("C:\\nas\\share\\x.md"), 1, "drive path does not also become a UNC match");
77 eq(firstPath("见 \\nas\\share\\x.md"), "\\\\nas\\share\\x.md", "real UNC path still matches");
78 eq(firstPath("见 C:\\Program\\ Files\\ (x86) 完成"), "C:\\Program Files (x86)", "escaped-space path with (x86) keeps its closing paren");
79
80 console.log("\nsegment alternation");
81
82 const segs = linkifyLocalPaths("见 D:\\x\\y.md 然后 file:///C:/a/b.txt 完成");
83 eq(segs.length, 5, "text/link/text/link/text alternation");
84 eq(segs[0], { text: "见 " }, "leading plain text");
85 eq(segs[1].path, "D:\\x\\y.md", "first link segment");
86 eq(segs[2], { text: " 然后 " }, "middle plain text");
87 eq(segs[3].path, "file:///C:/a/b.txt", "second link segment");
88 eq(segs[4], { text: " 完成" }, "trailing plain text");
89
90 console.log("\nlocalPathHref round-trip");
91
92 eq(localPathHref("D:\\x\\y.md"), "file:///D:/x/y.md", "backslashes become forward slashes");
93 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",
94 "CJK percent-encoded");
95 eq(localPathHref("D:\\a\\b#c.md"), "file:///D:/a/b%23c.md", "hash escaped for URL safety");
96 eq(localPathHref("file:///C:/a/b.txt"), "file:///C:/a/b.txt", "already-absolute file URL is not double-prefixed");
97 eq(localPathHref("file:///D:/a%20b.txt"), "file:///D:/a%20b.txt", "literal %xx in a file URL is not double-encoded");
98 eq(localPathFromHref("file:///D:/a%20b.txt"), "D:/a b.txt", "decoded %20 becomes a real space");
99
100 eq(localPathFromHref("file:///D:/x/y.md"), "D:/x/y.md", "decodes plain path");
101 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"),
102 "D:/Project/中停时分析/05-静态验收.md", "decodes percent-encoded CJK");
103 eq(localPathFromHref("file:///D:/x/%zz"), null, "malformed escapes yield null");
104 eq(localPathFromHref("https://example.com/x"), null, "http URL is not local");
105 eq(localPathFromHref(undefined), null, "undefined href is not local");
106
107 console.log("\nremark plugin via react-markdown render");
108
109 import React from "react";
110 import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
111 import { renderToStaticMarkup } from "react-dom/server";
112
113 // Mirrors MarkdownRenderer: file:/// anchors survive the default sanitizer.
114 const markdownUrlTransform = (value: string) =>
115 value.startsWith("file:///") ? value : defaultUrlTransform(value);
116
117 const html = renderToStaticMarkup(
118 <ReactMarkdown remarkPlugins={[remarkLocalPathLinks]} urlTransform={markdownUrlTransform}>
119 {"文件在 D:\\Project\\x\\05-静态验收.md 已生成"}
120 </ReactMarkdown>,
121 );
122 ok(html.includes('href="file:///D:/Project/x/05-%E9%9D%99%E6%80%81%E9%AA%8C%E6%94%B6.md"'),
123 "plain drive path renders as a file:/// anchor");
124 ok(html.includes("05-静态验收.md"), "anchor label keeps the original text");
125
126 const mixed = renderToStaticMarkup(
127 <ReactMarkdown remarkPlugins={[remarkLocalPathLinks]} urlTransform={markdownUrlTransform}>
128 {"见 D:\\x\\y.md,然后 file:///C:/a/b.txt 完成"}
129 </ReactMarkdown>,
130 );
131 ok(mixed.includes('href="file:///D:/x/y.md"'), "CJK comma boundary renders drive link");
132 ok(mixed.includes('href="file:///C:/a/b.txt"'), "explicit file URL renders its own anchor");
133
134 const plain = renderToStaticMarkup(
135 <ReactMarkdown remarkPlugins={[remarkLocalPathLinks]} urlTransform={markdownUrlTransform}>
136 {"no paths here"}
137 </ReactMarkdown>,
138 );
139 ok(!plain.includes("<a"), "plain text without paths renders no anchors");
140
141 const nested = renderToStaticMarkup(
142 <ReactMarkdown remarkPlugins={[remarkLocalPathLinks]} urlTransform={markdownUrlTransform}>
143 {"[D:\\x\\y.md](https://example.com)"}
144 </ReactMarkdown>,
145 );
146 ok(!nested.includes("file:///"), "explicit link text is not re-linkified (no nested anchors)");
147
148 const uppercase = renderToStaticMarkup(
149 <ReactMarkdown remarkPlugins={[remarkLocalPathLinks]} urlTransform={markdownUrlTransform}>
150 {"see FILE:///C:/x/y.md"}
151 </ReactMarkdown>,
152 );
153 ok(!uppercase.includes("file:///"), "uppercase FILE:/// stays inert (case-sensitive allowlist)");
154
155 process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
156 if (failed > 0) process.exit(1);
157
157 lines Plain Text