1import { describe, expect, it, vi } from "vite-plus/test";
2import {
3 buildFileLinkParentSuffixByPath,
4 fileLinkLabel,
5 parseFileUrlHref,
6 splitFilePathPosition,
7 resolvePathLinkTarget,
8} from "./fileLinks.ts";
9
10describe("buildFileLinkParentSuffixByPath", () => {
11 it.each([
12 {
13 paths: ["", "/", "index.ts", "/repo/src/index.ts"],
14 suffixes: [["/repo/src/index.ts", "repo/src"]],
15 },
16 { paths: ["/repo/a.ts", "/repo/b.ts", "/repo/a.ts"], suffixes: [] },
17 {
18 paths: ["/repo/src/index.ts", "/repo/test/index.ts"],
19 suffixes: [
20 ["/repo/src/index.ts", "repo/src"],
21 ["/repo/test/index.ts", "repo/test"],
22 ],
23 },
24 {
25 paths: ["/repo/a/src/index.ts", "/repo/b/src/index.ts"],
26 suffixes: [
27 ["/repo/a/src/index.ts", "a/src"],
28 ["/repo/b/src/index.ts", "b/src"],
29 ],
30 },
31 {
32 paths: ["/a/pkg/src/index.ts", "/b/pkg/src/index.ts"],
33 suffixes: [
34 ["/a/pkg/src/index.ts", "a/pkg/src"],
35 ["/b/pkg/src/index.ts", "b/pkg/src"],
36 ],
37 },
38 {
39 paths: ["src/index.ts", "/repo/src/index.ts"],
40 suffixes: [
41 ["src/index.ts", "src"],
42 ["/repo/src/index.ts", "repo/src"],
43 ],
44 },
45 {
46 paths: ["/repo//src/index.ts", "/repo/src/index.ts"],
47 suffixes: [
48 ["/repo//src/index.ts", "repo/src"],
49 ["/repo/src/index.ts", "repo/src"],
50 ],
51 },
52 { paths: [String.raw`C:\repo\src\index.ts`, "C:/repo/src/index.ts"], suffixes: [] },
53 {
54 paths: [String.raw`C:\repo\src\index.ts`, String.raw`D:\repo\src\index.ts`],
55 suffixes: [
56 ["C:/repo/src/index.ts", "C:/repo/src"],
57 ["D:/repo/src/index.ts", "D:/repo/src"],
58 ],
59 },
60 {
61 paths: [String.raw`\\one\share\src\index.ts`, String.raw`\\two\share\src\index.ts`],
62 suffixes: [
63 ["//one/share/src/index.ts", "one/share/src"],
64 ["//two/share/src/index.ts", "two/share/src"],
65 ],
66 },
67 ])("preserves suffixes for $paths", ({ paths, suffixes }) => {
68 expect([...buildFileLinkParentSuffixByPath(paths)]).toEqual(suffixes);
69 });
70
71 it("bounds suffix construction by path count for a same-basename group", () => {
72 const paths = Array.from({ length: 128 }, (_, i) => `/repo/packages/pkg-${i}/src/index.ts`);
73 using joins = vi.spyOn(Array.prototype, "join");
74 const result = buildFileLinkParentSuffixByPath(paths);
75 const suffixConstructions = joins.mock.calls.length;
76
77 expect(result).toEqual(new Map(paths.map((path, i) => [path, `pkg-${i}/src`])));
78 // Count work instead of elapsed time so a pairwise scan fails on every machine.
79 expect(suffixConstructions).toBeLessThan(paths.length * 20);
80 });
81});
82
83describe("parseFileUrlHref", () => {
84 it.each([
85 [
86 "file:///D:/Programme/t3code/OpenInPicker.tsx#L69",
87 "D:/Programme/t3code/OpenInPicker.tsx",
88 "#L69",
89 ],
90 ["file://server/share/workspace-image.svg", "\\\\server\\share\\workspace-image.svg", ""],
91 ["file://localhost/home/me/notes.md", "/home/me/notes.md", ""],
92 ])("parses %s", (href, path, hash) => {
93 expect(parseFileUrlHref(href)).toEqual({ path, hash });
94 });
95
96 it("keeps percent-encoding so the caller decodes once", () => {
97 expect(parseFileUrlHref("file:///Users/julius/project/file%2520name.md")?.path).toBe(
98 "/Users/julius/project/file%2520name.md",
99 );
100 expect(parseFileUrlHref("file:///c%3A/Users/x/shot.png")?.path).toBe("/c%3A/Users/x/shot.png");
101 });
102
103 it.each(["https://example.com/a.ts", "file://%", "/Users/julius/a.ts"])("rejects %s", (href) => {
104 expect(parseFileUrlHref(href)).toBeNull();
105 });
106});
107
108describe("splitFilePathPosition", () => {
109 it.each([
110 ["src/main.ts:12:5", "", { path: "src/main.ts", line: 12, column: 5 }],
111 ["src/main.ts", "#L18C2", { path: "src/main.ts", line: 18, column: 2 }],
112 ["src/main.ts:3", "#L18C2", { path: "src/main.ts", line: 3 }],
113 ["src/main.ts:0", "", { path: "src/main.ts" }],
114 ["src/main.ts:42-64", "", { path: "src/main.ts", line: 42, endLine: 64 }],
115 ["src/main.ts", "#L42-L64", { path: "src/main.ts", line: 42, endLine: 64 }],
116 ["src/main.ts:42-42", "", { path: "src/main.ts", line: 42 }],
117 ])("splits %s%s", (path, hash, expected) => {
118 expect(splitFilePathPosition(path, hash)).toEqual(expected);
119 });
120});
121
122describe("resolvePathLinkTarget", () => {
123 // regression: #21199 Windows terminal file links resolve against a Windows cwd
124 it.each([
125 [
126 String.raw`src\index.ts:12:7`,
127 String.raw`C:\Users\Jane Doe\project`,
128 String.raw`C:\Users\Jane Doe\project\src\index.ts:12:7`,
129 ],
130 [
131 String.raw`.\src\index.ts:12`,
132 String.raw`\\server\share\project`,
133 String.raw`\\server\share\project\.\src\index.ts:12`,
134 ],
135 ["src/index.ts:12", "/home/jane/project", "/home/jane/project/src/index.ts:12"],
136 ])("resolves %s using the connected environment cwd %s", (path, cwd, expected) => {
137 expect(resolvePathLinkTarget(path, cwd)).toBe(expected);
138 });
139
140 it("keeps Windows absolute paths with forward slashes unchanged", () => {
141 expect(
142 resolvePathLinkTarget("C:/Users/julius/project/src/main.ts:12", "C:\\Users\\julius\\project"),
143 ).toBe("C:/Users/julius/project/src/main.ts:12");
144 });
145
146 // regression: #19627 Windows home paths match case-insensitively
147 it.each([
148 ["C:\\users\\alice\\project", "C:\\users\\alice\\notes.md:3"],
149 ["C:/uSeRs/alice/project", "C:/uSeRs/alice\\notes.md:3"],
150 ])("resolves home paths against the Windows cwd %s", (cwd, expected) => {
151 expect(resolvePathLinkTarget("~/notes.md:3", cwd)).toBe(expected);
152 });
153 it("keeps home paths unchanged when no supported home can be inferred", () => {
154 for (const cwd of ["C:/work/project", "/Home/alice/project", "/users/alice/project"]) {
155 expect(resolvePathLinkTarget("~/notes.md:3", cwd)).toBe("~/notes.md:3");
156 }
157 });
158});
159
160describe("fileLinkLabel", () => {
161 it.each([
162 [{ path: "/repo/src/http.ts", line: 42, column: 7 }, "http.ts · L42:C7"],
163 [{ path: "/repo/src/http.ts", line: 42, endLine: 64 }, "http.ts · L42–64"],
164 ])("labels %o", (file, label) => {
165 expect(fileLinkLabel(file, new Map())).toBe(label);
166 });
167});