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