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/copilotCLIChatSessions.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 { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
7
import type * as vscode from 'vscode';
8
// eslint-disable-next-line no-duplicate-imports
9
import * as vscodeShim from 'vscode';
10
import { IRunCommandExecutionService } from '../../../../platform/commands/common/runCommandExecutionService';
11
import { DefaultsOnlyConfigurationService } from '../../../../platform/configuration/common/defaultsOnlyConfigurationService';
12
import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService';
13
import { IGitService, RepoContext } from '../../../../platform/git/common/gitService';
14
import { PullRequestSearchItem } from '../../../../platform/github/common/githubAPI';
15
import { IOctoKitService } from '../../../../platform/github/common/githubService';
16
import { ILogService } from '../../../../platform/log/common/logService';
17
import { NullWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
18
import { mock } from '../../../../util/common/test/simpleMock';
19
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
20
import { Event } from '../../../../util/vs/base/common/event';
21
import { URI } from '../../../../util/vs/base/common/uri';
22
import { IChatSessionMetadataStore } from '../../common/chatSessionMetadataStore';
23
import { IChatSessionWorkspaceFolderService } from '../../common/chatSessionWorkspaceFolderService';
24
import { ChatSessionWorktreeProperties, IChatSessionWorktreeService } from '../../common/chatSessionWorktreeService';
25
import { IFolderRepositoryManager, IsolationMode } from '../../common/folderRepositoryManager';
26
import { emptyWorkspaceInfo } from '../../common/workspaceInfo';
27
import { ICustomSessionTitleService } from '../../copilotcli/common/customSessionTitleService';
28
import { ICopilotCLISession } from '../../copilotcli/node/copilotcliSession';
29
import { ICopilotCLISessionItem, ICopilotCLISessionService } from '../../copilotcli/node/copilotcliSessionService';
30
import { ICopilotCLISessionTracker } from '../../copilotcli/vscode-node/copilotCLISessionTracker';
31
import { CopilotCLIChatSessionContentProvider, resolveBranchLockState, resolveBranchSelection, resolveIsolationSelection, resolveSessionDirsForTerminal } from '../copilotCLIChatSessions';
32
import { PullRequestDetectionService } from '../pullRequestDetectionService';
33
import { ISessionOptionGroupBuilder } from '../sessionOptionGroupBuilder';
34
vi.mock('../copilotCLIShim.ps1', () => ({ default: '# mock powershell script' }));
35
36
beforeAll(() => {
37
(vscodeShim as Record<string, unknown>).chat = {
38
createChatSessionItemController: () => ({
39
id: 'copilotcli',
40
items: {
41
get: () => undefined,
42
add: () => { },
43
delete: () => { },
44
replace: () => { },
45
[Symbol.iterator]: function* () { },
46
forEach: () => { },
47
},
48
createChatSessionItem: (resource: vscode.Uri, label: string): vscode.ChatSessionItem => ({ resource, label }),
49
dispose: () => { },
50
}),
51
};
52
(vscodeShim as Record<string, unknown>).workspace = {
53
...((vscodeShim as Record<string, unknown>).workspace as object),
54
workspaceFolders: [],
55
isAgentSessionsWorkspace: false,
56
isResourceTrusted: async () => true,
57
};
58
});
59
60
class TestSessionService extends mock<ICopilotCLISessionService>() {
61
declare readonly _serviceBrand: undefined;
62
override onDidChangeSessions = Event.None;
63
override onDidDeleteSession = Event.None;
64
override onDidChangeSession = Event.None;
65
override onDidCreateSession = Event.None;
66
override getSessionWorkingDirectory = vi.fn(() => undefined);
67
override getSessionItem = vi.fn(async () => undefined);
68
override getAllSessions = vi.fn(async () => [] as ICopilotCLISessionItem[]);
69
override createNewSessionId = vi.fn(() => 'new-session');
70
override isNewSessionId = vi.fn(() => false);
71
override deleteSession = vi.fn(async () => { });
72
override renameSession = vi.fn(async () => { });
73
override getSessionTitle = vi.fn(async () => '');
74
override getSession = vi.fn(async () => ({
75
object: {
76
sessionId: 'session-1',
77
workspace: emptyWorkspaceInfo,
78
getChatHistory: async () => [],
79
},
80
dispose: () => { },
81
} as unknown as { object: ICopilotCLISession; dispose(): void }));
82
override createSession = vi.fn(async () => {
83
throw new Error('Not implemented');
84
});
85
override forkSession = vi.fn(async () => 'forked-session');
86
override tryGetPartialSessionHistory = vi.fn(async () => undefined);
87
override getChatHistory = vi.fn(async () => []);
88
}
89
90
class TestWorktreeService extends mock<IChatSessionWorktreeService>() {
91
declare readonly _serviceBrand: undefined;
92
override getWorktreeProperties = vi.fn(async (_sessionId: string | vscode.Uri): Promise<ChatSessionWorktreeProperties | undefined> => undefined);
93
override setWorktreeProperties = vi.fn(async () => { });
94
override getWorktreeChanges = vi.fn(async () => []);
95
override hasCachedChanges = vi.fn(async () => false);
96
override onDidChangeWorktreeChanges = Event.None;
97
}
98
99
class TestWorkspaceFolderService extends mock<IChatSessionWorkspaceFolderService>() {
100
declare readonly _serviceBrand: undefined;
101
override getWorkspaceChanges = vi.fn(async () => []);
102
override hasCachedChanges = vi.fn(async () => false);
103
override onDidChangeWorkspaceFolderChanges = Event.None;
104
}
105
106
class TestFolderRepositoryManager extends mock<IFolderRepositoryManager>() {
107
declare readonly _serviceBrand: undefined;
108
override setNewSessionFolder = vi.fn();
109
override deleteNewSessionFolder = vi.fn();
110
override getFolderRepository = vi.fn(async () => ({
111
folder: undefined,
112
repository: undefined,
113
worktree: undefined,
114
worktreeProperties: undefined,
115
trusted: undefined,
116
}));
117
override initializeFolderRepository = vi.fn(async () => ({
118
folder: undefined,
119
repository: undefined,
120
worktree: undefined,
121
worktreeProperties: undefined,
122
trusted: undefined,
123
}));
124
override getRepositoryInfo = vi.fn(async () => ({ repository: undefined, headBranchName: undefined }));
125
override getFolderMRU = vi.fn(async () => []);
126
}
127
128
class TestGitService extends mock<IGitService>() {
129
declare readonly _serviceBrand: undefined;
130
override onDidOpenRepository = Event.None;
131
override onDidCloseRepository = Event.None;
132
override onDidFinishInitialization = Event.None;
133
override activeRepository = { get: () => undefined } as IGitService['activeRepository'];
134
override repositories: RepoContext[] = [];
135
136
setRepo(repo: RepoContext): void {
137
this.repositories = [repo];
138
}
139
140
override getRepository = vi.fn(async () => this.repositories[0]);
141
}
142
143
class TestOctoKitService extends mock<IOctoKitService>() {
144
declare readonly _serviceBrand: undefined;
145
override findPullRequestByHeadBranch = vi.fn(async (): Promise<PullRequestSearchItem | undefined> => undefined);
146
}
147
148
class TestRunCommandExecutionService extends mock<IRunCommandExecutionService>() {
149
declare readonly _serviceBrand: undefined;
150
override executeCommand = vi.fn(async () => undefined);
151
}
152
153
class TestCustomSessionTitleService extends mock<ICustomSessionTitleService>() {
154
declare readonly _serviceBrand: undefined;
155
override getCustomSessionTitle = vi.fn(async () => 'Session Title');
156
override setCustomSessionTitle = vi.fn(async () => { });
157
override generateSessionTitle = vi.fn(async () => undefined);
158
}
159
160
function createProvider() {
161
const sessionService = new TestSessionService();
162
const worktreeService = new TestWorktreeService();
163
const metadataStore = new class extends mock<IChatSessionMetadataStore>() {
164
override getRequestDetails = vi.fn(async () => []);
165
override getRepositoryProperties = vi.fn(async () => undefined);
166
override getSessionParentId = vi.fn(async () => undefined);
167
};
168
const gitService = new TestGitService();
169
const folderRepositoryManager = new TestFolderRepositoryManager();
170
const configurationService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService());
171
const customSessionTitleService = new TestCustomSessionTitleService();
172
const commandExecutionService = new TestRunCommandExecutionService();
173
const workspaceFolderService = new TestWorkspaceFolderService();
174
const octoKitService = new TestOctoKitService();
175
const logService = new class extends mock<ILogService>() {
176
declare readonly _serviceBrand: undefined;
177
override trace = vi.fn();
178
override debug = vi.fn();
179
override info = vi.fn();
180
override error = vi.fn();
181
}();
182
183
const prDetectionService = new PullRequestDetectionService(
184
worktreeService,
185
gitService,
186
octoKitService,
187
logService,
188
);
189
const optionGroupBuilder = new class extends mock<ISessionOptionGroupBuilder>() {
190
declare readonly _serviceBrand: undefined;
191
override provideChatSessionProviderOptionGroups = vi.fn(async () => []);
192
override buildBranchOptionGroup = vi.fn(() => undefined);
193
override handleInputStateChange = vi.fn(async () => { });
194
override rebuildInputState = vi.fn(async () => { });
195
override buildExistingSessionInputStateGroups = vi.fn(async () => []);
196
override getBranchOptionItemsForRepository = vi.fn(async () => []);
197
override getRepositoryOptionItems = vi.fn(() => []);
198
}();
199
const provider = new CopilotCLIChatSessionContentProvider(
200
sessionService,
201
worktreeService,
202
folderRepositoryManager,
203
configurationService,
204
customSessionTitleService,
205
commandExecutionService,
206
logService,
207
prDetectionService,
208
optionGroupBuilder,
209
gitService,
210
workspaceFolderService,
211
metadataStore,
212
new NullWorkspaceService(),
213
worktreeService,
214
);
215
216
return {
217
provider,
218
prDetectionService,
219
sessionService,
220
worktreeService,
221
gitService,
222
octoKitService,
223
};
224
}
225
226
describe('CopilotCLIChatSessionContentProvider', () => {
227
beforeEach(() => {
228
vi.restoreAllMocks();
229
});
230
231
it('triggers pull request detection when opening an existing session', async () => {
232
const { provider, prDetectionService } = createProvider();
233
const detectSpy = vi.spyOn(prDetectionService, 'detectPullRequest');
234
235
await provider.provideChatSessionContent(
236
URI.from({ scheme: 'copilotcli', path: '/session-1' }),
237
CancellationToken.None,
238
);
239
240
expect(detectSpy).toHaveBeenCalledWith('session-1');
241
});
242
243
it('persists detected pull request url and state on session open', async () => {
244
const { prDetectionService, worktreeService, gitService, octoKitService } = createProvider();
245
const worktreeProperties: ChatSessionWorktreeProperties = {
246
version: 2,
247
baseCommit: 'abc123',
248
baseBranchName: 'main',
249
branchName: 'copilot/test-branch',
250
repositoryPath: '/repo',
251
worktreePath: '/worktree',
252
};
253
254
worktreeService.getWorktreeProperties.mockResolvedValue(worktreeProperties);
255
gitService.setRepo({
256
rootUri: URI.file('/repo'),
257
kind: 'repository',
258
remotes: ['origin'],
259
remoteFetchUrls: ['https://github.com/testowner/testrepo.git'],
260
} as unknown as RepoContext);
261
octoKitService.findPullRequestByHeadBranch.mockResolvedValue({
262
id: 'pr-42',
263
number: 42,
264
title: 'Test PR',
265
url: 'https://github.com/testowner/testrepo/pull/42',
266
state: 'OPEN',
267
isDraft: false,
268
createdAt: '2026-01-01T00:00:00Z',
269
updatedAt: '2026-01-01T00:00:00Z',
270
author: { login: 'testowner' },
271
repository: { owner: { login: 'testowner' }, name: 'testrepo' },
272
additions: 1,
273
deletions: 0,
274
files: { totalCount: 1 },
275
fullDatabaseId: 42,
276
headRefOid: 'deadbeef',
277
headRefName: 'copilot/test-branch',
278
baseRefName: 'main',
279
body: '',
280
});
281
282
prDetectionService.detectPullRequest('session-1');
283
284
await vi.waitFor(() => expect(worktreeService.setWorktreeProperties).toHaveBeenCalledWith(
285
'session-1',
286
expect.objectContaining({
287
pullRequestUrl: 'https://github.com/testowner/testrepo/pull/42',
288
pullRequestState: 'open',
289
}),
290
));
291
});
292
293
it('skips session-open detection for merged pull requests', async () => {
294
const { prDetectionService, worktreeService, octoKitService } = createProvider();
295
const mergedProperties: ChatSessionWorktreeProperties = {
296
version: 2,
297
baseCommit: 'abc123',
298
baseBranchName: 'main',
299
branchName: 'copilot/test-branch',
300
repositoryPath: '/repo',
301
worktreePath: '/worktree',
302
pullRequestState: 'merged',
303
};
304
305
worktreeService.getWorktreeProperties.mockResolvedValue(mergedProperties);
306
307
prDetectionService.detectPullRequest('session-1');
308
309
await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());
310
expect(octoKitService.findPullRequestByHeadBranch).not.toHaveBeenCalled();
311
expect(worktreeService.setWorktreeProperties).not.toHaveBeenCalled();
312
});
313
});
314
315
// ─── Re-exported helper function smoke tests ────────────────────
316
// Full test coverage lives in sessionOptionGroupBuilder.spec.ts;
317
// these just verify the re-exports are wired up correctly.
318
319
describe('re-exported dropdown helpers', () => {
320
it('resolveBranchSelection is callable', () => {
321
const branches = [{ id: 'main', name: 'main' }];
322
expect(resolveBranchSelection(branches, 'main', undefined)?.id).toBe('main');
323
});
324
325
it('resolveBranchLockState is callable', () => {
326
const result = resolveBranchLockState(false, undefined);
327
expect(result.locked).toBe(true);
328
});
329
330
it('resolveIsolationSelection is callable', () => {
331
expect(resolveIsolationSelection(IsolationMode.Workspace, undefined)).toBe(IsolationMode.Workspace);
332
});
333
});
334
335
// ─── resolveSessionDirsForTerminal ──────────────────────────────
336
337
describe('resolveSessionDirsForTerminal', () => {
338
it('returns matching terminal sessions before non-matching ones', async () => {
339
const terminal = {} as vscode.Terminal;
340
const otherTerminal = {} as vscode.Terminal;
341
const tracker: ICopilotCLISessionTracker = {
342
_serviceBrand: undefined,
343
getSessionIds: () => ['session-a', 'session-b'],
344
getTerminal: vi.fn(async (id: string) => id === 'session-a' ? terminal : otherTerminal),
345
} as unknown as ICopilotCLISessionTracker;
346
347
const dirs = await resolveSessionDirsForTerminal(tracker, terminal);
348
expect(dirs).toHaveLength(2);
349
// First dir should be for the matching session
350
expect(dirs[0].fsPath).toContain('session-a');
351
});
352
353
it('returns empty array when no sessions exist', async () => {
354
const terminal = {} as vscode.Terminal;
355
const tracker: ICopilotCLISessionTracker = {
356
_serviceBrand: undefined,
357
getSessionIds: () => [],
358
getTerminal: vi.fn(async () => undefined),
359
} as unknown as ICopilotCLISessionTracker;
360
361
const dirs = await resolveSessionDirsForTerminal(tracker, terminal);
362
expect(dirs).toHaveLength(0);
363
});
364
});
365
366
// ─── Additional CopilotCLIChatSessionContentProvider tests ──────
367
368
describe('CopilotCLIChatSessionContentProvider (additional)', () => {
369
beforeEach(() => {
370
vi.restoreAllMocks();
371
});
372
373
it('toChatSessionItem maps session to chat session item', async () => {
374
const { provider } = createProvider();
375
const sessionItem: ICopilotCLISessionItem = {
376
id: 'session-1',
377
label: 'Test Session',
378
status: undefined,
379
workingDirectory: undefined,
380
} as unknown as ICopilotCLISessionItem;
381
382
const item = await provider.toChatSessionItem(sessionItem);
383
expect(item.label).toBe('Test Session');
384
});
385
386
it('does not call refreshSession when PR detection finds no update', async () => {
387
const { provider, prDetectionService, worktreeService } = createProvider();
388
const refreshSpy = vi.spyOn(provider, 'refreshSession').mockResolvedValue();
389
390
// No worktree properties means no PR detection
391
worktreeService.getWorktreeProperties.mockResolvedValue(undefined);
392
393
prDetectionService.detectPullRequest('session-1');
394
await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());
395
expect(refreshSpy).not.toHaveBeenCalled();
396
});
397
});
398
399