Open History to see the last commit
1import { workspaceRelativeFilePath } from "./path.ts";
2import { describe, expect, it } from "vite-plus/test";
3import { serializeComposerFileLink } from "./composerTrigger.ts";
4import { extractMarkdownLinkHrefs, resolveMarkdownFileLinkTarget } from "./markdownLinks.ts";
5
6import {
7 inlineCodeFilePathCandidate,
8 isMarkdownFileLinkLabel,
9 parseMarkdownFileLink,
10} from "./markdownLinks.ts";
11
12describe("isMarkdownFileLinkLabel", () => {
13 // regression: #15509 descriptive labels are not mistaken for the linked file name
14 it.each([
15 ["validates the input", "/repo/src/example.ts:12", false],
16 ["example.ts:12", "/repo/src/example.ts:12", true],
17 ["example.ts:99", "/repo/src/example.ts:12", false],
18 ["example.ts:12-20", "/repo/src/example.ts:12-20", true],
19 ["example.ts:12-20", "/repo/src/example.ts:12", false],
20 ])("classifies %s for %s", (label, href, expected) => {
21 expect(isMarkdownFileLinkLabel(label, href)).toBe(expected);
22 });
23});
24
25describe("inlineCodeFilePathCandidate", () => {
26 // regression: #13909 model ids in inline code do not become file chips
27 it.each([
28 ["src\\main.ts", "src/main.ts"],
29 ["allocator.cc:217", "allocator.cc:217"],
30 ["src/http.ts:42-64", "src/http.ts:42-64"],
31 ["http.ts:42-64", "http.ts:42-64"],
32 ["origin/main", null],
33 ["z-ai/glm-5.3", null],
34 ["share/man/ls.1", "share/man/ls.1"],
35 ["src/main.ts/", null],
36 ["./patches/", "./patches/"],
37 // regression: #30757 an agent's shortened path is not a file
38 ["/tmp/claude-1000/...", null],
39 ["/tmp/claude-1000/...:12", null],
40 ["/tmp/.../scratchpad/notes.md", null],
41 ["~/projects/…/notes.md", null],
42 ["src/.../main.ts", null],
43 // regression: #22181 a `:line` suffix does not make an extensionless basename a file
44 ["I/W217:220", null],
45 ["bin/run:12", null],
46 ["docs/README:12", "docs/README:12"],
47 ["./bin/run:12", "./bin/run:12"],
48 ["src/資料.備份:12", "src/資料.備份:12"],
49 ["src/foo.c++:12", "src/foo.c++:12"],
50 ])("distinguishes file paths from code and hostnames in %s", (source, candidate) => {
51 expect(inlineCodeFilePathCandidate(source)).toBe(candidate);
52 });
53});
54
55describe("parseMarkdownFileLink", () => {
56 it.each([
57 ["C:%5Crepo%5Cimage.png", "C:\\repo\\image.png"],
58 ["</D:/Programme/t3code/ChatMarkdown.tsx:1>", "D:/Programme/t3code/ChatMarkdown.tsx"],
59 ["file://localhost/home/me/notes.md", "/home/me/notes.md"],
60 ["app/routes/posts.$postId.tsx", "app/routes/posts.$postId.tsx"],
61 ["src/routes/+page.svelte", "src/routes/+page.svelte"],
62 ["app/(marketing)/page.tsx:12", "app/(marketing)/page.tsx"],
63 ["pages/posts/%5Bid%5D.tsx", "pages/posts/[id].tsx"],
64 ["+page.svelte", "+page.svelte"],
65 ["[id].tsx", "[id].tsx"],
66 ["/\\server\\share\\x.ts", "/\\server\\share\\x.ts"],
67 ])("recognizes %s as a file", (href, path) => {
68 expect(parseMarkdownFileLink(href)?.path).toBe(path);
69 });
70
71 it.each([
72 "app/routes/posts.$postId.tsx",
73 "pages/posts/[id].tsx",
74 "docs/My File (draft).md",
75 "@scope/package.json",
76 "$postId.tsx",
77 ])("reads back the composer's link to %s", (path) => {
78 const destination = /\]\((.*)\)$/.exec(serializeComposerFileLink(path))?.[1];
79 expect(parseMarkdownFileLink(destination ?? "")?.path).toBe(path);
80 });
81
82 it.each([
83 "https://example.com/docs",
84 "/chat/settings#L3",
85 "TODO:12",
86 "patches",
87 "me@example.com",
88 "foo(bar)",
89 "$HOME",
90 "/tmp/claude-1000/...",
91 "/tmp/claude-1000/....:12",
92 "/tmp/claude-1000/%2E%2E%2E",
93 "\\foo.ts",
94 ])("does not treat %s as a file", (href) => {
95 expect(parseMarkdownFileLink(href)).toBeNull();
96 });
97
98 it("recognizes a relative folder that ends in a slash", () => {
99 expect(parseMarkdownFileLink("patches/")).toEqual({ path: "patches/" });
100 expect(parseMarkdownFileLink("docs/My%20Folder/")?.path).toBe("docs/My Folder/");
101 });
102
103 // regression: #16096 relative links with non-ASCII names open as files
104 it("recognizes relative paths with non-ASCII names", () => {
105 expect(parseMarkdownFileLink("pastexam/線性代數/exam_易志偉.pdf")?.path).toBe(
106 "pastexam/線性代數/exam_易志偉.pdf",
107 );
108 expect(parseMarkdownFileLink("Résumé%20final.pdf")?.path).toBe("Résumé final.pdf");
109 expect(parseMarkdownFileLink("資料.備份")?.path).toBe("資料.備份");
110 });
111
112 it("accepts conventional extensionless names with or without a position", () => {
113 expect(parseMarkdownFileLink("Makefile")).toEqual({ path: "Makefile" });
114 expect(parseMarkdownFileLink("Dockerfile:8")).toEqual({ path: "Dockerfile", line: 8 });
115 expect(parseMarkdownFileLink("/srv/app/Makefile")).toEqual({ path: "/srv/app/Makefile" });
116 });
117
118 it("reads line ranges and opens them at their first line", () => {
119 expect(parseMarkdownFileLink("/repo/src/http.ts:42-64")).toEqual({
120 path: "/repo/src/http.ts",
121 line: 42,
122 endLine: 64,
123 });
124 expect(parseMarkdownFileLink("/repo/src/http.ts#L42-L64")).toEqual({
125 path: "/repo/src/http.ts",
126 line: 42,
127 endLine: 64,
128 });
129 });
130
131 it("reads positions from suffixes and line anchors", () => {
132 expect(parseMarkdownFileLink("/Users/julius/project/src/main.ts#L42C7")).toEqual({
133 path: "/Users/julius/project/src/main.ts",
134 line: 42,
135 column: 7,
136 });
137 expect(parseMarkdownFileLink("file://server/share/src/main.ts#L42C7")).toMatchObject({
138 path: "\\\\server\\share\\src\\main.ts",
139 line: 42,
140 column: 7,
141 });
142 });
143});
144
145describe("workspaceRelativeFilePath", () => {
146 // regression: #12449 workspace root links resolve to "."
147 it.each([
148 ["/repo/project", "/repo/project", "."],
149 ["C:/", "c:/", "."],
150 ["/repo/project/a.ts", undefined, null],
151 ])("relates %s to %s", (path, workspaceRoot, relativePath) => {
152 expect(workspaceRelativeFilePath(path, workspaceRoot)).toBe(relativePath);
153 });
154});
155
156describe("extractMarkdownLinkHrefs", () => {
157 it.each([
158 ["[".repeat(32_000), []],
159 ["[a](<foo)bar> invalid [b](ok)", ["<foo", "ok"]],
160 ["[[[nested](src/main.ts) [unfinished", ["src/main.ts"]],
161 [`See ${serializeComposerFileLink("pages/posts/[id].tsx")} now`, ["pages/posts/%5Bid%5D.tsx"]],
162 ])("extracts links from incomplete and nested text", (markdown, hrefs) => {
163 expect(extractMarkdownLinkHrefs(markdown)).toEqual(hrefs);
164 });
165});
166
167describe("resolveMarkdownFileLinkTarget", () => {
168 it("resolves a relative folder with a trailing slash against cwd", () => {
169 expect(resolveMarkdownFileLinkTarget("patches/", "/repo/project")).toBe(
170 "/repo/project/patches/",
171 );
172 expect(resolveMarkdownFileLinkTarget("patches", "/repo/project")).toBeNull();
173 });
174
175 // regression: #33625 backslash-separated relative links open the workspace file
176 it.each([
177 ["src/Example.cs", "C:\\repo\\src\\Example.cs"],
178 ["src\\Example.cs", "C:\\repo\\src\\Example.cs"],
179 ["src%5CExample.cs", "C:\\repo\\src\\Example.cs"],
180 ["src\\Example.cs:3", "C:\\repo\\src\\Example.cs:3"],
181 ])("resolves the relative link %s against a windows cwd", (href, target) => {
182 expect(resolveMarkdownFileLinkTarget(href, "C:\\repo")).toBe(target);
183 });
184
185 it("keeps a backslash in a relative link that already names a file", () => {
186 expect(resolveMarkdownFileLinkTarget("./foo%5Cbar.ts", "/repo")).toBe("/repo/./foo\\bar.ts");
187 });
188
189 it("does not treat filename line references as external schemes", () => {
190 expect(resolveMarkdownFileLinkTarget("script.ts:10", "/Users/julius/project")).toBe(
191 "/Users/julius/project/script.ts:10",
192 );
193 });
194
195 it("maps #L line anchors to editor line suffixes", () => {
196 expect(resolveMarkdownFileLinkTarget("/Users/julius/project/src/main.ts#L42C7")).toBe(
197 "/Users/julius/project/src/main.ts:42:7",
198 );
199 });
200
201 it("ignores external urls", () => {
202 expect(resolveMarkdownFileLinkTarget("https://example.com/docs")).toBeNull();
203 expect(resolveMarkdownFileLinkTarget("//cdn.example.com/clip.mp4", "/workspace")).toBeNull();
204 });
205
206 it("does not double-decode file URLs", () => {
207 expect(resolveMarkdownFileLinkTarget("file:///Users/julius/project/file%2520name.md")).toBe(
208 "/Users/julius/project/file%20name.md",
209 );
210 });
211
212 it("links a network path only inside the project's share", () => {
213 const cwd = "\\\\server\\share";
214 expect(resolveMarkdownFileLinkTarget("file://server/share/workspace-image.svg", cwd)).toBe(
215 "\\\\server\\share\\workspace-image.svg",
216 );
217 // Opening these sent the Windows user's NTLM credentials to attacker.test.
218 for (const href of ["file://attacker.test/s/x.ts", "\\\\attacker.test\\s\\x.ts"]) {
219 expect(resolveMarkdownFileLinkTarget(href, cwd)).toBeNull();
220 }
221 });
222
223 it("keeps an encoded final space in the absolute target", () => {
224 expect(resolveMarkdownFileLinkTarget("/tmp/repo/file.ts%20", "/tmp/repo")).toBe(
225 "/tmp/repo/file.ts ",
226 );
227 });
228
229 it("normalizes slash-prefixed windows drive paths before resolving", () => {
230 expect(
231 resolveMarkdownFileLinkTarget(
232 "/D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx#L69",
233 ),
234 ).toBe("D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx:69");
235 });
236
237 it("decodes an encoded drive colon in a file uri before dropping its slash", () => {
238 expect(resolveMarkdownFileLinkTarget("file:///c%3A/Users/x/shot.png")).toBe(
239 "c:/Users/x/shot.png",
240 );
241 });
242});