qwen-code/packages/core/src/skills/skill-manager.test.ts
2026-03-18 20:10:31 +08:00

897 lines
27 KiB
TypeScript

/**
* @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<ReturnType<typeof fs.readdir>>);
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<ReturnType<typeof fs.readdir>>); // 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<ReturnType<typeof fs.readdir>>);
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<ReturnType<typeof fs.readdir>>);
}
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<ReturnType<typeof fs.readdir>>);
}
// Other provider dirs (.agent, .cursor, .codex, .claude) return empty
return Promise.resolve(
[] as unknown as Awaited<ReturnType<typeof fs.readdir>>,
);
});
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<ReturnType<typeof fs.readdir>>);
}
if (pathStr === projectAgentDir) {
return Promise.resolve([
{
name: 'shared-skill',
isDirectory: () => true,
isFile: () => false,
isSymbolicLink: () => false,
},
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
}
return Promise.resolve(
[] as unknown as Awaited<ReturnType<typeof fs.readdir>>,
);
});
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<ReturnType<typeof fs.readdir>>,
);
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<ReturnType<typeof fs.readdir>>;
function mockReaddirForLevels(levels: Set<string>) {
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<typeof fs.readdir>
>);
}
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<ReturnType<typeof fs.readdir>>,
);
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<ReturnType<typeof fs.readdir>>,
);
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<ReturnType<typeof fs.readdir>>);
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<ReturnType<typeof fs.readdir>>);
// Mock fs.stat to return directory stats for the symlink target
vi.mocked(fs.stat).mockResolvedValue({
isDirectory: () => true,
} as Awaited<ReturnType<typeof fs.stat>>);
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<ReturnType<typeof fs.readdir>>);
// Mock fs.stat to return file stats (not a directory)
vi.mocked(fs.stat).mockResolvedValue({
isDirectory: () => false,
} as Awaited<ReturnType<typeof fs.stat>>);
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<ReturnType<typeof fs.readdir>>);
// 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<ReturnType<typeof fs.readdir>>);
// Mock fs.stat to return directory stats for the symlink
vi.mocked(fs.stat).mockResolvedValue({
isDirectory: () => true,
} as Awaited<ReturnType<typeof fs.stat>>);
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',
]);
});
});
});