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/copilotCLITerminalIntegration.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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
import type { Terminal, TerminalOptions } from 'vscode';
8
import { IAuthenticationService } from '../../../../platform/authentication/common/authentication';
9
import { IEnvService } from '../../../../platform/env/common/envService';
10
import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';
11
import { MockAuthenticationService } from '../../../../platform/ignore/node/test/mockAuthenticationService';
12
import { ILogService } from '../../../../platform/log/common/logService';
13
import { NoopOTelService, resolveOTelConfig } from '../../../../platform/otel/common/index';
14
import { NullTelemetryService } from '../../../../platform/telemetry/common/nullTelemetryService';
15
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry';
16
import { ITerminalService, NullTerminalService } from '../../../../platform/terminal/common/terminalService';
17
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
18
import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';
19
20
// The .ps1 asset cannot be parsed by Vite's transform pipeline,
21
// so we need to tell Vite to treat .ps1 files as raw text via a mock
22
vi.mock('../copilotCLIShim.ps1', () => ({ default: '# mock powershell script' }));
23
24
// Mock fs operations to avoid real filesystem access during tests
25
const { mockMkdir, mockWriteFile, mockCopyFile, mockChmod, mockStat } = vi.hoisted(() => ({
26
mockMkdir: vi.fn(async () => { }),
27
mockWriteFile: vi.fn(async () => { }),
28
mockCopyFile: vi.fn(async () => { }),
29
mockChmod: vi.fn(async () => { }),
30
mockStat: vi.fn(async () => ({ isFile: () => true })),
31
}));
32
33
vi.mock('fs', () => ({
34
promises: {
35
mkdir: mockMkdir,
36
writeFile: mockWriteFile,
37
copyFile: mockCopyFile,
38
chmod: mockChmod,
39
stat: mockStat,
40
}
41
}));
42
43
// Mock Python terminal service to avoid extension dependency
44
vi.mock('../copilotCLIPythonTerminalService', () => ({
45
PythonTerminalService: class {
46
createTerminal = vi.fn(async () => undefined);
47
}
48
}));
49
50
// Mock terminal link provider to avoid pulling in unrelated notebook/proposed API dependencies
51
vi.mock('../copilotCLITerminalLinkProvider', () => ({
52
CopilotCLITerminalLinkProvider: class {
53
registerTerminal = vi.fn();
54
setSessionDir = vi.fn();
55
setSessionDirResolver = vi.fn();
56
},
57
}));
58
59
vi.mock('../../../../platform/workspace/common/workspaceService', () => ({
60
IWorkspaceService: (() => {
61
const identifier = () => { };
62
return identifier;
63
})(),
64
}));
65
66
import type { IConfigurationService } from '../../../../platform/configuration/common/configurationService';
67
import { PythonTerminalService } from '../copilotCLIPythonTerminalService';
68
import { CopilotCLITerminalIntegration } from '../copilotCLITerminalIntegration';
69
70
interface MockTerminal extends Pick<Terminal, 'show' | 'sendText' | 'dispose'> {
71
show: ReturnType<typeof vi.fn>;
72
sendText: ReturnType<typeof vi.fn>;
73
dispose: ReturnType<typeof vi.fn>;
74
shellIntegration: undefined;
75
}
76
77
class TestTerminalService extends NullTerminalService {
78
public mockTerminal: MockTerminal;
79
public createTerminalSpy: ReturnType<typeof vi.fn>;
80
public contributePathSpy: ReturnType<typeof vi.fn>;
81
82
constructor() {
83
super();
84
this.mockTerminal = {
85
show: vi.fn(),
86
sendText: vi.fn(),
87
dispose: vi.fn(),
88
shellIntegration: undefined,
89
};
90
this.createTerminalSpy = vi.fn().mockReturnValue(this.mockTerminal);
91
this.contributePathSpy = vi.fn();
92
}
93
94
override createTerminal(): Terminal {
95
return this.createTerminalSpy(...arguments) as Terminal;
96
}
97
98
override contributePath(contributor: unknown, pathLocation: unknown, description?: unknown, prepend?: unknown): void {
99
this.contributePathSpy(contributor, pathLocation, description, prepend);
100
}
101
}
102
103
class TestEnvService {
104
declare readonly _serviceBrand: undefined;
105
shell = 'zsh';
106
userHome = { fsPath: '/Users/testuser' };
107
OS = 2; // OperatingSystem.Macintosh
108
appRoot = '';
109
language = 'en';
110
uiKind = 1;
111
clipboard = { readText: async () => '', writeText: async () => { } };
112
getAppSpecificStorageUri() { return undefined; }
113
getEditorInfo() { return { name: 'test-editor', version: '1.0' }; }
114
}
115
116
class TestExtensionContext {
117
declare readonly _serviceBrand: undefined;
118
globalStorageUri = { fsPath: '/tmp/test-global-storage' };
119
extension = { id: 'GitHub.copilot-chat' };
120
extensionUri = { fsPath: '/tmp/extensions/copilot-chat' };
121
extensionMode = 3; // ExtensionMode.Test
122
}
123
124
class TestTelemetryService extends NullTelemetryService {
125
public readonly events: Array<{ name: string; properties: Record<string, string> }> = [];
126
override sendMSFTTelemetryEvent(name: string, properties: Record<string, string>): void {
127
this.events.push({ name, properties });
128
}
129
}
130
131
const { mockWorkspaceGetConfiguration, mockRegisterTerminalProfileProvider, mockRegisterTerminalLinkProvider } = vi.hoisted(() => ({
132
mockWorkspaceGetConfiguration: vi.fn(),
133
mockRegisterTerminalProfileProvider: vi.fn(() => ({ dispose: () => { } })),
134
mockRegisterTerminalLinkProvider: vi.fn(() => ({ dispose: () => { } })),
135
}));
136
137
vi.mock('vscode', async (importOriginal) => {
138
const actual = await importOriginal() as Record<string, unknown>;
139
return {
140
...actual,
141
workspace: {
142
getConfiguration: mockWorkspaceGetConfiguration,
143
},
144
window: {
145
registerTerminalProfileProvider: mockRegisterTerminalProfileProvider,
146
registerTerminalLinkProvider: mockRegisterTerminalLinkProvider,
147
},
148
TerminalLocation: { Panel: 1, Editor: 2 },
149
ViewColumn: { Active: -1, Beside: -2 },
150
ThemeIcon: class ThemeIcon {
151
constructor(public readonly id: string) { }
152
},
153
TerminalProfile: class TerminalProfile {
154
constructor(public readonly options: TerminalOptions) { }
155
},
156
Range: class Range {
157
constructor(public startLine: number, public startCharacter: number, public endLine: number, public endCharacter: number) { }
158
},
159
Uri: {
160
joinPath: (base: { fsPath: string; scheme: string }, ...segments: string[]) => ({ fsPath: [base.fsPath, ...segments].join('/'), scheme: base.scheme }),
161
file: (path: string) => ({ fsPath: path, scheme: 'file' }),
162
},
163
};
164
});
165
166
function setupTerminalConfig(defaultProfile: string | undefined, profiles: Record<string, { path: string | string[]; args?: string[] }> | undefined) {
167
mockWorkspaceGetConfiguration.mockImplementation((section: string) => ({
168
get: (key: string) => {
169
if (key.startsWith('integrated.defaultProfile.')) {
170
return defaultProfile;
171
}
172
if (key.startsWith('integrated.profiles.')) {
173
return profiles;
174
}
175
return undefined;
176
}
177
}));
178
}
179
180
describe('CopilotCLITerminalIntegration', () => {
181
const disposables = new DisposableStore();
182
let terminalService: TestTerminalService;
183
let telemetryService: TestTelemetryService;
184
let envService: TestEnvService;
185
let integration: CopilotCLITerminalIntegration;
186
let authService: MockAuthenticationService;
187
188
beforeEach(async () => {
189
vi.clearAllMocks();
190
191
terminalService = disposables.add(new TestTerminalService());
192
telemetryService = new TestTelemetryService();
193
envService = new TestEnvService();
194
authService = new MockAuthenticationService();
195
196
setupTerminalConfig('zsh', {
197
zsh: { path: 'zsh' },
198
});
199
200
integration = new CopilotCLITerminalIntegration(
201
new TestExtensionContext() as unknown as IVSCodeExtensionContext,
202
authService as unknown as IAuthenticationService,
203
terminalService as unknown as ITerminalService,
204
envService as unknown as IEnvService,
205
{ trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), createSubLogger: () => ({}) } as unknown as ILogService,
206
telemetryService as unknown as ITelemetryService,
207
{ getConfig: () => true } as unknown as IConfigurationService,
208
209
{ requestResourceTrust: vi.fn().mockResolvedValue(true) } as unknown as IWorkspaceService,
210
211
new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })),
212
);
213
disposables.add(integration);
214
215
// Wait for initialization to complete
216
await (integration as any).initialization;
217
});
218
219
afterEach(() => {
220
disposables.clear();
221
});
222
223
describe('openTerminal', () => {
224
it('should create a terminal via terminalService when no python terminal available', async () => {
225
await integration.openTerminal('Test Terminal');
226
227
// Since pythonTerminalService.createTerminal returns undefined by default,
228
// and getShellInfo returns shell info for zsh, it falls through to the
229
// shell args terminal creation path
230
expect(terminalService.createTerminalSpy).toHaveBeenCalled();
231
});
232
233
it('should set sessionType to "new" when no cliArgs provided', async () => {
234
await integration.openTerminal('Test Terminal');
235
236
const event = telemetryService.events.find(e => e.name === 'copilotcli.terminal.open');
237
expect(event).toBeDefined();
238
expect(event!.properties.sessionType).toBe('new');
239
});
240
241
it('should set sessionType to "resume" when cliArgs has --resume', async () => {
242
await integration.openTerminal('Test Terminal', ['--resume', 'session-123']);
243
244
const event = telemetryService.events.find(e => e.name === 'copilotcli.terminal.open');
245
expect(event).toBeDefined();
246
expect(event!.properties.sessionType).toBe('resume');
247
});
248
249
it('should send telemetry with shell type', async () => {
250
await integration.openTerminal('Test Terminal');
251
252
const event = telemetryService.events.find(e => e.name === 'copilotcli.terminal.open');
253
expect(event).toBeDefined();
254
expect(event!.properties.shell).toBe('zsh');
255
});
256
257
it('should pass cwd to terminal options', async () => {
258
await integration.openTerminal('Test Terminal', [], '/my/working/dir');
259
260
const callArgs = terminalService.createTerminalSpy.mock.calls[0][0] as TerminalOptions;
261
expect(callArgs.cwd).toBe('/my/working/dir');
262
});
263
264
it('should show the terminal after creation in shellArgs path', async () => {
265
await integration.openTerminal('Test Terminal');
266
267
expect(terminalService.mockTerminal.show).toHaveBeenCalled();
268
});
269
270
it('should fall back to terminalService when getShellInfo returns undefined', async () => {
271
// Setup config to return no matching profile
272
setupTerminalConfig(undefined, undefined);
273
274
// Re-create the integration to pick up the new config
275
envService.shell = '/bin/unknownshell';
276
const freshIntegration = new CopilotCLITerminalIntegration(
277
new TestExtensionContext() as unknown as IVSCodeExtensionContext,
278
authService as unknown as IAuthenticationService,
279
terminalService as unknown as ITerminalService,
280
envService as unknown as IEnvService,
281
{ trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), createSubLogger: () => ({}) } as unknown as ILogService,
282
telemetryService as unknown as ITelemetryService,
283
{ getConfig: () => true } as unknown as IConfigurationService,
284
285
{ requestResourceTrust: vi.fn().mockResolvedValue(true) } as unknown as IWorkspaceService,
286
287
new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })),
288
);
289
disposables.add(freshIntegration);
290
await (freshIntegration as any).initialization;
291
292
await freshIntegration.openTerminal('Fallback Terminal');
293
294
const event = telemetryService.events.find(e => e.name === 'copilotcli.terminal.open');
295
expect(event).toBeDefined();
296
expect(event!.properties.shell).toBe('unknown');
297
expect(event!.properties.terminalCreationMethod).toBe('fallbackTerminal');
298
});
299
300
it('should use pythonTerminal method when python terminal is available and shell is not powershell', async () => {
301
const mockPythonTerminal: MockTerminal = {
302
show: vi.fn(),
303
sendText: vi.fn(),
304
dispose: vi.fn(),
305
shellIntegration: undefined,
306
};
307
308
// Access the internal pythonTerminalService and mock createTerminal to return a terminal
309
const pythonService = (integration as any).pythonTerminalService as PythonTerminalService;
310
(pythonService.createTerminal as ReturnType<typeof vi.fn>).mockResolvedValue(mockPythonTerminal);
311
312
await integration.openTerminal('Python Terminal');
313
314
const event = telemetryService.events.find(e => e.name === 'copilotcli.terminal.open');
315
expect(event).toBeDefined();
316
expect(event!.properties.terminalCreationMethod).toBe('pythonTerminal');
317
expect(event!.properties.shell).toBe('zsh');
318
});
319
320
it('should use shellArgsTerminal method when python terminal is not available', async () => {
321
await integration.openTerminal('Shell Args Terminal');
322
323
const event = telemetryService.events.find(e => e.name === 'copilotcli.terminal.open');
324
expect(event).toBeDefined();
325
expect(event!.properties.terminalCreationMethod).toBe('shellArgsTerminal');
326
});
327
328
it('should prepend --clear to cliArgs', async () => {
329
await integration.openTerminal('Test Terminal', ['--resume', 'sess-1']);
330
331
// For shellArgs terminal path, --clear gets removed before getShellInfo,
332
// but the final shell args should contain the original CLI args
333
const callArgs = terminalService.createTerminalSpy.mock.calls[0][0] as TerminalOptions;
334
const shellArgs = callArgs.shellArgs as string[];
335
// Shell args should contain the cli args (--resume, sess-1) but not --clear
336
const joinedArgs = shellArgs.join(' ');
337
expect(joinedArgs).toContain('--resume');
338
expect(joinedArgs).toContain('sess-1');
339
});
340
341
it('should use editor location by default', async () => {
342
await integration.openTerminal('Test Terminal');
343
344
const callArgs = terminalService.createTerminalSpy.mock.calls[0][0] as TerminalOptions;
345
// Default location is 'editor' which maps to ViewColumn.Active
346
expect(callArgs.location).toEqual({ viewColumn: -1 }); // ViewColumn.Active
347
});
348
349
it('should set bash shell info when default profile is bash', async () => {
350
setupTerminalConfig('bash', {
351
bash: { path: 'bash' },
352
});
353
envService.shell = 'bash';
354
355
const freshIntegration = new CopilotCLITerminalIntegration(
356
new TestExtensionContext() as unknown as IVSCodeExtensionContext,
357
authService as unknown as IAuthenticationService,
358
terminalService as unknown as ITerminalService,
359
envService as unknown as IEnvService,
360
{ trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), createSubLogger: () => ({}) } as unknown as ILogService,
361
telemetryService as unknown as ITelemetryService,
362
363
{ getConfig: () => true } as unknown as IConfigurationService,
364
{ requestResourceTrust: vi.fn().mockResolvedValue(true) } as unknown as IWorkspaceService,
365
new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })),
366
);
367
disposables.add(freshIntegration);
368
await (freshIntegration as any).initialization;
369
370
await freshIntegration.openTerminal('Bash Terminal');
371
372
const event = telemetryService.events.find(e => e.name === 'copilotcli.terminal.open');
373
expect(event).toBeDefined();
374
expect(event!.properties.shell).toBe('bash');
375
});
376
});
377
378
describe('initialize', () => {
379
it('should contribute path to terminal service', async () => {
380
expect(terminalService.contributePathSpy).toHaveBeenCalledWith(
381
'copilot-cli',
382
expect.stringContaining('copilotCli'),
383
expect.objectContaining({ command: 'copilot' }),
384
true,
385
);
386
});
387
388
it('should register a terminal profile provider', async () => {
389
expect(mockRegisterTerminalProfileProvider).toHaveBeenCalledWith(
390
'copilot-cli',
391
expect.objectContaining({ provideTerminalProfile: expect.any(Function) }),
392
);
393
});
394
});
395
396
describe('telemetry', () => {
397
it('should include location in telemetry', async () => {
398
await integration.openTerminal('Test', [], undefined, 'panel');
399
400
const event = telemetryService.events.find(e => e.name === 'copilotcli.terminal.open');
401
expect(event).toBeDefined();
402
expect(event!.properties.location).toBe('panel');
403
});
404
405
it('should report editorBeside location', async () => {
406
await integration.openTerminal('Test', [], undefined, 'editorBeside');
407
408
const event = telemetryService.events.find(e => e.name === 'copilotcli.terminal.open');
409
expect(event!.properties.location).toBe('editorBeside');
410
});
411
});
412
413
describe('getCommonTerminalOptions (via openTerminal)', () => {
414
it('should set terminal name from parameter', async () => {
415
await integration.openTerminal('My Custom Name');
416
417
const callArgs = terminalService.createTerminalSpy.mock.calls[0][0] as TerminalOptions;
418
expect(callArgs.name).toBe('My Custom Name');
419
});
420
421
it('should not include auth env vars when no session available', async () => {
422
await integration.openTerminal('No Auth Terminal');
423
424
const callArgs = terminalService.createTerminalSpy.mock.calls[0][0] as TerminalOptions;
425
expect(callArgs.env).toBeUndefined();
426
});
427
428
it('should include auth env vars when session is available', async () => {
429
const authServiceWithSession = new class extends MockAuthenticationService {
430
override async getGitHubSession() {
431
return { accessToken: 'test-token-123' } as any;
432
}
433
}();
434
435
const freshIntegration = new CopilotCLITerminalIntegration(
436
new TestExtensionContext() as unknown as IVSCodeExtensionContext,
437
authServiceWithSession as unknown as IAuthenticationService,
438
terminalService as unknown as ITerminalService,
439
envService as unknown as IEnvService,
440
{ trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), createSubLogger: () => ({}) } as unknown as ILogService,
441
telemetryService as unknown as ITelemetryService,
442
443
{ getConfig: () => true } as unknown as IConfigurationService,
444
{ requestResourceTrust: vi.fn().mockResolvedValue(true) } as unknown as IWorkspaceService,
445
new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })),
446
);
447
disposables.add(freshIntegration);
448
await (freshIntegration as any).initialization;
449
450
await freshIntegration.openTerminal('Auth Terminal');
451
452
const callArgs = terminalService.createTerminalSpy.mock.calls[0][0] as TerminalOptions;
453
expect(callArgs.env).toEqual({
454
GH_TOKEN: 'test-token-123',
455
COPILOT_GITHUB_TOKEN: 'test-token-123',
456
});
457
});
458
});
459
});
460
461