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