Skip to content
Source
Sign inSign up

terminal-links.test.ts

314 lines11.5 KB

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