Open History to see the last commit
1import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 collectWrappedTerminalLinkLine,
6 extractTerminalLinks,
7 isTerminalUrl,
8 type TerminalBufferLineLike,
9} from "./terminal-links";
10
11function createBufferLine(text: string, isWrapped = false): TerminalBufferLineLike {
12 return {
13 isWrapped,
14 translateToString: (trimRight = false) => (trimRight ? text.replace(/\s+$/u, "") : text),
15 };
16}
17
18describe("extractTerminalLinks", () => {
19 it.each([
20 String.raw`C:\dev\ai-test\dir 1`,
21 String.raw`C:\dev\ai-test\dir 1\test`,
22 "C:\\",
23 String.raw`\\server\my share\dir 1`,
24 "/home/Jane Doe/project",
25 String.raw`C:\dev\folder (draft)\name!`,
26 ])("keeps the full PowerShell prompt path %s in one link", (path) => {
27 expect(extractTerminalLinks(`PS ${path}> `)).toEqual([
28 { kind: "path", text: path, start: 3, end: 3 + path.length },
29 ]);
30 });
31
32 it("keeps links in a command separate from the PowerShell prompt path", () => {
33 const path = String.raw`C:\dev\dir 1\test`;
34 const prompt = `PS ${path}> `;
35 const url = "https://example.com";
36 expect(extractTerminalLinks(`${prompt}echo ${url}`)).toEqual([
37 { kind: "path", text: path, start: 3, end: 3 + path.length },
38 { kind: "url", text: url, start: prompt.length + 5, end: prompt.length + 5 + url.length },
39 ]);
40 });
41
42 it("does not extend ordinary output paths through trailing prose", () => {
43 const path = String.raw`C:\dev\project`;
44 expect(extractTerminalLinks(`see ${path} for details`)).toEqual([
45 { kind: "path", text: path, start: 4, end: 4 + path.length },
46 ]);
47 });
48
49 it.each([
50 String.raw`src\index.ts:12`,
51 String.raw`.\src\index.ts:12:7`,
52 String.raw`..\src\index.ts:12`,
53 String.raw`C:\Users\Jane\project\index.ts:12`,
54 String.raw`\\server\share\index.ts:12`,
55 ])("finds Windows path %s", (text) => {
56 const line = `error at ${text}: failed`;
57 expect(extractTerminalLinks(line)).toEqual([
58 { kind: "path", text, start: 9, end: 9 + text.length },
59 ]);
60 });
61
62 it.each(['"', "'", "`"])("finds paths containing spaces enclosed in %s", (quote) => {
63 const text = String.raw`C:\Users\Jane Doe\project\index.ts:12:7`;
64 expect(extractTerminalLinks(`see ${quote}${text}${quote}.`)).toEqual([
65 { kind: "path", text, start: 5, end: 5 + text.length },
66 ]);
67 const atText = "src/my contact@support.vue:12:3";
68 expect(extractTerminalLinks(`see ${quote}${atText}${quote}.`)).toEqual([
69 { kind: "path", text: atText, start: 5, end: 5 + atText.length },
70 ]);
71 });
72
73 it.each([
74 String.raw`src\my folder\index.ts`,
75 "./my folder/index.ts",
76 "/home/Jane Doe/index.ts",
77 "/home/Jane Doe/file)",
78 String.raw`\\server\my share\index.ts`,
79 ])("keeps positions outside quotes on %s", (path) => {
80 expect(extractTerminalLinks(`"${path}":12:7: error`)).toEqual([
81 { kind: "path", text: `${path}:12:7`, start: 1, end: path.length + 7 },
82 ]);
83 });
84
85 it("does not treat quoted prose as a path", () => {
86 expect(extractTerminalLinks('error: "something went wrong"')).toEqual([]);
87 expect(extractTerminalLinks('error: "something went wrong."')).toEqual([]);
88 });
89
90 it("keeps quoted paths and URLs separate without duplicate partial paths", () => {
91 const path = String.raw`C:\Jane Doe\index.ts:12`;
92 const url = "https://example.com/docs";
93 const line = `"${path}" ${url} src/index.ts`;
94 expect(extractTerminalLinks(line)).toEqual([
95 { kind: "path", text: path, start: 1, end: 1 + path.length },
96 { kind: "url", text: url, start: path.length + 3, end: path.length + 3 + url.length },
97 { kind: "path", text: "src/index.ts", start: line.length - 12, end: line.length },
98 ]);
99 });
100
101 it("classifies uppercase schemes as URLs at activation time too", () => {
102 expect(isTerminalUrl("HTTPS://example.com/docs")).toBe(true);
103 expect(isTerminalUrl("Http://example.com")).toBe(true);
104 expect(isTerminalUrl("src/components/main.ts")).toBe(false);
105 expect(isTerminalUrl("httpsdocs/readme.md")).toBe(false);
106 });
107
108 it("trims trailing punctuation from links", () => {
109 const line = "(https://example.com/docs), ./src/main.ts:12.";
110 expect(extractTerminalLinks(line)).toEqual([
111 {
112 kind: "url",
113 text: "https://example.com/docs",
114 start: 1,
115 end: 25,
116 },
117 {
118 kind: "path",
119 text: "./src/main.ts:12",
120 start: 28,
121 end: 44,
122 },
123 ]);
124 });
125
126 it.each([
127 "src/żółć.ts:12:7",
128 "żółć/main.ts:12",
129 String.raw`żółć\main.ts:12`,
130 "src/cafe\u0301.ts:12",
131 "src/𐐀.ts:12",
132 "src/app/[id]/page.tsx:3:10",
133 "src/routes/(app)/+page.svelte:12:4",
134 "src/routes/(app)/+page@.svelte:12:3",
135 "src/contact@support.vue:12:3",
136 String.raw`src\contact@support.ts:12:3`,
137 "src/équipe@home.ts:12:3",
138 "src/foo$contact@support.ts:12:3",
139 "node_modules/.pnpm/react@18.3.1/node_modules/react/index.js:12:5",
140 "app/routes/posts.$id.tsx:5",
141 ])("keeps the complete relative path %s", (text) => {
142 expect(extractTerminalLinks(`see ${text} for details`)).toEqual([
143 { kind: "path", text, start: 4, end: 4 + text.length },
144 ]);
145 });
146
147 it("finds Windows absolute paths with forward slashes", () => {
148 const line = "see C:/Users/someone/project/src/file.ts:42 for details";
149 const path = "C:/Users/someone/project/src/file.ts:42";
150 const start = line.indexOf(path);
151 expect(extractTerminalLinks(line)).toEqual([
152 {
153 kind: "path",
154 text: path,
155 start,
156 end: start + path.length,
157 },
158 ]);
159 });
160
161 it("keeps a trailing colon on URLs", () => {
162 expect(extractTerminalLinks("GET https://example.test/items/foo:")).toEqual([
163 { kind: "url", text: "https://example.test/items/foo:", start: 4, end: 35 },
164 ]);
165 });
166
167 it.each([
168 ["./main.go:10:5: undefined: x", "./main.go:10:5"],
169 ["/home/dev/app/src/main.c:10:5: error: expected ';'", "/home/dev/app/src/main.c:10:5"],
170 ["C:\\dev\\app\\src\\main.c:10:5: error: expected ';'", "C:\\dev\\app\\src\\main.c:10:5"],
171 ["wrote ./out/report.txt:", "./out/report.txt"],
172 ])("drops the colon that ends a compiler diagnostic location in %s", (line, text) => {
173 const start = line.indexOf(text);
174 expect(extractTerminalLinks(line)).toEqual([
175 { kind: "path", text, start, end: start + text.length },
176 ]);
177 });
178
179 it.each([
180 " GET /api/trpc/post.list,user.me?batch=1&input=%7B%220%22%3A%7B%22json%22%3Anull%7D%7D 200 in 35ms",
181 "GET /api?+debug 200",
182 'Started GET "/posts?page=2" for ::1',
183 "proxying api/trpc/post.list?batch=1 to the backend",
184 ])("does not turn a logged request route into an editor link: %s", (line) => {
185 expect(extractTerminalLinks(line)).toEqual([]);
186 });
187
188 it("keeps file and URL links before and after a logged request route", () => {
189 const before = "src/server.ts:12";
190 const after = "/app/bin/server:3:7";
191 const url = "https://example.com/api/users?id=42";
192 const line = `${before} GET /api/users?id=42 200 ${after} ${url}`;
193 const afterStart = line.indexOf(after);
194 const urlStart = line.indexOf(url);
195
196 expect(extractTerminalLinks(line)).toEqual([
197 { kind: "path", text: before, start: 0, end: before.length },
198 { kind: "path", text: after, start: afterStart, end: afterStart + after.length },
199 { kind: "url", text: url, start: urlStart, end: urlStart + url.length },
200 ]);
201 });
202
203 it.each([
204 "connected to redis://127.0.0.1:6379/0",
205 "connected to postgres://localhost:5432/app",
206 "uploaded s3://bucket/reports/today.csv",
207 " at main (file:///Users/jane/app/src/index.mjs:3:7)",
208 "+redis://localhost:6379/0",
209 "-redis://localhost:6379/0",
210 "git clone git@github.com:acme/widgets.git",
211 "remote: git@gitlab.com:group/sub/project.git",
212 ])("does not turn part of a non-web URL into an editor link: %s", (line) => {
213 expect(extractTerminalLinks(line)).toEqual([]);
214 });
215
216 it("keeps file links beside a non-web URL", () => {
217 const line = "src/db.ts:4 redis://localhost:6379/0 C:/app/src/db.ts:9";
218 expect(extractTerminalLinks(line)).toEqual([
219 { kind: "path", text: "src/db.ts:4", start: 0, end: 11 },
220 { kind: "path", text: "C:/app/src/db.ts:9", start: 37, end: 55 },
221 ]);
222 });
223
224 it("keeps a question mark inside a filesystem path", () => {
225 const path = "/tmp/report?draft";
226 expect(extractTerminalLinks(`wrote ${path}`)).toEqual([
227 { kind: "path", text: path, start: 6, end: 6 + path.length },
228 ]);
229 });
230});
231
232describe("collectWrappedTerminalLinkLine", () => {
233 it("reconstructs a wrapped line from any physical row", () => {
234 const firstSegment = "see https://example.com/a";
235 const secondSegment = "/bc?x=1";
236 const lines = [
237 createBufferLine("prompt> "),
238 createBufferLine(firstSegment),
239 createBufferLine(secondSegment, true),
240 createBufferLine("done"),
241 ];
242
243 const fromFirstRow = collectWrappedTerminalLinkLine(2, (index) => lines[index]);
244 const fromWrappedRow = collectWrappedTerminalLinkLine(3, (index) => lines[index]);
245
246 expect(fromFirstRow).toEqual({
247 text: `${firstSegment}${secondSegment}`,
248 segments: [
249 {
250 bufferLineNumber: 2,
251 text: firstSegment,
252 startIndex: 0,
253 endIndex: firstSegment.length,
254 },
255 {
256 bufferLineNumber: 3,
257 text: secondSegment,
258 startIndex: firstSegment.length,
259 endIndex: firstSegment.length + secondSegment.length,
260 },
261 ],
262 });
263 expect(fromWrappedRow).toEqual(fromFirstRow);
264 });
265
266 it("preserves trailing spaces on continued segments for downstream offsets", () => {
267 const firstSegment = "prefix ";
268 const secondSegment = "https://example.com/path";
269 const lines = [createBufferLine(firstSegment), createBufferLine(secondSegment, true)];
270
271 const wrappedLine = collectWrappedTerminalLinkLine(2, (index) => lines[index]);
272
273 expect(wrappedLine?.text).toBe(`${firstSegment}${secondSegment}`);
274 expect(extractTerminalLinks(wrappedLine?.text ?? "")).toEqual([
275 {
276 kind: "url",
277 text: secondSegment,
278 start: firstSegment.length,
279 end: firstSegment.length + secondSegment.length,
280 },
281 ]);
282 });
283});
284
285describe("resolvePathLinkTarget", () => {
286 it("resolves a quoted terminal link against a Windows environment", () => {
287 const [link] = extractTerminalLinks(String.raw`error at "src\my folder\index.ts":12:7`);
288 expect(resolvePathLinkTarget(link?.text ?? "", String.raw`C:\Users\Jane Doe\project`)).toBe(
289 String.raw`C:\Users\Jane Doe\project\src\my folder\index.ts:12:7`,
290 );
291 });
292
293 // regression: #13408 terminal links kept a trailing colon
294 it("keeps the line and column of a compiler diagnostic", () => {
295 const [link] = extractTerminalLinks("/Users/julius/project/main.c:10:5: error: expected ';'");
296 expect(resolvePathLinkTarget(link?.text ?? "", "/Users/julius/project")).toBe(
297 "/Users/julius/project/main.c:10:5",
298 );
299 });
300});