/** * @license * Copyright 2025 Qwen * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; import { SkillManager } from './skill-manager.js'; import { type SkillConfig, SkillError } from './types.js'; import type { Config } from '../config/config.js'; import { makeFakeConfig } from '../test-utils/config.js'; // Mock file system operations vi.mock('fs/promises'); vi.mock('os'); // Mock yaml parser - use vi.hoisted for proper hoisting const mockParseYaml = vi.hoisted(() => vi.fn()); vi.mock('../utils/yaml-parser.js', () => ({ parse: mockParseYaml, stringify: vi.fn(), })); describe('SkillManager', () => { let manager: SkillManager; let mockConfig: Config; beforeEach(() => { // Create mock Config object using test utility mockConfig = makeFakeConfig({}); // Mock the project root method vi.spyOn(mockConfig, 'getProjectRoot').mockReturnValue('/test/project'); // Mock os.homedir vi.mocked(os.homedir).mockReturnValue('/home/user'); // Reset and setup mocks vi.clearAllMocks(); // Setup yaml parser mocks with sophisticated behavior mockParseYaml.mockImplementation((yamlString: string) => { // Handle different test cases based on YAML content if (yamlString.includes('allowedTools:')) { return { name: 'test-skill', description: 'A test skill', allowedTools: ['read_file', 'write_file'], }; } if (yamlString.includes('name: skill1')) { return { name: 'skill1', description: 'First skill' }; } if (yamlString.includes('name: skill2')) { return { name: 'skill2', description: 'Second skill' }; } if (yamlString.includes('name: skill3')) { return { name: 'skill3', description: 'Third skill' }; } if (yamlString.includes('name: symlink-skill')) { return { name: 'symlink-skill', description: 'A skill loaded from symlink', }; } if (yamlString.includes('A symlinked skill')) { return { name: 'symlink-skill', description: 'A symlinked skill' }; } if (yamlString.includes('name: regular-skill')) { return { name: 'regular-skill', description: 'A regular skill' }; } if (yamlString.includes('name: shared-skill')) { const desc = yamlString.includes('From qwen dir') ? 'From qwen dir' : yamlString.includes('From agent dir') ? 'From agent dir' : 'A shared skill'; return { name: 'shared-skill', description: desc }; } if (!yamlString.includes('name:')) { return { description: 'A test skill' }; // Missing name case } if (!yamlString.includes('description:')) { return { name: 'test-skill' }; // Missing description case } // Default case return { name: 'test-skill', description: 'A test skill', }; }); manager = new SkillManager(mockConfig); }); afterEach(() => { vi.restoreAllMocks(); }); const validSkillConfig: SkillConfig = { name: 'test-skill', description: 'A test skill', level: 'project', filePath: '/test/project/.qwen/skills/test-skill/SKILL.md', body: 'You are a helpful assistant with this skill.', }; const validMarkdown = `--- name: test-skill description: A test skill --- You are a helpful assistant with this skill. `; describe('parseSkillContent', () => { it('should parse valid markdown content', () => { const config = manager.parseSkillContent( validMarkdown, validSkillConfig.filePath, 'project', ); expect(config.name).toBe('test-skill'); expect(config.description).toBe('A test skill'); expect(config.body).toBe('You are a helpful assistant with this skill.'); expect(config.level).toBe('project'); expect(config.filePath).toBe(validSkillConfig.filePath); }); it('should parse markdown with CRLF line endings', () => { const markdownCrlf = `---\r name: test-skill\r description: A test skill\r ---\r \r You are a helpful assistant with this skill.\r `; const config = manager.parseSkillContent( markdownCrlf, validSkillConfig.filePath, 'project', ); expect(config.name).toBe('test-skill'); expect(config.description).toBe('A test skill'); expect(config.body).toBe('You are a helpful assistant with this skill.'); }); it('should parse markdown with UTF-8 BOM', () => { const markdownWithBom = `\uFEFF--- name: test-skill description: A test skill --- You are a helpful assistant with this skill. `; const config = manager.parseSkillContent( markdownWithBom, validSkillConfig.filePath, 'project', ); expect(config.name).toBe('test-skill'); expect(config.description).toBe('A test skill'); }); it('should parse markdown when body is empty and file ends after frontmatter', () => { const frontmatterOnly = `--- name: test-skill description: A test skill ---`; const config = manager.parseSkillContent( frontmatterOnly, validSkillConfig.filePath, 'project', ); expect(config.name).toBe('test-skill'); expect(config.description).toBe('A test skill'); expect(config.body).toBe(''); }); it('should parse content with allowedTools', () => { const markdownWithTools = `--- name: test-skill description: A test skill allowedTools: - read_file - write_file --- You are a helpful assistant with this skill. `; const config = manager.parseSkillContent( markdownWithTools, validSkillConfig.filePath, 'project', ); expect(config.allowedTools).toEqual(['read_file', 'write_file']); }); it('should determine level from file path', () => { const projectPath = '/test/project/.qwen/skills/test-skill/SKILL.md'; const userPath = '/home/user/.qwen/skills/test-skill/SKILL.md'; const projectConfig = manager.parseSkillContent( validMarkdown, projectPath, 'project', ); const userConfig = manager.parseSkillContent( validMarkdown, userPath, 'user', ); expect(projectConfig.level).toBe('project'); expect(userConfig.level).toBe('user'); }); it('should throw error for invalid frontmatter format', () => { const invalidMarkdown = `No frontmatter here Just content`; expect(() => manager.parseSkillContent( invalidMarkdown, validSkillConfig.filePath, 'project', ), ).toThrow(SkillError); }); it('should throw error for missing name', () => { const markdownWithoutName = `--- description: A test skill --- You are a helpful assistant. `; expect(() => manager.parseSkillContent( markdownWithoutName, validSkillConfig.filePath, 'project', ), ).toThrow(SkillError); }); it('should throw error for missing description', () => { const markdownWithoutDescription = `--- name: test-skill --- You are a helpful assistant. `; expect(() => manager.parseSkillContent( markdownWithoutDescription, validSkillConfig.filePath, 'project', ), ).toThrow(SkillError); }); }); describe('validateConfig', () => { it('should validate valid configuration', () => { const result = manager.validateConfig(validSkillConfig); expect(result.isValid).toBe(true); expect(result.errors).toHaveLength(0); }); it('should report error for missing name', () => { const invalidConfig = { ...validSkillConfig, name: '' }; const result = manager.validateConfig(invalidConfig); expect(result.isValid).toBe(false); expect(result.errors).toContain('"name" cannot be empty'); }); it('should report error for missing description', () => { const invalidConfig = { ...validSkillConfig, description: '' }; const result = manager.validateConfig(invalidConfig); expect(result.isValid).toBe(false); expect(result.errors).toContain('"description" cannot be empty'); }); it('should report error for invalid allowedTools type', () => { const invalidConfig = { ...validSkillConfig, allowedTools: 'not-an-array' as unknown as string[], }; const result = manager.validateConfig(invalidConfig); expect(result.isValid).toBe(false); expect(result.errors).toContain('"allowedTools" must be an array'); }); it('should warn for empty body', () => { const configWithEmptyBody = { ...validSkillConfig, body: '' }; const result = manager.validateConfig(configWithEmptyBody); expect(result.isValid).toBe(true); // Still valid expect(result.warnings).toContain('Skill body is empty'); }); }); describe('loadSkill', () => { it('should load skill from project level first', async () => { vi.mocked(fs.readdir).mockResolvedValue([ { name: 'test-skill', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, }, ] as unknown as Awaited>); vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.readFile).mockResolvedValue(validMarkdown); const config = await manager.loadSkill('test-skill'); expect(config).toBeDefined(); expect(config!.name).toBe('test-skill'); }); it('should fall back to user level if project level fails', async () => { vi.mocked(fs.readdir) .mockRejectedValueOnce(new Error('Project dir not found')) // project level fails .mockResolvedValueOnce([ { name: 'test-skill', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, }, ] as unknown as Awaited>); // user level succeeds vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.readFile).mockResolvedValue(validMarkdown); const config = await manager.loadSkill('test-skill'); expect(config).toBeDefined(); expect(config!.name).toBe('test-skill'); }); it('should return null if not found at either level', async () => { vi.mocked(fs.readdir).mockRejectedValue(new Error('Directory not found')); const config = await manager.loadSkill('nonexistent'); expect(config).toBeNull(); }); }); describe('loadSkillForRuntime', () => { it('should load skill for runtime', async () => { vi.mocked(fs.readdir).mockResolvedValueOnce([ { name: 'test-skill', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, }, ] as unknown as Awaited>); vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.readFile).mockResolvedValue(validMarkdown); // SKILL.md const config = await manager.loadSkillForRuntime('test-skill'); expect(config).toBeDefined(); expect(config!.name).toBe('test-skill'); }); it('should return null if skill not found', async () => { vi.mocked(fs.readdir).mockRejectedValue(new Error('Directory not found')); const config = await manager.loadSkillForRuntime('nonexistent'); expect(config).toBeNull(); }); }); describe('listSkills', () => { beforeEach(() => { // Mock directory listing based on path to handle multiple base dirs per level. // Use path.join to construct expected paths so separators match on all platforms. const projectQwenSkillsDir = path.join( '/test/project', '.qwen', 'skills', ); const userQwenSkillsDir = path.join('/home/user', '.qwen', 'skills'); // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.mocked(fs.readdir).mockImplementation((dirPath: any) => { const pathStr = String(dirPath); if (pathStr === projectQwenSkillsDir) { return Promise.resolve([ { name: 'skill1', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, }, { name: 'skill2', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, }, { name: 'not-a-dir.txt', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false, }, ] as unknown as Awaited>); } if (pathStr === userQwenSkillsDir) { return Promise.resolve([ { name: 'skill3', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, }, { name: 'skill1', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, }, ] as unknown as Awaited>); } // Other provider dirs (.agent, .cursor, .codex, .claude) return empty return Promise.resolve( [] as unknown as Awaited>, ); }); vi.mocked(fs.access).mockResolvedValue(undefined); // Mock file reading for valid skills vi.mocked(fs.readFile).mockImplementation((filePath) => { const pathStr = String(filePath); if (pathStr.includes('skill1')) { return Promise.resolve(`--- name: skill1 description: First skill --- Skill 1 content`); } else if (pathStr.includes('skill2')) { return Promise.resolve(`--- name: skill2 description: Second skill --- Skill 2 content`); } else if (pathStr.includes('skill3')) { return Promise.resolve(`--- name: skill3 description: Third skill --- Skill 3 content`); } return Promise.reject(new Error('File not found')); }); }); it('should list skills from both levels', async () => { const skills = await manager.listSkills(); expect(skills).toHaveLength(3); // skill1 (project takes precedence), skill2, skill3 expect(skills.map((s) => s.name).sort()).toEqual([ 'skill1', 'skill2', 'skill3', ]); }); it('should prioritize project level over user level', async () => { const skills = await manager.listSkills(); const skill1 = skills.find((s) => s.name === 'skill1'); expect(skill1!.level).toBe('project'); }); it('should filter by level', async () => { const projectSkills = await manager.listSkills({ level: 'project', }); expect(projectSkills).toHaveLength(2); // skill1, skill2 expect(projectSkills.every((s) => s.level === 'project')).toBe(true); }); it('should deduplicate same-name skills across provider dirs within a level', async () => { // Override readdir to return the same skill name from both .qwen and .agent dirs vi.mocked(fs.readdir).mockReset(); const projectQwenDir = path.join('/test/project', '.qwen', 'skills'); const projectAgentDir = path.join('/test/project', '.agent', 'skills'); // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.mocked(fs.readdir).mockImplementation((dirPath: any) => { const pathStr = String(dirPath); if (pathStr === projectQwenDir) { return Promise.resolve([ { name: 'shared-skill', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, }, ] as unknown as Awaited>); } if (pathStr === projectAgentDir) { return Promise.resolve([ { name: 'shared-skill', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, }, ] as unknown as Awaited>); } return Promise.resolve( [] as unknown as Awaited>, ); }); vi.mocked(fs.readFile).mockImplementation((filePath) => { const pathStr = String(filePath); if (pathStr.includes('.qwen') && pathStr.includes('shared-skill')) { return Promise.resolve( `---\nname: shared-skill\ndescription: From qwen dir\n---\nQwen content`, ); } if (pathStr.includes('.agent') && pathStr.includes('shared-skill')) { return Promise.resolve( `---\nname: shared-skill\ndescription: From agent dir\n---\nAgent content`, ); } return Promise.reject(new Error('File not found')); }); const skills = await manager.listSkills({ level: 'project', force: true, }); // Only one instance should remain, from .qwen (first in PROVIDER_CONFIG_DIRS) expect(skills).toHaveLength(1); expect(skills[0].name).toBe('shared-skill'); expect(skills[0].description).toBe('From qwen dir'); }); it('should handle empty directories', async () => { vi.mocked(fs.readdir).mockReset(); vi.mocked(fs.readdir).mockResolvedValue( [] as unknown as Awaited>, ); const skills = await manager.listSkills({ force: true }); expect(skills).toHaveLength(0); }); it('should handle directory read errors', async () => { vi.mocked(fs.readdir).mockReset(); vi.mocked(fs.readdir).mockRejectedValue(new Error('Directory not found')); const skills = await manager.listSkills({ force: true }); expect(skills).toHaveLength(0); }); }); describe('getSkillsBaseDirs', () => { it('should return all project-level base dirs', () => { const baseDirs = manager.getSkillsBaseDirs('project'); expect(baseDirs).toHaveLength(2); expect(baseDirs).toContain(path.join('/test/project', '.qwen', 'skills')); expect(baseDirs).toContain( path.join('/test/project', '.agent', 'skills'), ); }); it('should return all user-level base dirs', () => { const baseDirs = manager.getSkillsBaseDirs('user'); expect(baseDirs).toHaveLength(2); expect(baseDirs).toContain(path.join('/home/user', '.qwen', 'skills')); expect(baseDirs).toContain(path.join('/home/user', '.agent', 'skills')); }); it('should return bundled-level base dir', () => { const baseDirs = manager.getSkillsBaseDirs('bundled'); expect(baseDirs[0]).toMatch(/skills[/\\]bundled$/); }); it('should throw for extension level', () => { expect(() => manager.getSkillsBaseDirs('extension')).toThrow( 'Extension skills do not have a base directory', ); }); }); describe('bundled skills', () => { const bundledDirSegment = path.join('skills', 'bundled'); const projectDirSegment = path.join('.qwen', 'skills'); const userDirSegment = path.join('.qwen', 'skills'); const projectPrefix = path.join('/test/project'); const userPrefix = path.join('/home/user'); const reviewDirEntry = { name: 'review', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, }; const emptyDir = [] as unknown as Awaited>; function mockReaddirForLevels(levels: Set) { vi.mocked(fs.readdir).mockImplementation((dirPath) => { const pathStr = String(dirPath); const isBundled = pathStr.endsWith(bundledDirSegment) && !pathStr.includes('.qwen'); const isProject = pathStr.includes(projectDirSegment) && pathStr.startsWith(projectPrefix); const isUser = pathStr.includes(userDirSegment) && pathStr.startsWith(userPrefix); if ( (levels.has('bundled') && isBundled) || (levels.has('project') && isProject) || (levels.has('user') && isUser) ) { return Promise.resolve([reviewDirEntry] as unknown as Awaited< ReturnType >); } return Promise.resolve(emptyDir); }); } function setupReviewSkillMocks() { vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.readFile).mockResolvedValue(`--- name: review description: Review code changes --- Review content`); mockParseYaml.mockReturnValue({ name: 'review', description: 'Review code changes', }); } it('should load bundled skills in listSkills', async () => { mockReaddirForLevels(new Set(['bundled'])); setupReviewSkillMocks(); const skills = await manager.listSkills({ force: true }); expect(skills.some((s) => s.name === 'review')).toBe(true); const reviewSkill = skills.find((s) => s.name === 'review'); expect(reviewSkill!.level).toBe('bundled'); }); it('should prioritize project-level over bundled skills with same name', async () => { mockReaddirForLevels(new Set(['project', 'bundled'])); setupReviewSkillMocks(); const skills = await manager.listSkills({ force: true }); const reviewSkills = skills.filter((s) => s.name === 'review'); expect(reviewSkills).toHaveLength(1); expect(reviewSkills[0].level).toBe('project'); }); it('should prioritize user-level over bundled skills with same name', async () => { mockReaddirForLevels(new Set(['user', 'bundled'])); setupReviewSkillMocks(); const skills = await manager.listSkills({ force: true }); const reviewSkills = skills.filter((s) => s.name === 'review'); expect(reviewSkills).toHaveLength(1); expect(reviewSkills[0].level).toBe('user'); }); it('should fall back to bundled level in loadSkill', async () => { // Project, user, extension all empty; bundled has the skill mockReaddirForLevels(new Set(['bundled'])); setupReviewSkillMocks(); const skill = await manager.loadSkill('review'); expect(skill).toBeDefined(); expect(skill!.name).toBe('review'); expect(skill!.level).toBe('bundled'); }); }); describe('change listeners', () => { it('should notify listeners when cache is refreshed', async () => { const listener = vi.fn(); manager.addChangeListener(listener); vi.mocked(fs.readdir).mockResolvedValue( [] as unknown as Awaited>, ); await manager.refreshCache(); expect(listener).toHaveBeenCalled(); }); it('should remove listener when cleanup function is called', async () => { const listener = vi.fn(); const removeListener = manager.addChangeListener(listener); removeListener(); vi.mocked(fs.readdir).mockResolvedValue( [] as unknown as Awaited>, ); await manager.refreshCache(); expect(listener).not.toHaveBeenCalled(); }); }); describe('parse errors', () => { it('should track parse errors', async () => { vi.mocked(fs.readdir).mockResolvedValue([ { name: 'bad-skill', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, }, ] as unknown as Awaited>); vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.readFile).mockResolvedValue( 'invalid content without frontmatter', ); await manager.listSkills({ force: true }); const errors = manager.getParseErrors(); expect(errors.size).toBeGreaterThan(0); }); }); describe('symlink support', () => { it('should load skills from symlinked directories', async () => { vi.mocked(fs.readdir).mockResolvedValue([ { name: 'symlink-skill', isDirectory: () => false, isSymbolicLink: () => true, isFile: () => false, }, ] as unknown as Awaited>); // Mock fs.stat to return directory stats for the symlink target vi.mocked(fs.stat).mockResolvedValue({ isDirectory: () => true, } as Awaited>); vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.readFile).mockResolvedValue(`--- name: symlink-skill description: A skill loaded from symlink --- Symlink skill content`); const skills = await manager.listSkills({ force: true }); expect(skills).toHaveLength(1); expect(skills[0].name).toBe('symlink-skill'); expect(skills[0].description).toBe('A skill loaded from symlink'); }); it('should skip symlinks that point to non-directory targets', async () => { vi.mocked(fs.readdir).mockResolvedValue([ { name: 'bad-symlink', isDirectory: () => false, isSymbolicLink: () => true, isFile: () => false, }, ] as unknown as Awaited>); // Mock fs.stat to return file stats (not a directory) vi.mocked(fs.stat).mockResolvedValue({ isDirectory: () => false, } as Awaited>); const skills = await manager.listSkills({ force: true }); expect(skills).toHaveLength(0); }); it('should skip broken/invalid symlinks', async () => { vi.mocked(fs.readdir).mockResolvedValue([ { name: 'broken-symlink', isDirectory: () => false, isSymbolicLink: () => true, isFile: () => false, }, ] as unknown as Awaited>); // Mock fs.stat to throw error (symlink target doesn't exist) vi.mocked(fs.stat).mockRejectedValue( new Error('ENOENT: no such file or directory'), ); const skills = await manager.listSkills({ force: true }); expect(skills).toHaveLength(0); }); it('should load skills from both regular directories and symlinks', async () => { vi.mocked(fs.readdir).mockResolvedValue([ { name: 'regular-skill', isDirectory: () => true, isSymbolicLink: () => false, isFile: () => false, }, { name: 'symlink-skill', isDirectory: () => false, isSymbolicLink: () => true, isFile: () => false, }, ] as unknown as Awaited>); // Mock fs.stat to return directory stats for the symlink vi.mocked(fs.stat).mockResolvedValue({ isDirectory: () => true, } as Awaited>); vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.readFile).mockImplementation((filePath) => { const pathStr = String(filePath); if (pathStr.includes('regular-skill')) { return Promise.resolve(`--- name: regular-skill description: A regular skill --- Regular skill content`); } else if (pathStr.includes('symlink-skill')) { return Promise.resolve(`--- name: symlink-skill description: A symlinked skill --- Symlinked skill content`); } return Promise.reject(new Error('File not found')); }); const skills = await manager.listSkills({ force: true }); expect(skills).toHaveLength(2); expect(skills.map((s) => s.name).sort()).toEqual([ 'regular-skill', 'symlink-skill', ]); }); }); });