Files
ai-terminal-assistant/tests/unit/skills/loader.test.ts
T
kurihada 723558ff22 feat: 添加 Skills 系统支持可复用提示模板
- 新增 Skill 类型定义和加载器,支持 YAML/JSON/Markdown 格式
- 实现 Skill 注册表,支持搜索、分类和优先级覆盖
- 添加 8 个内置 Skills: code-review, explain-code, generate-docs 等
- 创建 skill 和 skill_search 工具供 Agent 调用
- 支持从用户目录和项目目录加载自定义 Skills
- 添加完整的单元测试覆盖
2025-12-11 15:56:19 +08:00

194 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
});
});
});