Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCLISkills.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 { 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 { ILogService } from '../../../../../platform/log/common/logService';13import type { IPromptsService } from '../../../../../platform/promptFiles/common/promptsService';14import { IWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';15import { Event } from '../../../../../util/vs/base/common/event';16import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';17import { URI } from '../../../../../util/vs/base/common/uri';18import { createExtensionUnitTestingServices } from '../../../../test/node/services';19import { CopilotCLISkills } from '../copilotCLISkills';20import { MockPromptsService } from '../../../../../platform/promptFiles/test/common/mockPromptsService';21import { CancellationToken } from '../../../../../util/vs/base/common/cancellation';2223const CopilotCLISkillsConstructor = CopilotCLISkills as unknown as new (24logService: ILogService,25instantiationService: unknown,26configurationService: IConfigurationService,27envService: INativeEnvService,28workspaceService: IWorkspaceService,29promptsService: IPromptsService,30) => CopilotCLISkills;3132function createWorkspaceService(folders: URI[] = [URI.file('/workspace')]): IWorkspaceService {33return {34_serviceBrand: undefined,35onDidChangeWorkspaceFolders: Event.None,36getWorkspaceFolders: () => folders,37} as unknown as IWorkspaceService;38}3940describe('CopilotCLISkills', () => {41const disposables = new DisposableStore();42let logService: ILogService;43let baseConfigurationService: IConfigurationService;4445beforeEach(() => {46const services = disposables.add(createExtensionUnitTestingServices());47const accessor = services.createTestingAccessor();48logService = accessor.get(ILogService);49baseConfigurationService = accessor.get(IConfigurationService);50});5152afterEach(() => {53disposables.clear();54});5556function createSkills(options?: {57configLocations?: Record<string, boolean>;58workspaceFolders?: URI[];59skills?: readonly ChatSkill[];60userHome?: URI;61}): CopilotCLISkills {62const configService = new InMemoryConfigurationService(baseConfigurationService);63if (options?.configLocations) {64configService.setNonExtensionConfig(SKILLS_LOCATION_KEY, options.configLocations);65}6667const envService = options?.userHome68? new class extends NullNativeEnvService { override get userHome() { return options.userHome!; } }()69: new NullNativeEnvService();7071const promptsService = disposables.add(new MockPromptsService());72if (options?.skills) {73promptsService.setSkills(options.skills);74}7576const skills = new CopilotCLISkillsConstructor(77logService,78{} as unknown,79configService,80envService,81createWorkspaceService(options?.workspaceFolders),82promptsService,83);84disposables.add(skills);85return skills;86}8788it('returns empty array when no config and no skills', async () => {89const skills = createSkills();90expect((await skills.getSkillsLocations(CancellationToken.None))).toEqual([]);91});9293it('expands tilde-prefixed paths using user home directory', async () => {94const userHome = URI.file('/home/user');95const skills = createSkills({96configLocations: { '~/my-skills': true },97userHome,98});99100const locations = await skills.getSkillsLocations(CancellationToken.None);101expect(locations).toHaveLength(1);102expect(locations[0].path).toBe('/home/user/my-skills');103});104105it('handles absolute paths', async () => {106const skills = createSkills({107configLocations: { '/absolute/skills/path': true },108});109110const locations = await skills.getSkillsLocations(CancellationToken.None);111expect(locations).toHaveLength(1);112expect(locations[0].path).toBe('/absolute/skills/path');113});114115it('joins relative paths to each workspace folder', async () => {116const skills = createSkills({117configLocations: { 'relative/skills': true },118workspaceFolders: [URI.file('/workspace1'), URI.file('/workspace2')],119});120121const locations = await skills.getSkillsLocations(CancellationToken.None);122expect(locations).toHaveLength(2);123expect(locations[0].path).toBe('/workspace1/relative/skills');124expect(locations[1].path).toBe('/workspace2/relative/skills');125});126127it('ignores config entries with value !== true', async () => {128const skills = createSkills({129configLocations: {130'/included': true,131'/excluded': false,132},133});134135const locations = await skills.getSkillsLocations(CancellationToken.None);136expect(locations).toHaveLength(1);137expect(locations[0].path).toBe('/included');138});139140it('includes parent-of-parent directories of file-scheme skills', async () => {141const skills = createSkills({142skills: [143mockSkill('/skills/myskill/SKILL.md', 'myskill'),144],145});146147const locations = await skills.getSkillsLocations(CancellationToken.None);148expect(locations).toHaveLength(1);149expect(locations[0].path).toBe('/skills');150});151152it('filters out non-file-scheme skills', async () => {153const skills = createSkills({154skills: [155mockSkill('copilot-skill:/remote/skill/SKILL.md', 'remoteSkill'),156],157});158159const locations = await skills.getSkillsLocations(CancellationToken.None);160expect(locations).toHaveLength(0);161});162163it('deduplicates locations from config and skills', async () => {164const skills = createSkills({165configLocations: { '/skills': true },166skills: [167// dirname(dirname("/skills/myskill/SKILL.md")) = "/skills"168mockSkill('/skills/myskill/SKILL.md', 'myskill'),169],170});171172const locations = await skills.getSkillsLocations(CancellationToken.None);173expect(locations).toHaveLength(1);174expect(locations[0].path).toBe('/skills');175});176177it('deduplicates duplicate config entries', async () => {178const skills = createSkills({179configLocations: {180'/same/path': true,181'path': true,182},183workspaceFolders: [URI.file('/same')],184});185186const locations = await skills.getSkillsLocations(CancellationToken.None);187// Absolute '/same/path' and relative 'path' joined to workspace '/same'188// both resolve to '/same/path', so the result should be deduplicated.189expect(locations).toHaveLength(1);190expect(locations[0].path).toBe('/same/path');191});192193it('handles multiple skills deriving to same parent directory', async () => {194const skills = createSkills({195skills: [196mockSkill('/skills/skill1/SKILL.md', 'skill1'),197mockSkill('/skills/skill2/SKILL.md', 'skill2'),198],199});200201const locations = await skills.getSkillsLocations(CancellationToken.None);202// Both resolve to /skills via dirname(dirname())203expect(locations).toHaveLength(1);204expect(locations[0].path).toBe('/skills');205});206207it('combines config locations and skills locations', async () => {208const skills = createSkills({209configLocations: { '/config-skills': true },210skills: [211mockSkill('/prompt-skills/myskill/SKILL.md', 'myskill'),212],213});214215const locations = await skills.getSkillsLocations(CancellationToken.None);216expect(locations).toHaveLength(2);217const paths = locations.map(l => l.path);218expect(paths).toContain('/config-skills');219expect(paths).toContain('/prompt-skills');220});221222it('ignores empty or whitespace-only config keys', async () => {223const skills = createSkills({224configLocations: { ' ': true, '': true, '/valid': true },225});226227const locations = await skills.getSkillsLocations(CancellationToken.None);228// Empty string after trim is not absolute, not ~/,229// so goes to relative path. But it's just whitespace.230// The code trims and checks - empty string is not '~/' prefixed, not absolute,231// so it would try to join to workspace folders.232// Let's just verify '/valid' is there233const validLocations = locations.filter(l => l.path.endsWith('/valid'));234expect(validLocations).toHaveLength(1);235});236237it('returns empty when config is not an object', async () => {238const configService = new InMemoryConfigurationService(baseConfigurationService);239configService.setNonExtensionConfig(SKILLS_LOCATION_KEY, 'not-an-object');240241const mockPromptsService = disposables.add(new MockPromptsService());242const skillsService = new CopilotCLISkillsConstructor(243logService,244{} as unknown,245configService,246new NullNativeEnvService(),247createWorkspaceService(),248mockPromptsService,249);250disposables.add(skillsService);251252expect(await skillsService.getSkillsLocations(CancellationToken.None)).toEqual([]);253});254255function mockSkill(uri: string, name: string): ChatSkill {256return {257uri: URI.parse(uri),258name,259} as ChatSkill;260}261});262263264