Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLITerminalLinkProvider.spec.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { beforeEach, describe, expect, it, vi } from 'vitest';
7
import type { CancellationToken, Terminal, TerminalLinkContext, Uri } from 'vscode';
8
import { TestLogService } from '../../../../platform/testing/common/testLogService';
9
import { CopilotCLITerminalLinkProvider } from '../copilotCLITerminalLinkProvider';
10
11
// --- Mocks ---------------------------------------------------------------
12
13
const mockStat = vi.hoisted(() => vi.fn());
14
const mockReadDirectory = vi.hoisted(() => vi.fn());
15
const mockShowTextDocument = vi.hoisted(() => vi.fn());
16
const mockShowQuickPick = vi.hoisted(() => vi.fn());
17
const mockWorkspaceFolders = vi.hoisted(() => ({ value: undefined as { uri: { fsPath: string; scheme: string } }[] | undefined }));
18
19
vi.mock('vscode', () => ({
20
Uri: {
21
file: (path: string) => ({
22
fsPath: path,
23
scheme: 'file',
24
toString: (skipEncoding?: boolean) => skipEncoding ? `file://${path}` : `file://${encodeURI(path)}`,
25
}),
26
joinPath: (base: { fsPath: string; scheme: string }, ...segments: string[]) => {
27
const joined = [base.fsPath, ...segments].join('/');
28
return {
29
fsPath: joined,
30
scheme: base.scheme,
31
toString: (skipEncoding?: boolean) => skipEncoding ? `file://${joined}` : `file://${encodeURI(joined)}`,
32
};
33
},
34
},
35
Range: class Range {
36
constructor(
37
public readonly startLine: number,
38
public readonly startCharacter: number,
39
public readonly endLine: number,
40
public readonly endCharacter: number,
41
) { }
42
},
43
window: {
44
showTextDocument: mockShowTextDocument,
45
showQuickPick: mockShowQuickPick,
46
},
47
l10n: {
48
t: (message: string, ...args: string[]) => message.replace(/\{(\d+)\}/g, (_, i) => args[Number(i)]),
49
},
50
FileType: {
51
Unknown: 0,
52
File: 1,
53
Directory: 2,
54
SymbolicLink: 64,
55
},
56
workspace: {
57
fs: {
58
stat: mockStat,
59
readDirectory: mockReadDirectory,
60
},
61
get workspaceFolders() {
62
return mockWorkspaceFolders.value;
63
},
64
},
65
}));
66
67
vi.mock('os', () => ({
68
homedir: () => '/Users/anthonykim',
69
}));
70
71
// --- Helpers -------------------------------------------------------------
72
73
const SESSION_UUID = 'ak1234fe-ae47-4c68-8123-f4adef123123';
74
const SESSION_DIR = `/Users/anthonykim/.copilot/session-state/${SESSION_UUID}`;
75
76
class MockTerminal {
77
readonly processId = Promise.resolve(123);
78
readonly name = 'test';
79
readonly creationOptions = {};
80
readonly exitStatus = undefined;
81
readonly state = { isInteractedWith: false, shell: undefined };
82
readonly selection = undefined;
83
readonly shellIntegration = undefined;
84
sendText() { }
85
show() { }
86
hide() { }
87
dispose() { }
88
}
89
90
function makeTerminal(): Terminal {
91
return new MockTerminal() as Terminal;
92
}
93
94
function makeContext(line: string, terminal: Terminal): TerminalLinkContext {
95
return { line, terminal };
96
}
97
98
function makeToken(): CancellationToken {
99
return { isCancellationRequested: false, onCancellationRequested: vi.fn() } as CancellationToken;
100
}
101
102
function makeCancelledToken(): CancellationToken {
103
return { isCancellationRequested: true, onCancellationRequested: vi.fn() } as CancellationToken;
104
}
105
106
// --- Tests ---------------------------------------------------------------
107
108
describe('CopilotCLITerminalLinkProvider', () => {
109
let provider: CopilotCLITerminalLinkProvider;
110
let terminal: Terminal;
111
let sessionDirUri: Uri;
112
113
beforeEach(async () => {
114
vi.clearAllMocks();
115
mockWorkspaceFolders.value = undefined;
116
mockReadDirectory.mockResolvedValue([]);
117
mockShowQuickPick.mockResolvedValue(undefined);
118
const vscode = await import('vscode');
119
120
provider = new CopilotCLITerminalLinkProvider(new TestLogService());
121
terminal = makeTerminal();
122
sessionDirUri = vscode.Uri.file(SESSION_DIR);
123
124
provider.registerTerminal(terminal);
125
provider.setSessionDir(terminal, sessionDirUri);
126
127
// By default, stat succeeds (file exists).
128
mockStat.mockResolvedValue({ type: 1 });
129
});
130
131
describe('relative paths', () => {
132
it('should detect files/sample-summary.md', async () => {
133
const links = await provider.provideTerminalLinks(
134
makeContext(' Relative: files/sample-summary.md', terminal),
135
makeToken(),
136
);
137
expect(links).toHaveLength(1);
138
expect(links[0].pathText).toBe('files/sample-summary.md');
139
expect(links[0].uri?.fsPath).toBe(`${SESSION_DIR}/files/sample-summary.md`);
140
});
141
142
it('should detect bare files/sample-summary.md at start of line', async () => {
143
const links = await provider.provideTerminalLinks(
144
makeContext('files/sample-summary.md', terminal),
145
makeToken(),
146
);
147
expect(links).toHaveLength(1);
148
expect(links[0].pathText).toBe('files/sample-summary.md');
149
});
150
151
it('should detect dot-prefixed ./files/sample-summary.md', async () => {
152
const links = await provider.provideTerminalLinks(
153
makeContext('./files/sample-summary.md', terminal),
154
makeToken(),
155
);
156
expect(links).toHaveLength(1);
157
expect(links[0].pathText).toBe('./files/sample-summary.md');
158
});
159
160
it('should detect standalone plan.md in a sentence', async () => {
161
const links = await provider.provideTerminalLinks(
162
makeContext('Created plan.md with next steps', terminal),
163
makeToken(),
164
);
165
expect(links).toHaveLength(1);
166
expect(links[0].pathText).toBe('plan.md');
167
expect(links[0].uri?.fsPath).toBe(`${SESSION_DIR}/plan.md`);
168
});
169
170
it('should detect standalone plan.md with :line:col suffix', async () => {
171
const links = await provider.provideTerminalLinks(
172
makeContext('See plan.md:12:3 for details', terminal),
173
makeToken(),
174
);
175
expect(links).toHaveLength(1);
176
expect(links[0].pathText).toBe('plan.md');
177
expect(links[0].line).toBe(12);
178
expect(links[0].col).toBe(3);
179
});
180
181
it('should detect standalone filenames with 1-character extensions', async () => {
182
const links = await provider.provideTerminalLinks(
183
makeContext('Compile main.c next', terminal),
184
makeToken(),
185
);
186
expect(links).toHaveLength(1);
187
expect(links[0].pathText).toBe('main.c');
188
});
189
190
it('should not detect numeric tokens like version 1.2', async () => {
191
const links = await provider.provideTerminalLinks(
192
makeContext('Version 1.2 is installed', terminal),
193
makeToken(),
194
);
195
expect(links).toHaveLength(0);
196
});
197
198
it('should resolve bare filename to files/<name> when root file does not exist', async () => {
199
mockStat.mockImplementation((uri: { fsPath: string }) => {
200
if (uri.fsPath === `${SESSION_DIR}/todo.md`) {
201
return Promise.reject(new Error('not found'));
202
}
203
if (uri.fsPath === `${SESSION_DIR}/files/todo.md`) {
204
return Promise.resolve({ type: 1 });
205
}
206
return Promise.reject(new Error('not found'));
207
});
208
209
const links = await provider.provideTerminalLinks(
210
makeContext('| todo.md | /Users/anthonykim/.copilot/session-state/id/files/todo.md | files/todo.md |', terminal),
211
makeToken(),
212
);
213
214
const todoLink = links.find(link => link.pathText === 'todo.md');
215
expect(todoLink).toBeDefined();
216
expect(todoLink?.uri?.fsPath).toBe(`${SESSION_DIR}/files/todo.md`);
217
});
218
219
it('should resolve slash paths relative to files/ when session-root path does not exist', async () => {
220
mockStat.mockImplementation((uri: { fsPath: string }) => {
221
if (uri.fsPath === `${SESSION_DIR}/anotherFolderNamehere/thenyourfilehere.txt`) {
222
return Promise.reject(new Error('not found'));
223
}
224
if (uri.fsPath === `${SESSION_DIR}/files/anotherFolderNamehere/thenyourfilehere.txt`) {
225
return Promise.resolve({ type: 1 });
226
}
227
return Promise.reject(new Error('not found'));
228
});
229
230
const links = await provider.provideTerminalLinks(
231
makeContext('anotherFolderNamehere/thenyourfilehere.txt', terminal),
232
makeToken(),
233
);
234
235
expect(links).toHaveLength(1);
236
expect(links[0].uri?.fsPath).toBe(`${SESSION_DIR}/files/anotherFolderNamehere/thenyourfilehere.txt`);
237
});
238
239
it('should resolve bare filename in nested session subdirectories', async () => {
240
mockStat.mockImplementation((uri: { fsPath: string }) => {
241
if (uri.fsPath === `${SESSION_DIR}/001-created-session-files-and-path.md`) {
242
return Promise.reject(new Error('not found'));
243
}
244
if (uri.fsPath === `${SESSION_DIR}/files/001-created-session-files-and-path.md`) {
245
return Promise.reject(new Error('not found'));
246
}
247
return Promise.reject(new Error('not found'));
248
});
249
250
mockReadDirectory.mockImplementation((uri: { fsPath: string }) => {
251
if (uri.fsPath === SESSION_DIR) {
252
return Promise.resolve([
253
['checkpoints', 2],
254
]);
255
}
256
257
if (uri.fsPath === `${SESSION_DIR}/checkpoints`) {
258
return Promise.resolve([
259
['001-created-session-files-and-path.md', 1],
260
]);
261
}
262
263
return Promise.resolve([]);
264
});
265
266
const links = await provider.provideTerminalLinks(
267
makeContext('001-created-session-files-and-path.md', terminal),
268
makeToken(),
269
);
270
271
expect(links).toHaveLength(1);
272
expect(links[0].uri?.fsPath).toBe(`${SESSION_DIR}/checkpoints/001-created-session-files-and-path.md`);
273
});
274
});
275
276
describe('tilde paths', () => {
277
it('should expand ~/.copilot/session-state/.../files/sample-summary.md', async () => {
278
const links = await provider.provideTerminalLinks(
279
makeContext(` Absolute: ~/.copilot/session-state/${SESSION_UUID}/files/sample-summary.md`, terminal),
280
makeToken(),
281
);
282
expect(links).toHaveLength(1);
283
expect(links[0].pathText).toContain('~/.copilot/session-state');
284
expect(links[0].uri?.fsPath).toBe(`/Users/anthonykim/.copilot/session-state/${SESSION_UUID}/files/sample-summary.md`);
285
});
286
});
287
288
describe('absolute paths', () => {
289
it('should skip /Users/anthonykim/.copilot/.../files/sample-summary.md', async () => {
290
const links = await provider.provideTerminalLinks(
291
makeContext(` /Users/anthonykim/.copilot/session-state/${SESSION_UUID}/files/sample-summary.md`, terminal),
292
makeToken(),
293
);
294
// Absolute paths are skipped — the built-in detector handles them.
295
expect(links).toHaveLength(0);
296
});
297
});
298
299
describe('trailing punctuation', () => {
300
it('should strip trailing period from files/sample-summary.md.', async () => {
301
const links = await provider.provideTerminalLinks(
302
makeContext('file at files/sample-summary.md.', terminal),
303
makeToken(),
304
);
305
expect(links).toHaveLength(1);
306
expect(links[0].pathText).toBe('files/sample-summary.md');
307
});
308
309
it('should strip multiple trailing dots', async () => {
310
const links = await provider.provideTerminalLinks(
311
makeContext('files/sample-summary.md...', terminal),
312
makeToken(),
313
);
314
expect(links).toHaveLength(1);
315
expect(links[0].pathText).toBe('files/sample-summary.md');
316
});
317
});
318
319
describe('line and column suffixes', () => {
320
it('should parse :line:col suffix', async () => {
321
const links = await provider.provideTerminalLinks(
322
makeContext('src/foo/bar.ts:10:5', terminal),
323
makeToken(),
324
);
325
expect(links).toHaveLength(1);
326
expect(links[0].pathText).toBe('src/foo/bar.ts');
327
expect(links[0].line).toBe(10);
328
expect(links[0].col).toBe(5);
329
});
330
331
it('should parse (line, col) suffix', async () => {
332
const links = await provider.provideTerminalLinks(
333
makeContext('src/foo/bar.ts(42, 7)', terminal),
334
makeToken(),
335
);
336
expect(links).toHaveLength(1);
337
expect(links[0].line).toBe(42);
338
expect(links[0].col).toBe(7);
339
});
340
});
341
342
describe('URLs', () => {
343
it('should skip https:// URLs', async () => {
344
const links = await provider.provideTerminalLinks(
345
makeContext('Visit https://example.com/path for info', terminal),
346
makeToken(),
347
);
348
expect(links).toHaveLength(0);
349
});
350
});
351
352
describe('guards', () => {
353
it('should return empty for blank lines', async () => {
354
const links = await provider.provideTerminalLinks(
355
makeContext(' ', terminal),
356
makeToken(),
357
);
358
expect(links).toHaveLength(0);
359
});
360
361
it('should return empty for lines over 2000 chars', async () => {
362
const longLine = 'files/summary.md ' + 'x'.repeat(2000);
363
const links = await provider.provideTerminalLinks(
364
makeContext(longLine, terminal),
365
makeToken(),
366
);
367
expect(links).toHaveLength(0);
368
});
369
370
it('should cap links at 10 per line', async () => {
371
const paths = Array.from({ length: 15 }, (_, i) => `dir/file${i}.ts`).join(' ');
372
const links = await provider.provideTerminalLinks(
373
makeContext(paths, terminal),
374
makeToken(),
375
);
376
expect(links.length).toBeLessThanOrEqual(10);
377
});
378
379
it('should skip unregistered terminals with no session dirs', async () => {
380
const unknownTerminal = makeTerminal();
381
const links = await provider.provideTerminalLinks(
382
makeContext('files/summary.md', unknownTerminal),
383
makeToken(),
384
);
385
expect(links).toHaveLength(0);
386
});
387
388
it('should stop processing when cancellation is requested before path resolution', async () => {
389
const links = await provider.provideTerminalLinks(
390
makeContext('files/sample-summary.md', terminal),
391
makeCancelledToken(),
392
);
393
expect(links).toHaveLength(0);
394
expect(mockStat).not.toHaveBeenCalled();
395
expect(mockReadDirectory).not.toHaveBeenCalled();
396
});
397
});
398
399
describe('cancellation', () => {
400
it('should stop nested lookup when token is cancelled during traversal', async () => {
401
const token = makeToken();
402
const cancellationState = { cancelled: false };
403
Object.defineProperty(token, 'isCancellationRequested', {
404
get: () => cancellationState.cancelled,
405
});
406
407
mockStat.mockRejectedValue(new Error('not found'));
408
mockReadDirectory.mockImplementation((uri: { fsPath: string }) => {
409
if (uri.fsPath === SESSION_DIR) {
410
cancellationState.cancelled = true;
411
return Promise.resolve([
412
['checkpoints', 2],
413
]);
414
}
415
416
return Promise.resolve([]);
417
});
418
419
const links = await provider.provideTerminalLinks(
420
makeContext('001-created-session-files-and-path.md', terminal),
421
token,
422
);
423
424
expect(links).toHaveLength(0);
425
expect(mockReadDirectory).toHaveBeenCalledTimes(1);
426
});
427
});
428
429
describe('handleTerminalLink', () => {
430
it('should prompt when multiple targets exist and open selected target', async () => {
431
const vscode = await import('vscode');
432
mockWorkspaceFolders.value = [{ uri: vscode.Uri.file('/workspace/project') }];
433
434
mockStat.mockImplementation((uri: { fsPath: string }) => {
435
if (uri.fsPath === `${SESSION_DIR}/plan.md`) {
436
return Promise.resolve({ type: 1 });
437
}
438
if (uri.fsPath === '/workspace/project/plan.md') {
439
return Promise.resolve({ type: 1 });
440
}
441
return Promise.reject(new Error('not found'));
442
});
443
444
mockShowQuickPick.mockImplementation(async (items: Array<{ uri: { fsPath: string }; label: string; description?: string; detail?: string }>) => {
445
expect(items).toHaveLength(2);
446
expect(items[0].label).toBe('plan.md');
447
expect(items[1].label).toBe('plan.md');
448
expect(items.some(item => item.description === 'session-state/ak1234fe-ae47-4c68-8123-f4adef123123')).toBe(true);
449
expect(items.some(item => item.description === 'workspace')).toBe(true);
450
expect(items.every(item => item.detail === undefined)).toBe(true);
451
return items.find(item => item.uri.fsPath === '/workspace/project/plan.md');
452
});
453
454
const links = await provider.provideTerminalLinks(
455
makeContext('plan.md', terminal),
456
makeToken(),
457
);
458
459
expect(links).toHaveLength(1);
460
await provider.handleTerminalLink(links[0]);
461
expect(mockShowQuickPick).toHaveBeenCalled();
462
expect(mockShowTextDocument).toHaveBeenCalled();
463
expect(mockShowTextDocument.mock.calls[0][0].fsPath).toBe('/workspace/project/plan.md');
464
});
465
466
it('should not open when quick pick is cancelled', async () => {
467
const vscode = await import('vscode');
468
mockWorkspaceFolders.value = [{ uri: vscode.Uri.file('/workspace/project') }];
469
470
mockStat.mockImplementation((uri: { fsPath: string }) => {
471
if (uri.fsPath === `${SESSION_DIR}/plan.md`) {
472
return Promise.resolve({ type: 1 });
473
}
474
if (uri.fsPath === '/workspace/project/plan.md') {
475
return Promise.resolve({ type: 1 });
476
}
477
return Promise.reject(new Error('not found'));
478
});
479
480
mockShowQuickPick.mockResolvedValue(undefined);
481
482
const links = await provider.provideTerminalLinks(
483
makeContext('plan.md', terminal),
484
makeToken(),
485
);
486
487
expect(links).toHaveLength(1);
488
await provider.handleTerminalLink(links[0]);
489
expect(mockShowQuickPick).toHaveBeenCalled();
490
expect(mockShowTextDocument).not.toHaveBeenCalled();
491
});
492
493
it('should open directly without prompting when only one target exists', async () => {
494
const vscode = await import('vscode');
495
mockWorkspaceFolders.value = [{ uri: vscode.Uri.file('/workspace/project') }];
496
497
mockStat.mockImplementation((uri: { fsPath: string }) => {
498
if (uri.fsPath === `${SESSION_DIR}/plan.md`) {
499
return Promise.resolve({ type: 1 });
500
}
501
return Promise.reject(new Error('not found'));
502
});
503
504
const links = await provider.provideTerminalLinks(
505
makeContext('plan.md', terminal),
506
makeToken(),
507
);
508
509
expect(links).toHaveLength(1);
510
await provider.handleTerminalLink(links[0]);
511
expect(mockShowQuickPick).not.toHaveBeenCalled();
512
expect(mockShowTextDocument).toHaveBeenCalledTimes(1);
513
expect(mockShowTextDocument.mock.calls[0][0].fsPath).toBe(`${SESSION_DIR}/plan.md`);
514
});
515
});
516
517
describe('session dir resolution', () => {
518
it('should resolve via session dir resolver when no cached dir', async () => {
519
const vscode = await import('vscode');
520
const freshTerminal = makeTerminal();
521
provider.registerTerminal(freshTerminal);
522
provider.setSessionDirResolver(async _t => [vscode.Uri.file(SESSION_DIR)]);
523
524
const links = await provider.provideTerminalLinks(
525
makeContext('files/demo.md', freshTerminal),
526
makeToken(),
527
);
528
expect(links).toHaveLength(1);
529
expect(links[0].uri?.fsPath).toBe(`${SESSION_DIR}/files/demo.md`);
530
});
531
532
it('should fall back to workspace folders when file not in session dir', async () => {
533
const vscode = await import('vscode');
534
// stat fails for session dir, succeeds for workspace
535
mockStat.mockRejectedValueOnce(new Error('not found'))
536
.mockResolvedValueOnce({ type: 1 });
537
538
mockWorkspaceFolders.value = [{ uri: vscode.Uri.file('/workspace/project') }];
539
540
const links = await provider.provideTerminalLinks(
541
makeContext('src/index.ts', terminal),
542
makeToken(),
543
);
544
expect(links).toHaveLength(1);
545
expect(links[0].uri?.fsPath).toBe('/workspace/project/src/index.ts');
546
});
547
548
// Regression test for https://github.com/microsoft/vscode/issues/301594
549
// Resolver first returned an unrelated session (only one tracked at the
550
// time).
551
it('should not cache stale resolver result when session tracker learns the real session later', async () => {
552
const vscode = await import('vscode');
553
const freshTerminal = makeTerminal();
554
provider.registerTerminal(freshTerminal);
555
556
const staleDir = '/Users/anthonykim/.copilot/session-state/31830812-0221-4389-b6bf-b1d33fe556e2';
557
const realDir = '/Users/anthonykim/.copilot/session-state/278b1a81-eb86-4a81-bff0-ba68035c1b48';
558
559
// sessionTracker initially only knows about an unrelated session,
560
// then later also learns the real one for this terminal.
561
let call = 0;
562
provider.setSessionDirResolver(async _t => {
563
call++;
564
return call === 1
565
? [vscode.Uri.file(staleDir)]
566
: [vscode.Uri.file(staleDir), vscode.Uri.file(realDir)];
567
});
568
569
// files/file-01.md only exists under the real session dir.
570
mockStat.mockImplementation((uri: { fsPath: string }) => {
571
if (uri.fsPath === `${realDir}/files/file-01.md`) {
572
return Promise.resolve({ type: 1 });
573
}
574
return Promise.reject(new Error('not found'));
575
});
576
577
// First hover: only the stale dir is known, file isn't found there.
578
const first = await provider.provideTerminalLinks(
579
makeContext('files/file-01.md', freshTerminal),
580
makeToken(),
581
);
582
expect(first).toHaveLength(0);
583
584
// Second hover: resolver must be consulted again and pick up the
585
// real dir. Previously this returned the stale dir from the cache.
586
const second = await provider.provideTerminalLinks(
587
makeContext('files/file-01.md', freshTerminal),
588
makeToken(),
589
);
590
expect(call).toBe(2);
591
expect(second).toHaveLength(1);
592
expect(second[0].uri?.fsPath).toBe(`${realDir}/files/file-01.md`);
593
});
594
595
it('should still consult resolver even when an explicit session dir is cached', async () => {
596
const vscode = await import('vscode');
597
const freshTerminal = makeTerminal();
598
provider.registerTerminal(freshTerminal);
599
600
const oldDir = '/Users/anthonykim/.copilot/session-state/old';
601
const newDir = '/Users/anthonykim/.copilot/session-state/new';
602
603
// Explicitly cached (e.g. resumed session) but user then started a
604
// new `copilot` run in the same terminal.
605
provider.setSessionDir(freshTerminal, vscode.Uri.file(oldDir));
606
provider.setSessionDirResolver(async _t => [vscode.Uri.file(newDir)]);
607
608
mockStat.mockImplementation((uri: { fsPath: string }) => {
609
if (uri.fsPath === `${newDir}/files/demo.md`) {
610
return Promise.resolve({ type: 1 });
611
}
612
return Promise.reject(new Error('not found'));
613
});
614
615
const links = await provider.provideTerminalLinks(
616
makeContext('files/demo.md', freshTerminal),
617
makeToken(),
618
);
619
expect(links).toHaveLength(1);
620
expect(links[0].uri?.fsPath).toBe(`${newDir}/files/demo.md`);
621
});
622
623
// Scenario 1: User quits session X and starts session Y in the SAME
624
// terminal. The stale cached dir from X's resume should not shadow Y's
625
// active session dir, even when both have a file with the same name.
626
it('should prefer active resolver dir over stale cached dir when file exists in both', async () => {
627
const vscode = await import('vscode');
628
const freshTerminal = makeTerminal();
629
provider.registerTerminal(freshTerminal);
630
631
const staleDir = '/Users/anthonykim/.copilot/session-state/ended-session';
632
const activeDir = '/Users/anthonykim/.copilot/session-state/active-session';
633
634
// Stale cache from a previous resumed session that has since ended.
635
provider.setSessionDir(freshTerminal, vscode.Uri.file(staleDir));
636
637
// Resolver only returns the active session (stale one was disposed).
638
provider.setSessionDirResolver(async _t => [vscode.Uri.file(activeDir)]);
639
640
// The file exists in BOTH dirs on disk (session-state persists).
641
mockStat.mockResolvedValue({ type: 1 });
642
643
const links = await provider.provideTerminalLinks(
644
makeContext('files/summary.md', freshTerminal),
645
makeToken(),
646
);
647
expect(links).toHaveLength(1);
648
// Must resolve to the active session dir, not the stale cached one.
649
expect(links[0].uri?.fsPath).toBe(`${activeDir}/files/summary.md`);
650
});
651
652
// Scenario 2: Two terminals, each with its own session. The resolver
653
// returns matching-terminal sessions first for correct isolation.
654
it('should prefer terminal-matched resolver dirs over unrelated sessions', async () => {
655
const vscode = await import('vscode');
656
const terminalA = makeTerminal();
657
const terminalB = makeTerminal();
658
provider.registerTerminal(terminalA);
659
provider.registerTerminal(terminalB);
660
661
const sessionXDir = '/Users/anthonykim/.copilot/session-state/session-x';
662
const sessionYDir = '/Users/anthonykim/.copilot/session-state/session-y';
663
664
// Terminal-aware resolver: session X belongs to terminal A,
665
// session Y belongs to terminal B.
666
provider.setSessionDirResolver(async t => {
667
if (t === terminalA) {
668
return [vscode.Uri.file(sessionXDir), vscode.Uri.file(sessionYDir)];
669
}
670
return [vscode.Uri.file(sessionYDir), vscode.Uri.file(sessionXDir)];
671
});
672
673
// The file exists in both session dirs.
674
mockStat.mockResolvedValue({ type: 1 });
675
676
const linksA = await provider.provideTerminalLinks(
677
makeContext('files/summary.md', terminalA),
678
makeToken(),
679
);
680
expect(linksA).toHaveLength(1);
681
expect(linksA[0].uri?.fsPath).toBe(`${sessionXDir}/files/summary.md`);
682
683
const linksB = await provider.provideTerminalLinks(
684
makeContext('files/summary.md', terminalB),
685
makeToken(),
686
);
687
expect(linksB).toHaveLength(1);
688
expect(linksB[0].uri?.fsPath).toBe(`${sessionYDir}/files/summary.md`);
689
});
690
});
691
692
describe('extensionless files', () => {
693
it('should detect dir/Makefile', async () => {
694
const links = await provider.provideTerminalLinks(
695
makeContext('dir/Makefile', terminal),
696
makeToken(),
697
);
698
expect(links).toHaveLength(1);
699
expect(links[0].pathText).toBe('dir/Makefile');
700
});
701
});
702
703
describe('Windows paths', () => {
704
it('should detect backslash relative paths', async () => {
705
const links = await provider.provideTerminalLinks(
706
makeContext('files\\sample-summary.md', terminal),
707
makeToken(),
708
);
709
expect(links).toHaveLength(1);
710
expect(links[0].pathText).toBe('files\\sample-summary.md');
711
});
712
713
it('should expand tilde with backslash (~\\.copilot\\...)', async () => {
714
const links = await provider.provideTerminalLinks(
715
makeContext('Create ~\\.copilot\\session-state\\5d9e\\files\\sample-summary.md (+4)', terminal),
716
makeToken(),
717
);
718
expect(links).toHaveLength(1);
719
expect(links[0].uri?.fsPath).toContain('/Users/anthonykim');
720
expect(links[0].uri?.fsPath).toContain('.copilot');
721
});
722
723
it('should skip Windows absolute paths (C:\\...)', async () => {
724
const links = await provider.provideTerminalLinks(
725
makeContext('Absolute: C:\\Users\\antho\\.copilot\\files\\sample-summary.md', terminal),
726
makeToken(),
727
);
728
// C:\... matched as \Users\... which starts with \ and is skipped.
729
expect(links).toHaveLength(0);
730
});
731
});
732
});
733
734