Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCLISkills.spec.ts
13406 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 } from 'vitest';
7
import type { ChatSkill } from 'vscode';
8
import { IConfigurationService } from '../../../../../platform/configuration/common/configurationService';
9
import { InMemoryConfigurationService } from '../../../../../platform/configuration/test/common/inMemoryConfigurationService';
10
import { SKILLS_LOCATION_KEY } from '../../../../../platform/customInstructions/common/promptTypes';
11
import { INativeEnvService } from '../../../../../platform/env/common/envService';
12
import { NullNativeEnvService } from '../../../../../platform/env/common/nullEnvService';
13
import { ILogService } from '../../../../../platform/log/common/logService';
14
import type { IPromptsService } from '../../../../../platform/promptFiles/common/promptsService';
15
import { IWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';
16
import { Event } from '../../../../../util/vs/base/common/event';
17
import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';
18
import { URI } from '../../../../../util/vs/base/common/uri';
19
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
20
import { CopilotCLISkills } from '../copilotCLISkills';
21
import { MockPromptsService } from '../../../../../platform/promptFiles/test/common/mockPromptsService';
22
import { CancellationToken } from '../../../../../util/vs/base/common/cancellation';
23
24
const CopilotCLISkillsConstructor = CopilotCLISkills as unknown as new (
25
logService: ILogService,
26
instantiationService: unknown,
27
configurationService: IConfigurationService,
28
envService: INativeEnvService,
29
workspaceService: IWorkspaceService,
30
promptsService: IPromptsService,
31
) => CopilotCLISkills;
32
33
function createWorkspaceService(folders: URI[] = [URI.file('/workspace')]): IWorkspaceService {
34
return {
35
_serviceBrand: undefined,
36
onDidChangeWorkspaceFolders: Event.None,
37
getWorkspaceFolders: () => folders,
38
} as unknown as IWorkspaceService;
39
}
40
41
describe('CopilotCLISkills', () => {
42
const disposables = new DisposableStore();
43
let logService: ILogService;
44
let baseConfigurationService: IConfigurationService;
45
46
beforeEach(() => {
47
const services = disposables.add(createExtensionUnitTestingServices());
48
const accessor = services.createTestingAccessor();
49
logService = accessor.get(ILogService);
50
baseConfigurationService = accessor.get(IConfigurationService);
51
});
52
53
afterEach(() => {
54
disposables.clear();
55
});
56
57
function createSkills(options?: {
58
configLocations?: Record<string, boolean>;
59
workspaceFolders?: URI[];
60
skills?: readonly ChatSkill[];
61
userHome?: URI;
62
}): CopilotCLISkills {
63
const configService = new InMemoryConfigurationService(baseConfigurationService);
64
if (options?.configLocations) {
65
configService.setNonExtensionConfig(SKILLS_LOCATION_KEY, options.configLocations);
66
}
67
68
const envService = options?.userHome
69
? new class extends NullNativeEnvService { override get userHome() { return options.userHome!; } }()
70
: new NullNativeEnvService();
71
72
const promptsService = disposables.add(new MockPromptsService());
73
if (options?.skills) {
74
promptsService.setSkills(options.skills);
75
}
76
77
const skills = new CopilotCLISkillsConstructor(
78
logService,
79
{} as unknown,
80
configService,
81
envService,
82
createWorkspaceService(options?.workspaceFolders),
83
promptsService,
84
);
85
disposables.add(skills);
86
return skills;
87
}
88
89
it('returns empty array when no config and no skills', async () => {
90
const skills = createSkills();
91
expect((await skills.getSkillsLocations(CancellationToken.None))).toEqual([]);
92
});
93
94
it('expands tilde-prefixed paths using user home directory', async () => {
95
const userHome = URI.file('/home/user');
96
const skills = createSkills({
97
configLocations: { '~/my-skills': true },
98
userHome,
99
});
100
101
const locations = await skills.getSkillsLocations(CancellationToken.None);
102
expect(locations).toHaveLength(1);
103
expect(locations[0].path).toBe('/home/user/my-skills');
104
});
105
106
it('handles absolute paths', async () => {
107
const skills = createSkills({
108
configLocations: { '/absolute/skills/path': true },
109
});
110
111
const locations = await skills.getSkillsLocations(CancellationToken.None);
112
expect(locations).toHaveLength(1);
113
expect(locations[0].path).toBe('/absolute/skills/path');
114
});
115
116
it('joins relative paths to each workspace folder', async () => {
117
const skills = createSkills({
118
configLocations: { 'relative/skills': true },
119
workspaceFolders: [URI.file('/workspace1'), URI.file('/workspace2')],
120
});
121
122
const locations = await skills.getSkillsLocations(CancellationToken.None);
123
expect(locations).toHaveLength(2);
124
expect(locations[0].path).toBe('/workspace1/relative/skills');
125
expect(locations[1].path).toBe('/workspace2/relative/skills');
126
});
127
128
it('ignores config entries with value !== true', async () => {
129
const skills = createSkills({
130
configLocations: {
131
'/included': true,
132
'/excluded': false,
133
},
134
});
135
136
const locations = await skills.getSkillsLocations(CancellationToken.None);
137
expect(locations).toHaveLength(1);
138
expect(locations[0].path).toBe('/included');
139
});
140
141
it('includes parent-of-parent directories of file-scheme skills', async () => {
142
const skills = createSkills({
143
skills: [
144
mockSkill('/skills/myskill/SKILL.md', 'myskill'),
145
],
146
});
147
148
const locations = await skills.getSkillsLocations(CancellationToken.None);
149
expect(locations).toHaveLength(1);
150
expect(locations[0].path).toBe('/skills');
151
});
152
153
it('filters out non-file-scheme skills', async () => {
154
const skills = createSkills({
155
skills: [
156
mockSkill('copilot-skill:/remote/skill/SKILL.md', 'remoteSkill'),
157
],
158
});
159
160
const locations = await skills.getSkillsLocations(CancellationToken.None);
161
expect(locations).toHaveLength(0);
162
});
163
164
it('deduplicates locations from config and skills', async () => {
165
const skills = createSkills({
166
configLocations: { '/skills': true },
167
skills: [
168
// dirname(dirname("/skills/myskill/SKILL.md")) = "/skills"
169
mockSkill('/skills/myskill/SKILL.md', 'myskill'),
170
],
171
});
172
173
const locations = await skills.getSkillsLocations(CancellationToken.None);
174
expect(locations).toHaveLength(1);
175
expect(locations[0].path).toBe('/skills');
176
});
177
178
it('deduplicates duplicate config entries', async () => {
179
const skills = createSkills({
180
configLocations: {
181
'/same/path': true,
182
'path': true,
183
},
184
workspaceFolders: [URI.file('/same')],
185
});
186
187
const locations = await skills.getSkillsLocations(CancellationToken.None);
188
// Absolute '/same/path' and relative 'path' joined to workspace '/same'
189
// both resolve to '/same/path', so the result should be deduplicated.
190
expect(locations).toHaveLength(1);
191
expect(locations[0].path).toBe('/same/path');
192
});
193
194
it('handles multiple skills deriving to same parent directory', async () => {
195
const skills = createSkills({
196
skills: [
197
mockSkill('/skills/skill1/SKILL.md', 'skill1'),
198
mockSkill('/skills/skill2/SKILL.md', 'skill2'),
199
],
200
});
201
202
const locations = await skills.getSkillsLocations(CancellationToken.None);
203
// Both resolve to /skills via dirname(dirname())
204
expect(locations).toHaveLength(1);
205
expect(locations[0].path).toBe('/skills');
206
});
207
208
it('combines config locations and skills locations', async () => {
209
const skills = createSkills({
210
configLocations: { '/config-skills': true },
211
skills: [
212
mockSkill('/prompt-skills/myskill/SKILL.md', 'myskill'),
213
],
214
});
215
216
const locations = await skills.getSkillsLocations(CancellationToken.None);
217
expect(locations).toHaveLength(2);
218
const paths = locations.map(l => l.path);
219
expect(paths).toContain('/config-skills');
220
expect(paths).toContain('/prompt-skills');
221
});
222
223
it('ignores empty or whitespace-only config keys', async () => {
224
const skills = createSkills({
225
configLocations: { ' ': true, '': true, '/valid': true },
226
});
227
228
const locations = await skills.getSkillsLocations(CancellationToken.None);
229
// Empty string after trim is not absolute, not ~/,
230
// so goes to relative path. But it's just whitespace.
231
// The code trims and checks - empty string is not '~/' prefixed, not absolute,
232
// so it would try to join to workspace folders.
233
// Let's just verify '/valid' is there
234
const validLocations = locations.filter(l => l.path.endsWith('/valid'));
235
expect(validLocations).toHaveLength(1);
236
});
237
238
it('returns empty when config is not an object', async () => {
239
const configService = new InMemoryConfigurationService(baseConfigurationService);
240
configService.setNonExtensionConfig(SKILLS_LOCATION_KEY, 'not-an-object');
241
242
const mockPromptsService = disposables.add(new MockPromptsService());
243
const skillsService = new CopilotCLISkillsConstructor(
244
logService,
245
{} as unknown,
246
configService,
247
new NullNativeEnvService(),
248
createWorkspaceService(),
249
mockPromptsService,
250
);
251
disposables.add(skillsService);
252
253
expect(await skillsService.getSkillsLocations(CancellationToken.None)).toEqual([]);
254
});
255
256
function mockSkill(uri: string, name: string): ChatSkill {
257
return {
258
uri: URI.parse(uri),
259
name,
260
} as ChatSkill;
261
}
262
});
263
264