clawt 2.16.4 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1116 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { Command } from 'commander';
3
+ import { registerCompletionCommand } from '../../../src/commands/completion.js';
4
+ import * as worktreeUtils from '../../../src/utils/worktree.js';
5
+ import * as fs from 'node:fs';
6
+ import { CONFIG_DEFINITIONS } from '../../../src/constants/config.js';
7
+
8
+ // Mock 依赖模块
9
+ vi.mock('node:fs', async () => {
10
+ const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
11
+ return {
12
+ ...actual,
13
+ existsSync: vi.fn(),
14
+ readFileSync: vi.fn(),
15
+ writeFileSync: vi.fn(),
16
+ readdirSync: vi.fn(),
17
+ statSync: vi.fn()
18
+ };
19
+ });
20
+ vi.mock('../../../src/utils/worktree.js');
21
+ vi.mock('../../../src/logger/index.js', () => ({
22
+ logger: {
23
+ success: vi.fn(),
24
+ info: vi.fn(),
25
+ warn: vi.fn(),
26
+ error: vi.fn()
27
+ }
28
+ }));
29
+ vi.mock('../../../src/utils/fs.js', () => ({}));
30
+
31
+ /**
32
+ * 创建 statSync 的 mock 返回值
33
+ * @param {boolean} isDir - 是否为目录
34
+ * @returns {object} mock stat 对象
35
+ */
36
+ function createStatMock(isDir: boolean) {
37
+ return {
38
+ isDirectory: () => isDir,
39
+ isFile: () => !isDir
40
+ } as any;
41
+ }
42
+
43
+ /**
44
+ * 创建带有完整子命令结构的测试用 program 实例
45
+ * 模拟实际 clawt 注册的命令层级,用于补全测试
46
+ * @returns {Command} 配置好的 Commander 实例
47
+ */
48
+ function createTestProgram(): Command {
49
+ const program = new Command();
50
+ program.name('clawt');
51
+
52
+ // 模拟 run 命令及其选项
53
+ program
54
+ .command('run')
55
+ .option('-b, --branch <name>', '指定分支')
56
+ .option('-f, --file <file>', '指定任务文件')
57
+ .option('-c, --concurrency <n>', '并发数')
58
+ .option('-p, --prompt <text>', '任务提示');
59
+
60
+ // 模拟 config 命令及其子命令
61
+ const configCmd = program.command('config');
62
+ configCmd.command('set').description('设置配置项');
63
+ configCmd.command('get').description('获取配置项');
64
+ configCmd.command('list').description('列出所有配置项');
65
+
66
+ // 模拟 create 命令
67
+ program.command('create').option('-b, --branch <name>', '分支名');
68
+
69
+ // 模拟 list 命令(别名 ls)
70
+ program.command('list').alias('ls');
71
+
72
+ // 模拟 remove 命令(别名 rm)
73
+ program.command('remove').alias('rm');
74
+
75
+ // 模拟 merge 命令
76
+ program.command('merge').option('-b, --branch <name>', '分支名');
77
+
78
+ // 模拟 resume 命令
79
+ program.command('resume').option('-b, --branch <name>', '分支名');
80
+
81
+ // 模拟 status 命令(别名 st)
82
+ program.command('status').alias('st');
83
+
84
+ // 模拟 sync 命令
85
+ program.command('sync');
86
+
87
+ // 模拟 reset 命令
88
+ program.command('reset');
89
+
90
+ // 模拟 validate 命令
91
+ program.command('validate');
92
+
93
+ // 模拟 alias 命令
94
+ program.command('alias');
95
+
96
+ // 注册 completion 命令
97
+ registerCompletionCommand(program);
98
+
99
+ return program;
100
+ }
101
+
102
+ describe('Completion Command', () => {
103
+ let program: Command;
104
+ let consoleSpy: ReturnType<typeof vi.spyOn>;
105
+
106
+ beforeEach(() => {
107
+ program = createTestProgram();
108
+ consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
109
+ vi.clearAllMocks();
110
+ // 重新创建 consoleSpy(clearAllMocks 会清除上面的 spy)
111
+ consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
112
+ });
113
+
114
+ afterEach(() => {
115
+ vi.restoreAllMocks();
116
+ });
117
+
118
+ // ==========================================
119
+ // Bash 脚本生成
120
+ // ==========================================
121
+ describe('Bash 脚本生成', () => {
122
+ it('应输出包含 complete 注册语句的 Bash 脚本', () => {
123
+ program.parse(['node', 'test', 'completion', 'bash']);
124
+ expect(consoleSpy).toHaveBeenCalled();
125
+ const script = consoleSpy.mock.calls[0][0];
126
+ expect(script).toContain('complete -o nospace -F _clawt_completion clawt');
127
+ });
128
+
129
+ it('Bash 脚本应包含 _clawt_completion 函数定义', () => {
130
+ program.parse(['node', 'test', 'completion', 'bash']);
131
+ const script = consoleSpy.mock.calls[0][0];
132
+ expect(script).toContain('_clawt_completion()');
133
+ });
134
+
135
+ it('Bash 脚本应调用 clawt completion _complete 获取动态补全', () => {
136
+ program.parse(['node', 'test', 'completion', 'bash']);
137
+ const script = consoleSpy.mock.calls[0][0];
138
+ expect(script).toContain('clawt completion _complete bash');
139
+ });
140
+
141
+ it('Bash 脚本应包含 COMP_CWORD 和 COMP_WORDS 变量引用', () => {
142
+ program.parse(['node', 'test', 'completion', 'bash']);
143
+ const script = consoleSpy.mock.calls[0][0];
144
+ expect(script).toContain('COMP_CWORD');
145
+ expect(script).toContain('COMP_WORDS');
146
+ });
147
+
148
+ it('Bash 脚本应包含 compopt -o nospace 处理目录补全', () => {
149
+ program.parse(['node', 'test', 'completion', 'bash']);
150
+ const script = consoleSpy.mock.calls[0][0];
151
+ expect(script).toContain('compopt -o nospace');
152
+ });
153
+
154
+ it('Bash 脚本应兼容 bash 3.2 (检查 compopt 是否可用)', () => {
155
+ program.parse(['node', 'test', 'completion', 'bash']);
156
+ const script = consoleSpy.mock.calls[0][0];
157
+ // macOS 默认 bash 3.2 不支持 compopt,需检测
158
+ expect(script).toContain('type compopt &>/dev/null');
159
+ });
160
+
161
+ it('Bash 脚本应使用 IFS 换行符分隔补全结果', () => {
162
+ program.parse(['node', 'test', 'completion', 'bash']);
163
+ const script = consoleSpy.mock.calls[0][0];
164
+ expect(script).toContain("local IFS=$'\\n'");
165
+ });
166
+
167
+ it('Bash 脚本应使用 COMPREPLY 数组存储补全结果', () => {
168
+ program.parse(['node', 'test', 'completion', 'bash']);
169
+ const script = consoleSpy.mock.calls[0][0];
170
+ expect(script).toContain('COMPREPLY=()');
171
+ expect(script).toContain('COMPREPLY+=');
172
+ });
173
+ });
174
+
175
+ // ==========================================
176
+ // Zsh 脚本生成
177
+ // ==========================================
178
+ describe('Zsh 脚本生成', () => {
179
+ it('应输出包含 compdef 注册语句的 Zsh 脚本', () => {
180
+ program.parse(['node', 'test', 'completion', 'zsh']);
181
+ expect(consoleSpy).toHaveBeenCalled();
182
+ const script = consoleSpy.mock.calls[0][0];
183
+ expect(script).toContain('compdef _clawt_completion clawt');
184
+ });
185
+
186
+ it('Zsh 脚本应包含 _clawt_completion 函数定义', () => {
187
+ program.parse(['node', 'test', 'completion', 'zsh']);
188
+ const script = consoleSpy.mock.calls[0][0];
189
+ expect(script).toContain('_clawt_completion()');
190
+ });
191
+
192
+ it('Zsh 脚本应包含 #compdef clawt 顶部标记', () => {
193
+ program.parse(['node', 'test', 'completion', 'zsh']);
194
+ const script = consoleSpy.mock.calls[0][0];
195
+ expect(script).toContain('#compdef clawt');
196
+ });
197
+
198
+ it('Zsh 脚本应使用 compadd 命令添加补全项', () => {
199
+ program.parse(['node', 'test', 'completion', 'zsh']);
200
+ const script = consoleSpy.mock.calls[0][0];
201
+ expect(script).toContain('compadd');
202
+ });
203
+
204
+ it('Zsh 脚本应区分目录和文件的空格后缀行为', () => {
205
+ program.parse(['node', 'test', 'completion', 'zsh']);
206
+ const script = consoleSpy.mock.calls[0][0];
207
+ // 目录:不追加空格
208
+ expect(script).toContain("compadd -S '' --");
209
+ // 文件/命令:追加空格
210
+ expect(script).toContain("compadd -S ' ' --");
211
+ });
212
+
213
+ it('Zsh 脚本应使用 CURRENT 变量计算 cword', () => {
214
+ program.parse(['node', 'test', 'completion', 'zsh']);
215
+ const script = consoleSpy.mock.calls[0][0];
216
+ expect(script).toContain('CURRENT - 1');
217
+ });
218
+
219
+ it('Zsh 脚本应使用 words 数组传递参数', () => {
220
+ program.parse(['node', 'test', 'completion', 'zsh']);
221
+ const script = consoleSpy.mock.calls[0][0];
222
+ expect(script).toContain('${words[@]}');
223
+ });
224
+ });
225
+
226
+ // ==========================================
227
+ // 动态补全:分支名补全 (-b / --branch)
228
+ // ==========================================
229
+ describe('分支名补全 (-b / --branch)', () => {
230
+ it('应列出所有匹配前缀的分支名 (Bash)', () => {
231
+ vi.mocked(worktreeUtils.getProjectWorktrees).mockReturnValue([
232
+ { branch: 'feature-login', path: '/path/1' } as any,
233
+ { branch: 'feature-signup', path: '/path/2' } as any,
234
+ { branch: 'bugfix-crash', path: '/path/3' } as any
235
+ ]);
236
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-b', 'feat']);
237
+ expect(consoleSpy).toHaveBeenCalledWith('feature-login\nfeature-signup');
238
+ });
239
+
240
+ it('应列出所有匹配前缀的分支名 (Zsh)', () => {
241
+ vi.mocked(worktreeUtils.getProjectWorktrees).mockReturnValue([
242
+ { branch: 'feature-login', path: '/path/1' } as any,
243
+ { branch: 'feature-signup', path: '/path/2' } as any,
244
+ { branch: 'bugfix-crash', path: '/path/3' } as any
245
+ ]);
246
+ program.parse(['node', 'test', 'completion', '_complete', 'zsh', '3', 'clawt', 'run', '-b', 'feat']);
247
+ expect(consoleSpy).toHaveBeenCalledWith('feature-login\nfeature-signup');
248
+ });
249
+
250
+ it('应支持 --branch 长选项触发分支补全', () => {
251
+ vi.mocked(worktreeUtils.getProjectWorktrees).mockReturnValue([
252
+ { branch: 'feature-auth', path: '/path/1' } as any,
253
+ { branch: 'main', path: '/path/2' } as any
254
+ ]);
255
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '--branch', 'feat']);
256
+ expect(consoleSpy).toHaveBeenCalledWith('feature-auth');
257
+ });
258
+
259
+ it('当输入前缀为空时应列出所有分支', () => {
260
+ vi.mocked(worktreeUtils.getProjectWorktrees).mockReturnValue([
261
+ { branch: 'feature-a', path: '/path/1' } as any,
262
+ { branch: 'bugfix-b', path: '/path/2' } as any,
263
+ { branch: 'main', path: '/path/3' } as any
264
+ ]);
265
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-b', '']);
266
+ expect(consoleSpy).toHaveBeenCalledWith('feature-a\nbugfix-b\nmain');
267
+ });
268
+
269
+ it('当没有匹配的分支时应输出空字符串', () => {
270
+ vi.mocked(worktreeUtils.getProjectWorktrees).mockReturnValue([
271
+ { branch: 'feature-a', path: '/path/1' } as any
272
+ ]);
273
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-b', 'xyz']);
274
+ expect(consoleSpy).toHaveBeenCalledWith('');
275
+ });
276
+
277
+ it('当 getProjectWorktrees 抛出异常时应静默处理', () => {
278
+ vi.mocked(worktreeUtils.getProjectWorktrees).mockImplementation(() => {
279
+ throw new Error('非 git 仓库');
280
+ });
281
+ // 不应抛异常
282
+ expect(() => {
283
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-b', 'feat']);
284
+ }).not.toThrow();
285
+ });
286
+
287
+ it('应支持 create 命令中的 -b 分支补全', () => {
288
+ vi.mocked(worktreeUtils.getProjectWorktrees).mockReturnValue([
289
+ { branch: 'dev-branch', path: '/path/1' } as any,
290
+ { branch: 'deploy-prod', path: '/path/2' } as any
291
+ ]);
292
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'create', '-b', 'dev']);
293
+ expect(consoleSpy).toHaveBeenCalledWith('dev-branch');
294
+ });
295
+
296
+ it('应支持 merge 命令中的 -b 分支补全', () => {
297
+ vi.mocked(worktreeUtils.getProjectWorktrees).mockReturnValue([
298
+ { branch: 'feature-merge-test', path: '/path/1' } as any
299
+ ]);
300
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'merge', '-b', 'feat']);
301
+ expect(consoleSpy).toHaveBeenCalledWith('feature-merge-test');
302
+ });
303
+
304
+ it('应支持 resume 命令中的 --branch 分支补全', () => {
305
+ vi.mocked(worktreeUtils.getProjectWorktrees).mockReturnValue([
306
+ { branch: 'task-1', path: '/path/1' } as any,
307
+ { branch: 'task-2', path: '/path/2' } as any
308
+ ]);
309
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'resume', '--branch', 'task']);
310
+ expect(consoleSpy).toHaveBeenCalledWith('task-1\ntask-2');
311
+ });
312
+
313
+ it('只有一个分支匹配时应精确输出该分支名', () => {
314
+ vi.mocked(worktreeUtils.getProjectWorktrees).mockReturnValue([
315
+ { branch: 'feature-unique', path: '/path/1' } as any,
316
+ { branch: 'bugfix-other', path: '/path/2' } as any
317
+ ]);
318
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-b', 'feature-u']);
319
+ expect(consoleSpy).toHaveBeenCalledWith('feature-unique');
320
+ });
321
+
322
+ it('worktrees 列表为空时应输出空字符串', () => {
323
+ vi.mocked(worktreeUtils.getProjectWorktrees).mockReturnValue([]);
324
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-b', '']);
325
+ expect(consoleSpy).toHaveBeenCalledWith('');
326
+ });
327
+ });
328
+
329
+ // ==========================================
330
+ // 动态补全:文件路径补全 (-f / --file)
331
+ // ==========================================
332
+ describe('文件路径补全 (-f / --file)', () => {
333
+ it('应列出所有文件和目录(不限制后缀)', () => {
334
+ vi.mocked(fs.existsSync).mockReturnValue(true);
335
+ vi.mocked(fs.readdirSync).mockReturnValue([
336
+ 'task1.md', 'readme.txt', 'main.ts', 'docs'
337
+ ] as any);
338
+ vi.mocked(fs.statSync).mockImplementation((p: any) => {
339
+ if (String(p).endsWith('docs')) {
340
+ return createStatMock(true);
341
+ }
342
+ return createStatMock(false);
343
+ });
344
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-f', '']);
345
+ expect(consoleSpy).toHaveBeenCalled();
346
+ const result = consoleSpy.mock.calls[0][0];
347
+ // 所有文件都应出现(代码不限制后缀)
348
+ expect(result).toContain('task1.md');
349
+ expect(result).toContain('readme.txt');
350
+ expect(result).toContain('main.ts');
351
+ expect(result).toContain('docs/');
352
+ });
353
+
354
+ it('应根据前缀过滤匹配的文件', () => {
355
+ vi.mocked(fs.existsSync).mockReturnValue(true);
356
+ vi.mocked(fs.readdirSync).mockReturnValue([
357
+ 'test-login.md', 'test-signup.md', 'batch-deploy.md'
358
+ ] as any);
359
+ vi.mocked(fs.statSync).mockReturnValue(createStatMock(false));
360
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-f', 'test']);
361
+ expect(consoleSpy).toHaveBeenCalledWith('test-login.md\ntest-signup.md');
362
+ });
363
+
364
+ it('应支持 --file 长选项触发文件补全', () => {
365
+ vi.mocked(fs.existsSync).mockReturnValue(true);
366
+ vi.mocked(fs.readdirSync).mockReturnValue(['task.md'] as any);
367
+ vi.mocked(fs.statSync).mockReturnValue(createStatMock(false));
368
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '--file', 'ta']);
369
+ expect(consoleSpy).toHaveBeenCalledWith('task.md');
370
+ });
371
+
372
+ it('目录应带有尾部斜杠 /', () => {
373
+ vi.mocked(fs.existsSync).mockReturnValue(true);
374
+ vi.mocked(fs.readdirSync).mockReturnValue(['tasks', 'scripts'] as any);
375
+ vi.mocked(fs.statSync).mockReturnValue(createStatMock(true));
376
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-f', '']);
377
+ const result = consoleSpy.mock.calls[0][0];
378
+ expect(result).toContain('tasks/');
379
+ expect(result).toContain('scripts/');
380
+ });
381
+
382
+ it('应支持子目录内的文件补全 (输入 "tasks/b")', () => {
383
+ vi.mocked(fs.existsSync).mockReturnValue(true);
384
+ vi.mocked(fs.readdirSync).mockReturnValue(['batch.md', 'login.md', 'deploy.md'] as any);
385
+ vi.mocked(fs.statSync).mockReturnValue(createStatMock(false));
386
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-f', 'tasks/b']);
387
+ expect(consoleSpy).toHaveBeenCalledWith('tasks/batch.md');
388
+ });
389
+
390
+ it('应支持子目录内带有前缀时过滤文件 (输入 "tasks/a")', () => {
391
+ vi.mocked(fs.existsSync).mockReturnValue(true);
392
+ vi.mocked(fs.readdirSync).mockReturnValue(['api.md', 'auth.md', 'ui.md'] as any);
393
+ vi.mocked(fs.statSync).mockReturnValue(createStatMock(false));
394
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-f', 'tasks/a']);
395
+ const result = consoleSpy.mock.calls[0][0];
396
+ expect(result).toContain('tasks/api.md');
397
+ expect(result).toContain('tasks/auth.md');
398
+ expect(result).not.toContain('tasks/ui.md');
399
+ });
400
+
401
+ it('应跳过隐藏文件和隐藏目录(以 . 开头)', () => {
402
+ vi.mocked(fs.existsSync).mockReturnValue(true);
403
+ vi.mocked(fs.readdirSync).mockReturnValue([
404
+ '.hidden.md', '.git', 'visible.md', 'docs'
405
+ ] as any);
406
+ vi.mocked(fs.statSync).mockImplementation((p: any) => {
407
+ if (String(p).endsWith('.git') || String(p).endsWith('docs')) {
408
+ return createStatMock(true);
409
+ }
410
+ return createStatMock(false);
411
+ });
412
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-f', '']);
413
+ const result = consoleSpy.mock.calls[0][0];
414
+ expect(result).not.toContain('.hidden.md');
415
+ expect(result).not.toContain('.git');
416
+ expect(result).toContain('visible.md');
417
+ expect(result).toContain('docs/');
418
+ });
419
+
420
+ it('当目录不存在时应返回空', () => {
421
+ vi.mocked(fs.existsSync).mockReturnValue(false);
422
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-f', 'nonexist/']);
423
+ expect(consoleSpy).toHaveBeenCalledWith('');
424
+ });
425
+
426
+ it('当 statSync 抛出异常时应忽略该文件(权限不足等)', () => {
427
+ vi.mocked(fs.existsSync).mockReturnValue(true);
428
+ vi.mocked(fs.readdirSync).mockReturnValue(['protected.md', 'normal.md'] as any);
429
+ vi.mocked(fs.statSync).mockImplementation((p: any) => {
430
+ if (String(p).endsWith('protected.md')) {
431
+ throw new Error('EACCES: permission denied');
432
+ }
433
+ return createStatMock(false);
434
+ });
435
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-f', '']);
436
+ const result = consoleSpy.mock.calls[0][0];
437
+ expect(result).not.toContain('protected.md');
438
+ expect(result).toContain('normal.md');
439
+ });
440
+
441
+ it('当目录为空时应返回空', () => {
442
+ vi.mocked(fs.existsSync).mockReturnValue(true);
443
+ vi.mocked(fs.readdirSync).mockReturnValue([] as any);
444
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-f', '']);
445
+ expect(consoleSpy).toHaveBeenCalledWith('');
446
+ });
447
+
448
+ it('应正确处理多层级嵌套目录路径 (输入 "a/b/c/deep")', () => {
449
+ vi.mocked(fs.existsSync).mockReturnValue(true);
450
+ vi.mocked(fs.readdirSync).mockReturnValue(['deep-task.md'] as any);
451
+ vi.mocked(fs.statSync).mockReturnValue(createStatMock(false));
452
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-f', 'a/b/c/deep']);
453
+ // dirname("a/b/c/deep") = "a/b/c",所以前缀是 "a/b/c/"
454
+ expect(consoleSpy).toHaveBeenCalledWith('a/b/c/deep-task.md');
455
+ });
456
+
457
+ it('只有目录没有文件时应只输出目录', () => {
458
+ vi.mocked(fs.existsSync).mockReturnValue(true);
459
+ vi.mocked(fs.readdirSync).mockReturnValue(['subdir1', 'subdir2'] as any);
460
+ vi.mocked(fs.statSync).mockReturnValue(createStatMock(true));
461
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-f', '']);
462
+ const result = consoleSpy.mock.calls[0][0];
463
+ expect(result).toBe('subdir1/\nsubdir2/');
464
+ });
465
+
466
+ it('前缀精确匹配文件名时应输出该文件', () => {
467
+ vi.mocked(fs.existsSync).mockReturnValue(true);
468
+ vi.mocked(fs.readdirSync).mockReturnValue(['task.md', 'task-extra.md'] as any);
469
+ vi.mocked(fs.statSync).mockReturnValue(createStatMock(false));
470
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-f', 'task']);
471
+ const result = consoleSpy.mock.calls[0][0];
472
+ expect(result).toContain('task.md');
473
+ expect(result).toContain('task-extra.md');
474
+ });
475
+ });
476
+
477
+ // ==========================================
478
+ // 动态补全:config set/get 配置键补全
479
+ // ==========================================
480
+ describe('config set/get 配置键补全', () => {
481
+ it('config set 应列出匹配前缀的配置键', () => {
482
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'config', 'set', 'a']);
483
+ expect(consoleSpy).toHaveBeenCalled();
484
+ const result = consoleSpy.mock.calls[0][0];
485
+ expect(result).toContain('autoDeleteBranch');
486
+ expect(result).toContain('autoPullPush');
487
+ });
488
+
489
+ it('config get 应列出匹配前缀的配置键', () => {
490
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'config', 'get', 'c']);
491
+ expect(consoleSpy).toHaveBeenCalled();
492
+ const result = consoleSpy.mock.calls[0][0];
493
+ expect(result).toContain('claudeCodeCommand');
494
+ expect(result).toContain('confirmDestructiveOps');
495
+ });
496
+
497
+ it('config set 前缀为空时应列出所有配置键', () => {
498
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'config', 'set', '']);
499
+ expect(consoleSpy).toHaveBeenCalled();
500
+ const result = consoleSpy.mock.calls[0][0];
501
+ // 验证所有配置键都包含在内
502
+ const configKeys = Object.keys(CONFIG_DEFINITIONS);
503
+ for (const key of configKeys) {
504
+ expect(result).toContain(key);
505
+ }
506
+ });
507
+
508
+ it('config set 输入不匹配任何配置键时应输出空', () => {
509
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'config', 'set', 'zzz']);
510
+ expect(consoleSpy).toHaveBeenCalledWith('');
511
+ });
512
+
513
+ it('config set 输入 "max" 应匹配 maxConcurrency', () => {
514
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'config', 'set', 'max']);
515
+ expect(consoleSpy).toHaveBeenCalledWith('maxConcurrency');
516
+ });
517
+
518
+ it('config set 输入 "terminal" 应匹配 terminalApp', () => {
519
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'config', 'set', 'terminal']);
520
+ expect(consoleSpy).toHaveBeenCalledWith('terminalApp');
521
+ });
522
+
523
+ it('config get 输入 "aliases" 应匹配 aliases', () => {
524
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'config', 'get', 'aliases']);
525
+ expect(consoleSpy).toHaveBeenCalledWith('aliases');
526
+ });
527
+
528
+ it('非 config 上下文中的 set 不应触发配置键补全', () => {
529
+ // "run set xxx" 不应触发配置键补全
530
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', 'set', 'auto']);
531
+ expect(consoleSpy).toHaveBeenCalled();
532
+ const result = consoleSpy.mock.calls[0][0];
533
+ // 不应包含配置键
534
+ expect(result).not.toContain('autoDeleteBranch');
535
+ });
536
+ });
537
+
538
+ // ==========================================
539
+ // 动态补全:子命令补全
540
+ // ==========================================
541
+ describe('子命令补全', () => {
542
+ it('输入 "r" 应匹配 run、remove、resume、reset', () => {
543
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '1', 'clawt', 'r']);
544
+ expect(consoleSpy).toHaveBeenCalled();
545
+ const result = consoleSpy.mock.calls[0][0];
546
+ expect(result).toContain('run');
547
+ expect(result).toContain('remove');
548
+ expect(result).toContain('resume');
549
+ expect(result).toContain('reset');
550
+ });
551
+
552
+ it('输入 "ru" 应仅匹配 run', () => {
553
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '1', 'clawt', 'ru']);
554
+ expect(consoleSpy).toHaveBeenCalled();
555
+ const result = consoleSpy.mock.calls[0][0];
556
+ expect(result).toContain('run');
557
+ expect(result).not.toContain('resume');
558
+ });
559
+
560
+ it('输入 "c" 应匹配 config、create、completion', () => {
561
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '1', 'clawt', 'c']);
562
+ expect(consoleSpy).toHaveBeenCalled();
563
+ const result = consoleSpy.mock.calls[0][0];
564
+ expect(result).toContain('config');
565
+ expect(result).toContain('create');
566
+ expect(result).toContain('completion');
567
+ });
568
+
569
+ it('输入 "s" 应匹配 status、sync 及别名 st', () => {
570
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '1', 'clawt', 's']);
571
+ expect(consoleSpy).toHaveBeenCalled();
572
+ const result = consoleSpy.mock.calls[0][0];
573
+ expect(result).toContain('status');
574
+ expect(result).toContain('sync');
575
+ expect(result).toContain('st');
576
+ });
577
+
578
+ it('输入 "l" 应匹配 list 和别名 ls', () => {
579
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '1', 'clawt', 'l']);
580
+ expect(consoleSpy).toHaveBeenCalled();
581
+ const result = consoleSpy.mock.calls[0][0];
582
+ expect(result).toContain('list');
583
+ expect(result).toContain('ls');
584
+ });
585
+
586
+ it('不应暴露内部命令 _complete', () => {
587
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '1', 'clawt', '_']);
588
+ expect(consoleSpy).toHaveBeenCalled();
589
+ const result = consoleSpy.mock.calls[0][0];
590
+ expect(result).not.toContain('_complete');
591
+ });
592
+
593
+ it('输入空字符串应列出所有顶级命令', () => {
594
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '1', 'clawt', '']);
595
+ expect(consoleSpy).toHaveBeenCalled();
596
+ const result = consoleSpy.mock.calls[0][0];
597
+ expect(result).toContain('run');
598
+ expect(result).toContain('config');
599
+ expect(result).toContain('list');
600
+ expect(result).toContain('create');
601
+ expect(result).toContain('remove');
602
+ expect(result).toContain('merge');
603
+ expect(result).toContain('status');
604
+ expect(result).toContain('completion');
605
+ });
606
+
607
+ it('输入完全不匹配的前缀应返回空', () => {
608
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '1', 'clawt', 'zzz']);
609
+ expect(consoleSpy).toHaveBeenCalledWith('');
610
+ });
611
+
612
+ it('输入 "m" 应匹配 merge', () => {
613
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '1', 'clawt', 'm']);
614
+ expect(consoleSpy).toHaveBeenCalled();
615
+ const result = consoleSpy.mock.calls[0][0];
616
+ expect(result).toContain('merge');
617
+ });
618
+
619
+ it('输入 "v" 应匹配 validate', () => {
620
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '1', 'clawt', 'v']);
621
+ expect(consoleSpy).toHaveBeenCalled();
622
+ const result = consoleSpy.mock.calls[0][0];
623
+ expect(result).toContain('validate');
624
+ });
625
+
626
+ it('输入 "a" 应匹配 alias', () => {
627
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '1', 'clawt', 'a']);
628
+ expect(consoleSpy).toHaveBeenCalled();
629
+ const result = consoleSpy.mock.calls[0][0];
630
+ expect(result).toContain('alias');
631
+ });
632
+ });
633
+
634
+ // ==========================================
635
+ // 动态补全:二级子命令补全
636
+ // ==========================================
637
+ describe('二级子命令补全', () => {
638
+ it('config 后输入 "s" 应匹配 set', () => {
639
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '2', 'clawt', 'config', 's']);
640
+ expect(consoleSpy).toHaveBeenCalled();
641
+ const result = consoleSpy.mock.calls[0][0];
642
+ expect(result).toContain('set');
643
+ });
644
+
645
+ it('config 后输入空字符串应列出 set/get/list', () => {
646
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '2', 'clawt', 'config', '']);
647
+ expect(consoleSpy).toHaveBeenCalled();
648
+ const result = consoleSpy.mock.calls[0][0];
649
+ expect(result).toContain('set');
650
+ expect(result).toContain('get');
651
+ expect(result).toContain('list');
652
+ });
653
+
654
+ it('completion 后输入 "b" 应匹配 bash', () => {
655
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '2', 'clawt', 'completion', 'b']);
656
+ expect(consoleSpy).toHaveBeenCalled();
657
+ const result = consoleSpy.mock.calls[0][0];
658
+ expect(result).toContain('bash');
659
+ });
660
+
661
+ it('completion 后输入 "z" 应匹配 zsh', () => {
662
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '2', 'clawt', 'completion', 'z']);
663
+ expect(consoleSpy).toHaveBeenCalled();
664
+ const result = consoleSpy.mock.calls[0][0];
665
+ expect(result).toContain('zsh');
666
+ });
667
+
668
+ it('completion 后输入 "i" 应匹配 install', () => {
669
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '2', 'clawt', 'completion', 'i']);
670
+ expect(consoleSpy).toHaveBeenCalled();
671
+ const result = consoleSpy.mock.calls[0][0];
672
+ expect(result).toContain('install');
673
+ });
674
+
675
+ it('config 后输入 "g" 应匹配 get', () => {
676
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '2', 'clawt', 'config', 'g']);
677
+ expect(consoleSpy).toHaveBeenCalled();
678
+ const result = consoleSpy.mock.calls[0][0];
679
+ expect(result).toContain('get');
680
+ expect(result).not.toContain('set');
681
+ });
682
+
683
+ it('config 后输入不匹配的前缀应返回空', () => {
684
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '2', 'clawt', 'config', 'zzz']);
685
+ expect(consoleSpy).toHaveBeenCalledWith('');
686
+ });
687
+ });
688
+
689
+ // ==========================================
690
+ // 动态补全:选项补全
691
+ // ==========================================
692
+ describe('选项补全', () => {
693
+ it('run 命令后输入 "-" 应列出可用短选项', () => {
694
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '2', 'clawt', 'run', '-']);
695
+ expect(consoleSpy).toHaveBeenCalled();
696
+ const result = consoleSpy.mock.calls[0][0];
697
+ expect(result).toContain('-b');
698
+ expect(result).toContain('-f');
699
+ expect(result).toContain('-c');
700
+ expect(result).toContain('-p');
701
+ });
702
+
703
+ it('run 命令后输入 "--" 应列出长选项', () => {
704
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '2', 'clawt', 'run', '--']);
705
+ expect(consoleSpy).toHaveBeenCalled();
706
+ const result = consoleSpy.mock.calls[0][0];
707
+ expect(result).toContain('--branch');
708
+ expect(result).toContain('--file');
709
+ expect(result).toContain('--concurrency');
710
+ expect(result).toContain('--prompt');
711
+ });
712
+
713
+ it('run 命令后输入 "--b" 应仅匹配 --branch', () => {
714
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '2', 'clawt', 'run', '--b']);
715
+ expect(consoleSpy).toHaveBeenCalled();
716
+ const result = consoleSpy.mock.calls[0][0];
717
+ expect(result).toContain('--branch');
718
+ expect(result).not.toContain('--file');
719
+ });
720
+
721
+ it('run 命令后输入 "--c" 应匹配 --concurrency', () => {
722
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '2', 'clawt', 'run', '--c']);
723
+ expect(consoleSpy).toHaveBeenCalled();
724
+ const result = consoleSpy.mock.calls[0][0];
725
+ expect(result).toContain('--concurrency');
726
+ });
727
+
728
+ it('run 命令后输入空字符串应同时列出子命令和选项', () => {
729
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '2', 'clawt', 'run', '']);
730
+ expect(consoleSpy).toHaveBeenCalled();
731
+ const result = consoleSpy.mock.calls[0][0];
732
+ // 选项也应作为候选出现
733
+ expect(result).toContain('--branch');
734
+ expect(result).toContain('--file');
735
+ });
736
+
737
+ it('run 命令后输入 "-f" 应匹配 -f 短选项', () => {
738
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '2', 'clawt', 'run', '-f']);
739
+ expect(consoleSpy).toHaveBeenCalled();
740
+ const result = consoleSpy.mock.calls[0][0];
741
+ expect(result).toContain('-f');
742
+ });
743
+ });
744
+
745
+ // ==========================================
746
+ // install 子命令
747
+ // ==========================================
748
+ describe('install 子命令', () => {
749
+ const originalEnv = process.env;
750
+
751
+ beforeEach(() => {
752
+ process.env = { ...originalEnv };
753
+ });
754
+
755
+ afterEach(() => {
756
+ process.env = originalEnv;
757
+ });
758
+
759
+ it('Zsh 环境应写入 .zshrc', () => {
760
+ process.env.SHELL = '/bin/zsh';
761
+ vi.mocked(fs.existsSync).mockReturnValue(false);
762
+ vi.mocked(fs.writeFileSync).mockImplementation(() => {});
763
+ program.parse(['node', 'test', 'completion', 'install']);
764
+ expect(fs.writeFileSync).toHaveBeenCalled();
765
+ const writePath = (fs.writeFileSync as any).mock.calls[0][0];
766
+ expect(writePath).toContain('.zshrc');
767
+ });
768
+
769
+ it('Bash 环境应写入 .bashrc', () => {
770
+ process.env.SHELL = '/bin/bash';
771
+ vi.mocked(fs.existsSync).mockReturnValue(false);
772
+ vi.mocked(fs.writeFileSync).mockImplementation(() => {});
773
+ program.parse(['node', 'test', 'completion', 'install']);
774
+ expect(fs.writeFileSync).toHaveBeenCalled();
775
+ const writePath = (fs.writeFileSync as any).mock.calls[0][0];
776
+ expect(writePath).toContain('.bashrc');
777
+ });
778
+
779
+ it('Zsh 安装脚本应使用 source <(clawt completion zsh)', () => {
780
+ process.env.SHELL = '/bin/zsh';
781
+ vi.mocked(fs.existsSync).mockReturnValue(false);
782
+ vi.mocked(fs.writeFileSync).mockImplementation(() => {});
783
+ program.parse(['node', 'test', 'completion', 'install']);
784
+ const content = (fs.writeFileSync as any).mock.calls[0][1];
785
+ expect(content).toContain('source <(clawt completion zsh)');
786
+ });
787
+
788
+ it('Bash 安装脚本应使用 eval "$(clawt completion bash)"', () => {
789
+ process.env.SHELL = '/bin/bash';
790
+ vi.mocked(fs.existsSync).mockReturnValue(false);
791
+ vi.mocked(fs.writeFileSync).mockImplementation(() => {});
792
+ program.parse(['node', 'test', 'completion', 'install']);
793
+ const content = (fs.writeFileSync as any).mock.calls[0][1];
794
+ expect(content).toContain('eval "$(clawt completion bash)"');
795
+ });
796
+
797
+ it('如果配置文件已包含 clawt completion 则不应重复写入', () => {
798
+ process.env.SHELL = '/bin/zsh';
799
+ vi.mocked(fs.existsSync).mockReturnValue(true);
800
+ vi.mocked(fs.readFileSync).mockReturnValue('# existing config\nsource <(clawt completion zsh)\n');
801
+ vi.mocked(fs.writeFileSync).mockImplementation(() => {});
802
+ program.parse(['node', 'test', 'completion', 'install']);
803
+ // 不应再次写入
804
+ expect(fs.writeFileSync).not.toHaveBeenCalled();
805
+ });
806
+
807
+ it('如果配置文件存在但未包含 clawt completion 则应追加', () => {
808
+ process.env.SHELL = '/bin/zsh';
809
+ vi.mocked(fs.existsSync).mockReturnValue(true);
810
+ vi.mocked(fs.readFileSync).mockReturnValue('# my zshrc\nexport PATH=$PATH:/usr/local/bin\n');
811
+ vi.mocked(fs.writeFileSync).mockImplementation(() => {});
812
+ program.parse(['node', 'test', 'completion', 'install']);
813
+ expect(fs.writeFileSync).toHaveBeenCalled();
814
+ const content = (fs.writeFileSync as any).mock.calls[0][1];
815
+ // 应包含原有内容 + 新增的 completion 配置
816
+ expect(content).toContain('# my zshrc');
817
+ expect(content).toContain('clawt completion zsh');
818
+ });
819
+
820
+ it('未知 Shell 环境 (fish) 应给出警告而非报错', () => {
821
+ process.env.SHELL = '/usr/bin/fish';
822
+ expect(() => {
823
+ program.parse(['node', 'test', 'completion', 'install']);
824
+ }).not.toThrow();
825
+ expect(fs.writeFileSync).not.toHaveBeenCalled();
826
+ });
827
+
828
+ it('SHELL 环境变量为空时应给出警告', () => {
829
+ process.env.SHELL = '';
830
+ expect(() => {
831
+ program.parse(['node', 'test', 'completion', 'install']);
832
+ }).not.toThrow();
833
+ expect(fs.writeFileSync).not.toHaveBeenCalled();
834
+ });
835
+
836
+ it('SHELL 环境变量未设置时应给出警告', () => {
837
+ delete process.env.SHELL;
838
+ expect(() => {
839
+ program.parse(['node', 'test', 'completion', 'install']);
840
+ }).not.toThrow();
841
+ expect(fs.writeFileSync).not.toHaveBeenCalled();
842
+ });
843
+
844
+ it('SHELL 路径包含 zsh 子串时应识别为 Zsh (如 /usr/local/bin/zsh)', () => {
845
+ process.env.SHELL = '/usr/local/bin/zsh';
846
+ vi.mocked(fs.existsSync).mockReturnValue(false);
847
+ vi.mocked(fs.writeFileSync).mockImplementation(() => {});
848
+ program.parse(['node', 'test', 'completion', 'install']);
849
+ expect(fs.writeFileSync).toHaveBeenCalled();
850
+ const writePath = (fs.writeFileSync as any).mock.calls[0][0];
851
+ expect(writePath).toContain('.zshrc');
852
+ });
853
+
854
+ it('SHELL 路径包含 bash 子串时应识别为 Bash (如 /opt/homebrew/bin/bash)', () => {
855
+ process.env.SHELL = '/opt/homebrew/bin/bash';
856
+ vi.mocked(fs.existsSync).mockReturnValue(false);
857
+ vi.mocked(fs.writeFileSync).mockImplementation(() => {});
858
+ program.parse(['node', 'test', 'completion', 'install']);
859
+ expect(fs.writeFileSync).toHaveBeenCalled();
860
+ const writePath = (fs.writeFileSync as any).mock.calls[0][0];
861
+ expect(writePath).toContain('.bashrc');
862
+ });
863
+
864
+ it('安装脚本应包含 clawt completion 注释标记', () => {
865
+ process.env.SHELL = '/bin/zsh';
866
+ vi.mocked(fs.existsSync).mockReturnValue(false);
867
+ vi.mocked(fs.writeFileSync).mockImplementation(() => {});
868
+ program.parse(['node', 'test', 'completion', 'install']);
869
+ const content = (fs.writeFileSync as any).mock.calls[0][1];
870
+ expect(content).toContain('# clawt completion');
871
+ });
872
+ });
873
+
874
+ // ==========================================
875
+ // _complete 命令参数校验
876
+ // ==========================================
877
+ describe('_complete 命令参数校验', () => {
878
+ it('参数不足时不应输出任何内容', () => {
879
+ program.parse(['node', 'test', 'completion', '_complete']);
880
+ expect(consoleSpy).not.toHaveBeenCalled();
881
+ });
882
+
883
+ it('仅传入 shell 参数时不应输出任何内容', () => {
884
+ program.parse(['node', 'test', 'completion', '_complete', 'bash']);
885
+ expect(consoleSpy).not.toHaveBeenCalled();
886
+ });
887
+
888
+ it('传入两个参数时应正常执行补全', () => {
889
+ // shell + cword 满足最低参数要求
890
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '0']);
891
+ // 虽然可能没有有效输出,但不应报错
892
+ expect(() => {}).not.toThrow();
893
+ });
894
+ });
895
+
896
+ // ==========================================
897
+ // Bash vs Zsh 动态补全行为一致性
898
+ // ==========================================
899
+ describe('Bash 与 Zsh 动态补全行为一致性', () => {
900
+ it('子命令补全结果在 Bash 和 Zsh 中应一致', () => {
901
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '1', 'clawt', 'r']);
902
+ const bashResult = consoleSpy.mock.calls[0][0];
903
+
904
+ consoleSpy.mockClear();
905
+ // 重新创建 program(commander 不支持重复 parse)
906
+ const program2 = createTestProgram();
907
+ program2.parse(['node', 'test', 'completion', '_complete', 'zsh', '1', 'clawt', 'r']);
908
+ const zshResult = consoleSpy.mock.calls[0][0];
909
+
910
+ expect(bashResult).toBe(zshResult);
911
+ });
912
+
913
+ it('分支补全结果在 Bash 和 Zsh 中应一致', () => {
914
+ vi.mocked(worktreeUtils.getProjectWorktrees).mockReturnValue([
915
+ { branch: 'test-1', path: '/p' } as any,
916
+ { branch: 'test-2', path: '/p' } as any
917
+ ]);
918
+
919
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-b', 'test']);
920
+ const bashResult = consoleSpy.mock.calls[0][0];
921
+
922
+ consoleSpy.mockClear();
923
+ const program2 = createTestProgram();
924
+ program2.parse(['node', 'test', 'completion', '_complete', 'zsh', '3', 'clawt', 'run', '-b', 'test']);
925
+ const zshResult = consoleSpy.mock.calls[0][0];
926
+
927
+ expect(bashResult).toBe(zshResult);
928
+ });
929
+
930
+ it('文件补全结果在 Bash 和 Zsh 中应一致', () => {
931
+ vi.mocked(fs.existsSync).mockReturnValue(true);
932
+ vi.mocked(fs.readdirSync).mockReturnValue(['a.md', 'b.md'] as any);
933
+ vi.mocked(fs.statSync).mockReturnValue(createStatMock(false));
934
+
935
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-f', '']);
936
+ const bashResult = consoleSpy.mock.calls[0][0];
937
+
938
+ consoleSpy.mockClear();
939
+ const program2 = createTestProgram();
940
+ program2.parse(['node', 'test', 'completion', '_complete', 'zsh', '3', 'clawt', 'run', '-f', '']);
941
+ const zshResult = consoleSpy.mock.calls[0][0];
942
+
943
+ expect(bashResult).toBe(zshResult);
944
+ });
945
+
946
+ it('配置键补全结果在 Bash 和 Zsh 中应一致', () => {
947
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'config', 'set', 'auto']);
948
+ const bashResult = consoleSpy.mock.calls[0][0];
949
+
950
+ consoleSpy.mockClear();
951
+ const program2 = createTestProgram();
952
+ program2.parse(['node', 'test', 'completion', '_complete', 'zsh', '3', 'clawt', 'config', 'set', 'auto']);
953
+ const zshResult = consoleSpy.mock.calls[0][0];
954
+
955
+ expect(bashResult).toBe(zshResult);
956
+ });
957
+ });
958
+
959
+ // ==========================================
960
+ // 补全结果去重
961
+ // ==========================================
962
+ describe('补全结果去重', () => {
963
+ it('输出的子命令列表不应有重复项', () => {
964
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '1', 'clawt', '']);
965
+ const result = consoleSpy.mock.calls[0][0];
966
+ const items = result.split('\n').filter((s: string) => s.length > 0);
967
+ const uniqueItems = [...new Set(items)];
968
+ expect(items.length).toBe(uniqueItems.length);
969
+ });
970
+
971
+ it('输出的选项列表不应有重复项', () => {
972
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '2', 'clawt', 'run', '-']);
973
+ const result = consoleSpy.mock.calls[0][0];
974
+ const items = result.split('\n').filter((s: string) => s.length > 0);
975
+ const uniqueItems = [...new Set(items)];
976
+ expect(items.length).toBe(uniqueItems.length);
977
+ });
978
+ });
979
+
980
+ // ==========================================
981
+ // 命令别名在子命令列表中的补全
982
+ // ==========================================
983
+ describe('命令别名在子命令列表中的补全', () => {
984
+ // 别名只会在父命令名匹配时一并输出
985
+ // 例如输入 "l" 时,list 匹配,其别名 ls 也以 "l" 开头所以会出现
986
+ it('输入 "r" 时应包含 remove 的别名 rm', () => {
987
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '1', 'clawt', 'r']);
988
+ const result = consoleSpy.mock.calls[0][0];
989
+ expect(result).toContain('remove');
990
+ expect(result).toContain('rm');
991
+ });
992
+
993
+ it('输入 "l" 时应包含 list 的别名 ls', () => {
994
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '1', 'clawt', 'l']);
995
+ const result = consoleSpy.mock.calls[0][0];
996
+ expect(result).toContain('list');
997
+ expect(result).toContain('ls');
998
+ });
999
+
1000
+ it('输入 "s" 时应包含 status 的别名 st', () => {
1001
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '1', 'clawt', 's']);
1002
+ const result = consoleSpy.mock.calls[0][0];
1003
+ expect(result).toContain('status');
1004
+ expect(result).toContain('st');
1005
+ });
1006
+
1007
+ it('输入空字符串时别名也应出现在补全列表中', () => {
1008
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '1', 'clawt', '']);
1009
+ const result = consoleSpy.mock.calls[0][0];
1010
+ // 所有别名都应列出
1011
+ expect(result).toContain('ls');
1012
+ expect(result).toContain('rm');
1013
+ expect(result).toContain('st');
1014
+ });
1015
+ });
1016
+
1017
+ // ==========================================
1018
+ // 边界情况
1019
+ // ==========================================
1020
+ describe('边界情况', () => {
1021
+ it('cword 超出 words 长度时不应崩溃', () => {
1022
+ expect(() => {
1023
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '99', 'clawt']);
1024
+ }).not.toThrow();
1025
+ });
1026
+
1027
+ it('cword 为 0 时应正常处理', () => {
1028
+ expect(() => {
1029
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '0', 'clawt']);
1030
+ }).not.toThrow();
1031
+ });
1032
+
1033
+ it('cword 为负数时不应崩溃', () => {
1034
+ expect(() => {
1035
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '-1', 'clawt']);
1036
+ }).not.toThrow();
1037
+ });
1038
+
1039
+ it('cword 为非数字时不应崩溃', () => {
1040
+ expect(() => {
1041
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', 'abc', 'clawt']);
1042
+ }).not.toThrow();
1043
+ });
1044
+
1045
+ it('包含斜杠的分支名应正常补全 (如 feat/user-auth)', () => {
1046
+ vi.mocked(worktreeUtils.getProjectWorktrees).mockReturnValue([
1047
+ { branch: 'feat/user-auth', path: '/p' } as any,
1048
+ { branch: 'fix/bug-123', path: '/p' } as any
1049
+ ]);
1050
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-b', 'feat']);
1051
+ expect(consoleSpy).toHaveBeenCalledWith('feat/user-auth');
1052
+ });
1053
+
1054
+ it('包含井号的分支名应正常补全 (如 fix/bug#123)', () => {
1055
+ vi.mocked(worktreeUtils.getProjectWorktrees).mockReturnValue([
1056
+ { branch: 'fix/bug#123', path: '/p' } as any
1057
+ ]);
1058
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-b', 'fix']);
1059
+ expect(consoleSpy).toHaveBeenCalledWith('fix/bug#123');
1060
+ });
1061
+
1062
+ it('分支名包含中文时应正常补全', () => {
1063
+ vi.mocked(worktreeUtils.getProjectWorktrees).mockReturnValue([
1064
+ { branch: 'feature-测试分支', path: '/p' } as any,
1065
+ { branch: 'feature-正式环境', path: '/p' } as any
1066
+ ]);
1067
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-b', 'feature-测试']);
1068
+ expect(consoleSpy).toHaveBeenCalledWith('feature-测试分支');
1069
+ });
1070
+
1071
+ it('大量分支列表时应正确过滤', () => {
1072
+ const branches = Array.from({ length: 50 }, (_, i) => ({
1073
+ branch: `branch-${String(i).padStart(3, '0')}`,
1074
+ path: `/p/${i}`
1075
+ }));
1076
+ vi.mocked(worktreeUtils.getProjectWorktrees).mockReturnValue(branches as any);
1077
+ // 输入 "branch-00" 应匹配 branch-000 到 branch-009 共 10 个
1078
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-b', 'branch-00']);
1079
+ const result = consoleSpy.mock.calls[0][0];
1080
+ const lines = result.split('\n').filter((s: string) => s.length > 0);
1081
+ expect(lines.length).toBe(10);
1082
+ });
1083
+
1084
+ it('大量文件时应正确过滤', () => {
1085
+ vi.mocked(fs.existsSync).mockReturnValue(true);
1086
+ const files = Array.from({ length: 100 }, (_, i) => `task-${i}.md`);
1087
+ vi.mocked(fs.readdirSync).mockReturnValue(files as any);
1088
+ vi.mocked(fs.statSync).mockReturnValue(createStatMock(false));
1089
+ // 输入 "task-9" 应匹配: task-9.md, task-90.md ~ task-99.md
1090
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-f', 'task-9']);
1091
+ const result = consoleSpy.mock.calls[0][0];
1092
+ const lines = result.split('\n').filter((s: string) => s.length > 0);
1093
+ // task-9.md + task-90.md ~ task-99.md = 11 个
1094
+ expect(lines.length).toBe(11);
1095
+ });
1096
+
1097
+ it('worktrees 返回单个分支且完全匹配时应输出该分支', () => {
1098
+ vi.mocked(worktreeUtils.getProjectWorktrees).mockReturnValue([
1099
+ { branch: 'main', path: '/p' } as any
1100
+ ]);
1101
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-b', 'main']);
1102
+ expect(consoleSpy).toHaveBeenCalledWith('main');
1103
+ });
1104
+
1105
+ it('文件名以数字开头时应正常补全', () => {
1106
+ vi.mocked(fs.existsSync).mockReturnValue(true);
1107
+ vi.mocked(fs.readdirSync).mockReturnValue(['001-init.md', '002-deploy.md', 'readme.md'] as any);
1108
+ vi.mocked(fs.statSync).mockReturnValue(createStatMock(false));
1109
+ program.parse(['node', 'test', 'completion', '_complete', 'bash', '3', 'clawt', 'run', '-f', '00']);
1110
+ const result = consoleSpy.mock.calls[0][0];
1111
+ expect(result).toContain('001-init.md');
1112
+ expect(result).toContain('002-deploy.md');
1113
+ expect(result).not.toContain('readme.md');
1114
+ });
1115
+ });
1116
+ });