Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/node/test/claudePluginService.spec.ts
13406 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { afterEach, beforeEach, describe, expect, it } from 'vitest';6import type { ChatPlugin, ChatSkill } from 'vscode';7import { IConfigurationService } from '../../../../../platform/configuration/common/configurationService';8import { InMemoryConfigurationService } from '../../../../../platform/configuration/test/common/inMemoryConfigurationService';9import { SKILLS_LOCATION_KEY } from '../../../../../platform/customInstructions/common/promptTypes';10import { INativeEnvService } from '../../../../../platform/env/common/envService';11import { NullNativeEnvService } from '../../../../../platform/env/common/nullEnvService';12import { IWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';13import { CancellationToken } from '../../../../../util/vs/base/common/cancellation';14import { Event } from '../../../../../util/vs/base/common/event';15import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';16import { URI } from '../../../../../util/vs/base/common/uri';17import { MockPromptsService } from '../../../../../platform/promptFiles/test/common/mockPromptsService';18import { createExtensionUnitTestingServices } from '../../../../test/node/services';19import { ClaudePluginService } from '../claudeSkills';20import { IPromptsService } from '../../../../../platform/promptFiles/common/promptsService';2122const ClaudePluginServiceConstructor = ClaudePluginService as unknown as new (23configurationService: IConfigurationService,24envService: INativeEnvService,25workspaceService: IWorkspaceService,26promptsService: IPromptsService,27) => ClaudePluginService;2829function createWorkspaceService(folders: URI[] = [URI.file('/workspace')]): IWorkspaceService {30return {31_serviceBrand: undefined,32onDidChangeWorkspaceFolders: Event.None,33getWorkspaceFolders: () => folders,34} as unknown as IWorkspaceService;35}3637function mockSkill(uri: string, name: string): ChatSkill {38return { uri: URI.parse(uri), name } as ChatSkill;39}4041function mockPlugin(uri: string): ChatPlugin {42return { uri: URI.parse(uri) } as ChatPlugin;43}4445describe('ClaudePluginService', () => {46const disposables = new DisposableStore();47let baseConfigurationService: IConfigurationService;4849beforeEach(() => {50const services = disposables.add(createExtensionUnitTestingServices());51const accessor = services.createTestingAccessor();52baseConfigurationService = accessor.get(IConfigurationService);53});5455afterEach(() => {56disposables.clear();57});5859function createService(options?: {60configLocations?: Record<string, boolean>;61workspaceFolders?: URI[];62skills?: readonly ChatSkill[];63plugins?: readonly ChatPlugin[];64userHome?: URI;65}): ClaudePluginService {66const configService = new InMemoryConfigurationService(baseConfigurationService);67if (options?.configLocations) {68configService.setNonExtensionConfig(SKILLS_LOCATION_KEY, options.configLocations);69}7071const envService = options?.userHome72? new class extends NullNativeEnvService { override get userHome() { return options.userHome!; } }()73: new NullNativeEnvService();7475const promptsService = disposables.add(new MockPromptsService());76if (options?.skills) {77promptsService.setSkills(options.skills);78}79if (options?.plugins) {80promptsService.setPlugins(options.plugins);81}8283const service = new ClaudePluginServiceConstructor(84configService,85envService,86createWorkspaceService(options?.workspaceFolders),87promptsService,88);89disposables.add(service);90return service;91}9293it('returns empty array when no config, no skills, and no plugins', async () => {94const service = createService();95expect(await service.getPluginLocations(CancellationToken.None)).toEqual([]);96});9798// #region Config-based skill locations (walks one level up)99100it('walks one level up from config skill locations to get plugin roots', async () => {101const service = createService({102configLocations: { '/projects/my-extension/skills': true },103});104const locations = await service.getPluginLocations(CancellationToken.None);105expect(locations).toHaveLength(1);106expect(locations[0].path).toBe('/projects/my-extension');107});108109it('resolves tilde paths from config and walks up', async () => {110const service = createService({111configLocations: { '~/skills': true },112userHome: URI.file('/home/user'),113});114const locations = await service.getPluginLocations(CancellationToken.None);115expect(locations).toHaveLength(1);116expect(locations[0].path).toBe('/home/user');117});118119it('resolves relative config paths per workspace folder and walks up', async () => {120const service = createService({121configLocations: { 'skills': true },122workspaceFolders: [URI.file('/workspace1'), URI.file('/workspace2')],123});124const locations = await service.getPluginLocations(CancellationToken.None);125expect(locations).toHaveLength(2);126expect(locations[0].path).toBe('/workspace1');127expect(locations[1].path).toBe('/workspace2');128});129130// #endregion131132// #region Skills from prompts service (walks three levels up from SKILL.md)133134it('derives plugin roots from SKILL.md URIs by walking three levels up', async () => {135const service = createService({136skills: [mockSkill('/plugins/my-plugin/skills/my-skill/SKILL.md', 'my-skill')],137});138const locations = await service.getPluginLocations(CancellationToken.None);139expect(locations).toHaveLength(1);140expect(locations[0].path).toBe('/plugins/my-plugin');141});142143it('deduplicates skills from the same plugin root', async () => {144const service = createService({145skills: [146mockSkill('/plugins/my-plugin/skills/skill-a/SKILL.md', 'skill-a'),147mockSkill('/plugins/my-plugin/skills/skill-b/SKILL.md', 'skill-b'),148],149});150const locations = await service.getPluginLocations(CancellationToken.None);151expect(locations).toHaveLength(1);152expect(locations[0].path).toBe('/plugins/my-plugin');153});154155it('filters out non-file-scheme skills', async () => {156const service = createService({157skills: [mockSkill('copilot-skill:/remote/skills/my-skill/SKILL.md', 'remote')],158});159const locations = await service.getPluginLocations(CancellationToken.None);160expect(locations).toHaveLength(0);161});162163it('filters out skills inside .claude directories', async () => {164const service = createService({165skills: [mockSkill('/projects/my-project/.claude/skills/my-skill/SKILL.md', 'my-skill')],166});167const locations = await service.getPluginLocations(CancellationToken.None);168expect(locations).toHaveLength(0);169});170171// #endregion172173// #region Plugin roots from prompts service174175it('includes plugin roots from prompts service', async () => {176const service = createService({177plugins: [mockPlugin('/plugins/external-plugin')],178});179const locations = await service.getPluginLocations(CancellationToken.None);180expect(locations).toHaveLength(1);181expect(locations[0].path).toBe('/plugins/external-plugin');182});183184it('filters out non-file-scheme plugins', async () => {185const service = createService({186plugins: [mockPlugin('copilot-plugin:/remote/plugin')],187});188const locations = await service.getPluginLocations(CancellationToken.None);189expect(locations).toHaveLength(0);190});191192it('filters out plugins inside .claude directories', async () => {193const service = createService({194plugins: [mockPlugin('/projects/my-project/.claude')],195});196const locations = await service.getPluginLocations(CancellationToken.None);197expect(locations).toHaveLength(0);198});199200// #endregion201202// #region Deduplication across all sources203204it('deduplicates across config locations, skills, and plugins', async () => {205const service = createService({206configLocations: { '/my-plugin/skills': true },207skills: [mockSkill('/my-plugin/skills/skill-a/SKILL.md', 'skill-a')],208plugins: [mockPlugin('/my-plugin')],209});210const locations = await service.getPluginLocations(CancellationToken.None);211expect(locations).toHaveLength(1);212expect(locations[0].path).toBe('/my-plugin');213});214215it('combines distinct locations from all sources', async () => {216const service = createService({217configLocations: { '/config-plugin/skills': true },218skills: [mockSkill('/skill-plugin/skills/my-skill/SKILL.md', 'my-skill')],219plugins: [mockPlugin('/direct-plugin')],220});221const locations = await service.getPluginLocations(CancellationToken.None);222const paths = locations.map(l => l.path);223expect(paths).toContain('/config-plugin');224expect(paths).toContain('/skill-plugin');225expect(paths).toContain('/direct-plugin');226expect(locations).toHaveLength(3);227});228229// #endregion230});231232233