723558ff22
- 新增 Skill 类型定义和加载器,支持 YAML/JSON/Markdown 格式 - 实现 Skill 注册表,支持搜索、分类和优先级覆盖 - 添加 8 个内置 Skills: code-review, explain-code, generate-docs 等 - 创建 skill 和 skill_search 工具供 Agent 调用 - 支持从用户目录和项目目录加载自定义 Skills - 添加完整的单元测试覆盖
194 lines
5.8 KiB
TypeScript
194 lines
5.8 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
import { SkillLoader } from '../../../src/skills/loader.js';
|
||
|
||
// Mock fs/promises
|
||
vi.mock('fs/promises', () => ({
|
||
access: vi.fn(),
|
||
readdir: vi.fn(),
|
||
readFile: vi.fn(),
|
||
}));
|
||
|
||
import * as fs from 'fs/promises';
|
||
|
||
describe('SkillLoader - Skill 加载器', () => {
|
||
let loader: SkillLoader;
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
loader = new SkillLoader();
|
||
});
|
||
|
||
describe('loadFromFile - 从文件加载', () => {
|
||
it('加载 YAML 格式的 Skill', async () => {
|
||
const yamlContent = `
|
||
skill:
|
||
name: test-skill
|
||
description: 测试 Skill
|
||
promptTemplate: "提示模板 {{param}}"
|
||
parameters:
|
||
param:
|
||
type: string
|
||
required: true
|
||
description: 参数描述
|
||
`;
|
||
|
||
vi.mocked(fs.readFile).mockResolvedValue(yamlContent);
|
||
|
||
const skill = await loader.loadFromFile('/test/skill.yaml', 'user');
|
||
|
||
expect(skill).not.toBeNull();
|
||
expect(skill?.name).toBe('test-skill');
|
||
expect(skill?.description).toBe('测试 Skill');
|
||
expect(skill?.source).toBe('user');
|
||
expect(skill?.parameters?.param.required).toBe(true);
|
||
});
|
||
|
||
it('加载 JSON 格式的 Skill', async () => {
|
||
const jsonContent = JSON.stringify({
|
||
skill: {
|
||
name: 'json-skill',
|
||
description: 'JSON Skill',
|
||
promptTemplate: '提示',
|
||
},
|
||
});
|
||
|
||
vi.mocked(fs.readFile).mockResolvedValue(jsonContent);
|
||
|
||
const skill = await loader.loadFromFile('/test/skill.json', 'project');
|
||
|
||
expect(skill).not.toBeNull();
|
||
expect(skill?.name).toBe('json-skill');
|
||
expect(skill?.source).toBe('project');
|
||
});
|
||
|
||
it('加载 Markdown 格式的 Skill(带 frontmatter)', async () => {
|
||
const mdContent = `---
|
||
name: md-skill
|
||
description: Markdown Skill
|
||
parameters:
|
||
code:
|
||
type: string
|
||
required: true
|
||
description: 代码
|
||
---
|
||
|
||
# 代码审查
|
||
|
||
请审查以下代码:
|
||
|
||
{{code}}
|
||
`;
|
||
|
||
vi.mocked(fs.readFile).mockResolvedValue(mdContent);
|
||
|
||
const skill = await loader.loadFromFile('/test/skill.md', 'user');
|
||
|
||
expect(skill).not.toBeNull();
|
||
expect(skill?.name).toBe('md-skill');
|
||
expect(skill?.description).toBe('Markdown Skill');
|
||
expect(skill?.promptTemplate).toContain('{{code}}');
|
||
expect(skill?.promptTemplate).toContain('代码审查');
|
||
});
|
||
|
||
it('加载 Markdown 格式的 Skill(无 frontmatter)', async () => {
|
||
const mdContent = `# 简单提示
|
||
|
||
这是一个简单的提示模板。
|
||
|
||
{{input}}
|
||
`;
|
||
|
||
vi.mocked(fs.readFile).mockResolvedValue(mdContent);
|
||
|
||
const skill = await loader.loadFromFile('/test/simple.md', 'user');
|
||
|
||
expect(skill).not.toBeNull();
|
||
expect(skill?.name).toBe('simple'); // 从文件名获取
|
||
expect(skill?.promptTemplate).toContain('简单提示');
|
||
});
|
||
|
||
it('缺少必需字段时返回 null', async () => {
|
||
const yamlContent = `
|
||
skill:
|
||
description: 没有 name 和 promptTemplate
|
||
`;
|
||
|
||
vi.mocked(fs.readFile).mockResolvedValue(yamlContent);
|
||
|
||
const skill = await loader.loadFromFile('/test/invalid.yaml', 'user');
|
||
|
||
expect(skill).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('loadFromDirectory - 从目录加载', () => {
|
||
it('目录不存在时返回空数组', async () => {
|
||
vi.mocked(fs.access).mockRejectedValue(new Error('Not found'));
|
||
|
||
const skills = await loader.loadFromDirectory('/non-existent', 'user');
|
||
|
||
expect(skills).toEqual([]);
|
||
});
|
||
|
||
it('加载目录中的所有 Skill 文件', async () => {
|
||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||
vi.mocked(fs.readdir).mockResolvedValue([
|
||
{ name: 'skill1.yaml', isFile: () => true, isDirectory: () => false },
|
||
{ name: 'skill2.json', isFile: () => true, isDirectory: () => false },
|
||
{ name: 'readme.txt', isFile: () => true, isDirectory: () => false }, // 不支持的格式
|
||
] as any);
|
||
|
||
vi.mocked(fs.readFile)
|
||
.mockResolvedValueOnce(`skill:\n name: skill1\n description: 1\n promptTemplate: t1`)
|
||
.mockResolvedValueOnce(JSON.stringify({ skill: { name: 'skill2', description: '2', promptTemplate: 't2' } }));
|
||
|
||
const skills = await loader.loadFromDirectory('/test', 'user');
|
||
|
||
expect(skills.length).toBe(2);
|
||
expect(skills.map((s) => s.name)).toContain('skill1');
|
||
expect(skills.map((s) => s.name)).toContain('skill2');
|
||
});
|
||
|
||
it('递归加载子目录', async () => {
|
||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||
vi.mocked(fs.readdir)
|
||
.mockResolvedValueOnce([
|
||
{ name: 'subdir', isFile: () => false, isDirectory: () => true },
|
||
{ name: 'root.yaml', isFile: () => true, isDirectory: () => false },
|
||
] as any)
|
||
.mockResolvedValueOnce([
|
||
{ name: 'sub.yaml', isFile: () => true, isDirectory: () => false },
|
||
] as any);
|
||
|
||
vi.mocked(fs.readFile)
|
||
.mockResolvedValueOnce(`skill:\n name: root\n description: root\n promptTemplate: root`)
|
||
.mockResolvedValueOnce(`skill:\n name: sub\n description: sub\n promptTemplate: sub`);
|
||
|
||
const skills = await loader.loadFromDirectory('/test', 'user');
|
||
|
||
expect(skills.length).toBe(2);
|
||
expect(skills.map((s) => s.name)).toContain('root');
|
||
expect(skills.map((s) => s.name)).toContain('sub');
|
||
});
|
||
});
|
||
|
||
describe('目录路径', () => {
|
||
it('getUserSkillsDir 返回用户 Skills 目录', () => {
|
||
const originalHome = process.env.HOME;
|
||
process.env.HOME = '/home/testuser';
|
||
|
||
const dir = loader.getUserSkillsDir();
|
||
|
||
expect(dir).toBe('/home/testuser/.config/ai-terminal/skills');
|
||
|
||
process.env.HOME = originalHome;
|
||
});
|
||
|
||
it('getProjectSkillsDir 返回项目 Skills 目录', () => {
|
||
const dir = loader.getProjectSkillsDir('/workspace');
|
||
|
||
expect(dir).toBe('/workspace/.ai-terminal/skills');
|
||
});
|
||
});
|
||
});
|