Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/node/test/claudePluginService.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 { ChatPlugin, 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 { IWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';
14
import { CancellationToken } from '../../../../../util/vs/base/common/cancellation';
15
import { Event } from '../../../../../util/vs/base/common/event';
16
import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';
17
import { URI } from '../../../../../util/vs/base/common/uri';
18
import { MockPromptsService } from '../../../../../platform/promptFiles/test/common/mockPromptsService';
19
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
20
import { ClaudePluginService } from '../claudeSkills';
21
import { IPromptsService } from '../../../../../platform/promptFiles/common/promptsService';
22
23
const ClaudePluginServiceConstructor = ClaudePluginService as unknown as new (
24
configurationService: IConfigurationService,
25
envService: INativeEnvService,
26
workspaceService: IWorkspaceService,
27
promptsService: IPromptsService,
28
) => ClaudePluginService;
29
30
function createWorkspaceService(folders: URI[] = [URI.file('/workspace')]): IWorkspaceService {
31
return {
32
_serviceBrand: undefined,
33
onDidChangeWorkspaceFolders: Event.None,
34
getWorkspaceFolders: () => folders,
35
} as unknown as IWorkspaceService;
36
}
37
38
function mockSkill(uri: string, name: string): ChatSkill {
39
return { uri: URI.parse(uri), name } as ChatSkill;
40
}
41
42
function mockPlugin(uri: string): ChatPlugin {
43
return { uri: URI.parse(uri) } as ChatPlugin;
44
}
45
46
describe('ClaudePluginService', () => {
47
const disposables = new DisposableStore();
48
let baseConfigurationService: IConfigurationService;
49
50
beforeEach(() => {
51
const services = disposables.add(createExtensionUnitTestingServices());
52
const accessor = services.createTestingAccessor();
53
baseConfigurationService = accessor.get(IConfigurationService);
54
});
55
56
afterEach(() => {
57
disposables.clear();
58
});
59
60
function createService(options?: {
61
configLocations?: Record<string, boolean>;
62
workspaceFolders?: URI[];
63
skills?: readonly ChatSkill[];
64
plugins?: readonly ChatPlugin[];
65
userHome?: URI;
66
}): ClaudePluginService {
67
const configService = new InMemoryConfigurationService(baseConfigurationService);
68
if (options?.configLocations) {
69
configService.setNonExtensionConfig(SKILLS_LOCATION_KEY, options.configLocations);
70
}
71
72
const envService = options?.userHome
73
? new class extends NullNativeEnvService { override get userHome() { return options.userHome!; } }()
74
: new NullNativeEnvService();
75
76
const promptsService = disposables.add(new MockPromptsService());
77
if (options?.skills) {
78
promptsService.setSkills(options.skills);
79
}
80
if (options?.plugins) {
81
promptsService.setPlugins(options.plugins);
82
}
83
84
const service = new ClaudePluginServiceConstructor(
85
configService,
86
envService,
87
createWorkspaceService(options?.workspaceFolders),
88
promptsService,
89
);
90
disposables.add(service);
91
return service;
92
}
93
94
it('returns empty array when no config, no skills, and no plugins', async () => {
95
const service = createService();
96
expect(await service.getPluginLocations(CancellationToken.None)).toEqual([]);
97
});
98
99
// #region Config-based skill locations (walks one level up)
100
101
it('walks one level up from config skill locations to get plugin roots', async () => {
102
const service = createService({
103
configLocations: { '/projects/my-extension/skills': true },
104
});
105
const locations = await service.getPluginLocations(CancellationToken.None);
106
expect(locations).toHaveLength(1);
107
expect(locations[0].path).toBe('/projects/my-extension');
108
});
109
110
it('resolves tilde paths from config and walks up', async () => {
111
const service = createService({
112
configLocations: { '~/skills': true },
113
userHome: URI.file('/home/user'),
114
});
115
const locations = await service.getPluginLocations(CancellationToken.None);
116
expect(locations).toHaveLength(1);
117
expect(locations[0].path).toBe('/home/user');
118
});
119
120
it('resolves relative config paths per workspace folder and walks up', async () => {
121
const service = createService({
122
configLocations: { 'skills': true },
123
workspaceFolders: [URI.file('/workspace1'), URI.file('/workspace2')],
124
});
125
const locations = await service.getPluginLocations(CancellationToken.None);
126
expect(locations).toHaveLength(2);
127
expect(locations[0].path).toBe('/workspace1');
128
expect(locations[1].path).toBe('/workspace2');
129
});
130
131
// #endregion
132
133
// #region Skills from prompts service (walks three levels up from SKILL.md)
134
135
it('derives plugin roots from SKILL.md URIs by walking three levels up', async () => {
136
const service = createService({
137
skills: [mockSkill('/plugins/my-plugin/skills/my-skill/SKILL.md', 'my-skill')],
138
});
139
const locations = await service.getPluginLocations(CancellationToken.None);
140
expect(locations).toHaveLength(1);
141
expect(locations[0].path).toBe('/plugins/my-plugin');
142
});
143
144
it('deduplicates skills from the same plugin root', async () => {
145
const service = createService({
146
skills: [
147
mockSkill('/plugins/my-plugin/skills/skill-a/SKILL.md', 'skill-a'),
148
mockSkill('/plugins/my-plugin/skills/skill-b/SKILL.md', 'skill-b'),
149
],
150
});
151
const locations = await service.getPluginLocations(CancellationToken.None);
152
expect(locations).toHaveLength(1);
153
expect(locations[0].path).toBe('/plugins/my-plugin');
154
});
155
156
it('filters out non-file-scheme skills', async () => {
157
const service = createService({
158
skills: [mockSkill('copilot-skill:/remote/skills/my-skill/SKILL.md', 'remote')],
159
});
160
const locations = await service.getPluginLocations(CancellationToken.None);
161
expect(locations).toHaveLength(0);
162
});
163
164
it('filters out skills inside .claude directories', async () => {
165
const service = createService({
166
skills: [mockSkill('/projects/my-project/.claude/skills/my-skill/SKILL.md', 'my-skill')],
167
});
168
const locations = await service.getPluginLocations(CancellationToken.None);
169
expect(locations).toHaveLength(0);
170
});
171
172
// #endregion
173
174
// #region Plugin roots from prompts service
175
176
it('includes plugin roots from prompts service', async () => {
177
const service = createService({
178
plugins: [mockPlugin('/plugins/external-plugin')],
179
});
180
const locations = await service.getPluginLocations(CancellationToken.None);
181
expect(locations).toHaveLength(1);
182
expect(locations[0].path).toBe('/plugins/external-plugin');
183
});
184
185
it('filters out non-file-scheme plugins', async () => {
186
const service = createService({
187
plugins: [mockPlugin('copilot-plugin:/remote/plugin')],
188
});
189
const locations = await service.getPluginLocations(CancellationToken.None);
190
expect(locations).toHaveLength(0);
191
});
192
193
it('filters out plugins inside .claude directories', async () => {
194
const service = createService({
195
plugins: [mockPlugin('/projects/my-project/.claude')],
196
});
197
const locations = await service.getPluginLocations(CancellationToken.None);
198
expect(locations).toHaveLength(0);
199
});
200
201
// #endregion
202
203
// #region Deduplication across all sources
204
205
it('deduplicates across config locations, skills, and plugins', async () => {
206
const service = createService({
207
configLocations: { '/my-plugin/skills': true },
208
skills: [mockSkill('/my-plugin/skills/skill-a/SKILL.md', 'skill-a')],
209
plugins: [mockPlugin('/my-plugin')],
210
});
211
const locations = await service.getPluginLocations(CancellationToken.None);
212
expect(locations).toHaveLength(1);
213
expect(locations[0].path).toBe('/my-plugin');
214
});
215
216
it('combines distinct locations from all sources', async () => {
217
const service = createService({
218
configLocations: { '/config-plugin/skills': true },
219
skills: [mockSkill('/skill-plugin/skills/my-skill/SKILL.md', 'my-skill')],
220
plugins: [mockPlugin('/direct-plugin')],
221
});
222
const locations = await service.getPluginLocations(CancellationToken.None);
223
const paths = locations.map(l => l.path);
224
expect(paths).toContain('/config-plugin');
225
expect(paths).toContain('/skill-plugin');
226
expect(paths).toContain('/direct-plugin');
227
expect(locations).toHaveLength(3);
228
});
229
230
// #endregion
231
});
232
233