a476a4240c
实现类似 OpenCode 的 Commands 功能: - 支持 Markdown 格式定义命令,带 YAML frontmatter - 变量替换:$ARGUMENTS, $1/$2, @filepath, !`shell` - 三级加载:builtin < user < project - 7 个内置命令:init, review, test, fix, explain, commit, help - 集成终端 UI 支持 /commands 列表和命令执行 - 完整单元测试覆盖 (46 tests)
297 lines
8.3 KiB
TypeScript
297 lines
8.3 KiB
TypeScript
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
import * as fs from 'fs/promises';
|
|
import * as childProcess from 'child_process';
|
|
import {
|
|
CommandExecutor,
|
|
createCommandExecutor,
|
|
getCommandRegistry,
|
|
resetCommandRegistry,
|
|
} from '../../../src/commands/index.js';
|
|
import type { Command } from '../../../src/commands/types.js';
|
|
|
|
// Mock fs
|
|
vi.mock('fs/promises', () => ({
|
|
readFile: vi.fn(),
|
|
}));
|
|
|
|
// Mock child_process
|
|
vi.mock('child_process', () => ({
|
|
exec: vi.fn(),
|
|
}));
|
|
|
|
// Mock util.promisify to return our mocked exec
|
|
vi.mock('util', () => ({
|
|
promisify: (fn: unknown) => {
|
|
if (fn === childProcess.exec) {
|
|
return vi.fn().mockImplementation((cmd: string, _options: unknown) => {
|
|
// 默认返回命令输出
|
|
return Promise.resolve({ stdout: `output of: ${cmd}`, stderr: '' });
|
|
});
|
|
}
|
|
return fn;
|
|
},
|
|
}));
|
|
|
|
// Mock loader and builtin commands
|
|
vi.mock('../../../src/commands/loader.js', () => ({
|
|
commandLoader: {
|
|
loadFromDirectory: vi.fn().mockResolvedValue([]),
|
|
getUserCommandsDir: vi.fn().mockReturnValue('/mock/user/commands'),
|
|
getProjectCommandsDir: vi.fn().mockReturnValue('/mock/project/commands'),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../../src/commands/builtin/index.js', () => ({
|
|
builtinCommands: [],
|
|
}));
|
|
|
|
describe('CommandExecutor - Command 执行器', () => {
|
|
let executor: CommandExecutor;
|
|
|
|
beforeEach(() => {
|
|
resetCommandRegistry();
|
|
executor = new CommandExecutor('/test/workdir');
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('parseInput - 解析输入', () => {
|
|
it('解析简单命令', () => {
|
|
const result = executor.parseInput('/test');
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.command).toBe('test');
|
|
expect(result?.arguments).toBe('');
|
|
expect(result?.args).toEqual([]);
|
|
});
|
|
|
|
it('解析带参数的命令', () => {
|
|
const result = executor.parseInput('/review main..feature');
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.command).toBe('review');
|
|
expect(result?.arguments).toBe('main..feature');
|
|
expect(result?.args).toEqual(['main..feature']);
|
|
});
|
|
|
|
it('解析多个参数', () => {
|
|
const result = executor.parseInput('/deploy staging production');
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.command).toBe('deploy');
|
|
expect(result?.arguments).toBe('staging production');
|
|
expect(result?.args).toEqual(['staging', 'production']);
|
|
});
|
|
|
|
it('解析带引号的参数', () => {
|
|
const result = executor.parseInput('/commit "fix: bug fix"');
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.command).toBe('commit');
|
|
expect(result?.args).toEqual(['fix: bug fix']);
|
|
});
|
|
|
|
it('解析带单引号的参数', () => {
|
|
const result = executor.parseInput("/test 'hello world' foo");
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.args).toEqual(['hello world', 'foo']);
|
|
});
|
|
|
|
it('不是命令时返回 null', () => {
|
|
const result = executor.parseInput('not a command');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('空输入返回 null', () => {
|
|
const result = executor.parseInput('');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('包含工作目录', () => {
|
|
const result = executor.parseInput('/test');
|
|
expect(result?.workdir).toBe('/test/workdir');
|
|
});
|
|
});
|
|
|
|
describe('execute - 执行命令', () => {
|
|
beforeEach(async () => {
|
|
const registry = getCommandRegistry();
|
|
await registry.initialize('/test');
|
|
|
|
// 手动注册测试命令
|
|
const testCommand: Command = {
|
|
name: 'greet',
|
|
description: '打招呼',
|
|
template: 'Hello, $ARGUMENTS!',
|
|
source: 'builtin',
|
|
};
|
|
registry.register(testCommand);
|
|
});
|
|
|
|
it('执行存在的命令', async () => {
|
|
const input = executor.parseInput('/greet World');
|
|
expect(input).not.toBeNull();
|
|
|
|
const result = await executor.execute(input!);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.prompt).toBe('Hello, World!');
|
|
});
|
|
|
|
it('命令不存在时返回错误', async () => {
|
|
const input = executor.parseInput('/nonexistent');
|
|
expect(input).not.toBeNull();
|
|
|
|
const result = await executor.execute(input!);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Command 不存在');
|
|
});
|
|
|
|
it('返回 agent 配置', async () => {
|
|
const registry = getCommandRegistry();
|
|
const agentCommand: Command = {
|
|
name: 'code',
|
|
description: '编码',
|
|
template: 'Write code',
|
|
agent: 'coder',
|
|
source: 'builtin',
|
|
};
|
|
registry.register(agentCommand);
|
|
|
|
const input = executor.parseInput('/code');
|
|
const result = await executor.execute(input!);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.agent).toBe('coder');
|
|
});
|
|
|
|
it('返回 model 配置', async () => {
|
|
const registry = getCommandRegistry();
|
|
const modelCommand: Command = {
|
|
name: 'complex',
|
|
description: '复杂任务',
|
|
template: 'Complex task',
|
|
model: 'opus',
|
|
source: 'builtin',
|
|
};
|
|
registry.register(modelCommand);
|
|
|
|
const input = executor.parseInput('/complex');
|
|
const result = await executor.execute(input!);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.model).toBe('opus');
|
|
});
|
|
});
|
|
|
|
describe('renderTemplate - 模板渲染', () => {
|
|
it('替换 $ARGUMENTS', async () => {
|
|
const input = {
|
|
command: 'test',
|
|
arguments: 'hello world',
|
|
args: ['hello', 'world'],
|
|
workdir: '/test',
|
|
};
|
|
|
|
const result = await executor.renderTemplate('Say: $ARGUMENTS', input);
|
|
expect(result).toBe('Say: hello world');
|
|
});
|
|
|
|
it('替换位置参数 $1(单个参数时获取全部)', async () => {
|
|
const input = {
|
|
command: 'test',
|
|
arguments: 'foo bar',
|
|
args: ['foo', 'bar'],
|
|
workdir: '/test',
|
|
};
|
|
|
|
// 当 $1 是最大参数索引时,获取所有剩余参数
|
|
const result = await executor.renderTemplate('First: $1', input);
|
|
expect(result).toBe('First: foo bar');
|
|
});
|
|
|
|
it('替换多个位置参数', async () => {
|
|
const input = {
|
|
command: 'test',
|
|
arguments: 'a b c',
|
|
args: ['a', 'b', 'c'],
|
|
workdir: '/test',
|
|
};
|
|
|
|
const result = await executor.renderTemplate('$1 and $2', input);
|
|
expect(result).toBe('a and b c');
|
|
});
|
|
|
|
it('最后一个位置参数获取剩余所有', async () => {
|
|
const input = {
|
|
command: 'test',
|
|
arguments: 'a b c d',
|
|
args: ['a', 'b', 'c', 'd'],
|
|
workdir: '/test',
|
|
};
|
|
|
|
const result = await executor.renderTemplate('First: $1, Rest: $2', input);
|
|
expect(result).toBe('First: a, Rest: b c d');
|
|
});
|
|
|
|
it('缺少参数时替换为空字符串', async () => {
|
|
const input = {
|
|
command: 'test',
|
|
arguments: '',
|
|
args: [],
|
|
workdir: '/test',
|
|
};
|
|
|
|
const result = await executor.renderTemplate('Value: $1', input);
|
|
expect(result).toBe('Value: ');
|
|
});
|
|
|
|
it('处理文件引用 @filepath', async () => {
|
|
const mockContent = 'file content here';
|
|
vi.mocked(fs.readFile).mockResolvedValue(mockContent);
|
|
|
|
const input = {
|
|
command: 'test',
|
|
arguments: '',
|
|
args: [],
|
|
workdir: '/test',
|
|
};
|
|
|
|
const result = await executor.renderTemplate('Review: @src/file.ts', input);
|
|
expect(result).toContain('file content here');
|
|
expect(result).toContain('```ts');
|
|
});
|
|
|
|
it('文件不存在时显示提示', async () => {
|
|
vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
|
|
|
|
const input = {
|
|
command: 'test',
|
|
arguments: '',
|
|
args: [],
|
|
workdir: '/test',
|
|
};
|
|
|
|
const result = await executor.renderTemplate('Review: @nonexistent.ts', input);
|
|
expect(result).toContain('[文件不存在: nonexistent.ts]');
|
|
});
|
|
});
|
|
|
|
describe('createCommandExecutor - 工厂函数', () => {
|
|
it('创建执行器实例', () => {
|
|
const exec = createCommandExecutor('/custom/path');
|
|
expect(exec).toBeInstanceOf(CommandExecutor);
|
|
});
|
|
|
|
it('默认使用 process.cwd', () => {
|
|
const exec = createCommandExecutor();
|
|
const input = exec.parseInput('/test');
|
|
expect(input?.workdir).toBe(process.cwd());
|
|
});
|
|
});
|
|
});
|