clawt 2.8.2 → 2.9.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,532 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // mock shell(拦截所有 git 操作)
4
+ vi.mock('../../../src/utils/shell.js', () => ({
5
+ execCommand: vi.fn(),
6
+ execCommandWithInput: vi.fn(),
7
+ }));
8
+
9
+ // mock node:child_process(用于直接调用 execSync 的函数)
10
+ vi.mock('node:child_process', () => ({
11
+ execSync: vi.fn(),
12
+ }));
13
+
14
+ // mock logger
15
+ vi.mock('../../../src/logger/index.js', () => ({
16
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
17
+ }));
18
+
19
+ import { execSync } from 'node:child_process';
20
+ import { execCommand, execCommandWithInput } from '../../../src/utils/shell.js';
21
+ import {
22
+ getGitCommonDir,
23
+ getGitTopLevel,
24
+ getProjectName,
25
+ checkBranchExists,
26
+ createWorktree,
27
+ removeWorktreeByPath,
28
+ deleteBranch,
29
+ getStatusPorcelain,
30
+ isWorkingDirClean,
31
+ gitAddAll,
32
+ gitCommit,
33
+ gitMerge,
34
+ hasMergeConflict,
35
+ gitResetHard,
36
+ gitCleanForce,
37
+ gitStashPush,
38
+ gitStashApply,
39
+ gitStashPop,
40
+ gitStashDrop,
41
+ gitStashList,
42
+ gitRestoreStaged,
43
+ gitWorktreeList,
44
+ gitWorktreePrune,
45
+ hasLocalCommits,
46
+ getCommitCountAhead,
47
+ getDiffStat,
48
+ gitDiffCachedBinary,
49
+ gitApplyCachedFromStdin,
50
+ getCurrentBranch,
51
+ getHeadCommitHash,
52
+ gitDiffBinaryAgainstBranch,
53
+ gitApplyFromStdin,
54
+ gitResetSoft,
55
+ gitMergeBase,
56
+ hasCommitWithMessage,
57
+ gitResetSoftTo,
58
+ gitWriteTree,
59
+ gitReadTree,
60
+ getCommitTreeHash,
61
+ gitDiffTree,
62
+ gitApplyCachedCheck,
63
+ } from '../../../src/utils/git.js';
64
+
65
+ const mockedExecCommand = vi.mocked(execCommand);
66
+ const mockedExecCommandWithInput = vi.mocked(execCommandWithInput);
67
+ const mockedExecSync = vi.mocked(execSync);
68
+
69
+ beforeEach(() => {
70
+ // 重置 mock 实现,避免前一个测试的 mockImplementation 影响后续测试
71
+ mockedExecCommand.mockReset();
72
+ mockedExecCommandWithInput.mockReset();
73
+ mockedExecSync.mockReset();
74
+ });
75
+
76
+ describe('getGitCommonDir', () => {
77
+ it('返回 git common dir', () => {
78
+ mockedExecCommand.mockReturnValue('.git');
79
+ expect(getGitCommonDir()).toBe('.git');
80
+ });
81
+
82
+ it('传递 cwd', () => {
83
+ mockedExecCommand.mockReturnValue('/path/.git');
84
+ getGitCommonDir('/repo');
85
+ expect(mockedExecCommand).toHaveBeenCalledWith('git rev-parse --git-common-dir', { cwd: '/repo' });
86
+ });
87
+ });
88
+
89
+ describe('getGitTopLevel', () => {
90
+ it('返回仓库根目录', () => {
91
+ mockedExecCommand.mockReturnValue('/Users/test/project');
92
+ expect(getGitTopLevel()).toBe('/Users/test/project');
93
+ });
94
+ });
95
+
96
+ describe('getProjectName', () => {
97
+ it('返回仓库根目录的 basename', () => {
98
+ mockedExecCommand.mockReturnValue('/Users/test/my-project');
99
+ expect(getProjectName()).toBe('my-project');
100
+ });
101
+ });
102
+
103
+ describe('checkBranchExists', () => {
104
+ it('分支存在时返回 true', () => {
105
+ mockedExecCommand.mockReturnValue('abc123 refs/heads/feature');
106
+ expect(checkBranchExists('feature')).toBe(true);
107
+ });
108
+
109
+ it('分支不存在时返回 false', () => {
110
+ mockedExecCommand.mockImplementation(() => { throw new Error('not found'); });
111
+ expect(checkBranchExists('nonexistent')).toBe(false);
112
+ });
113
+ });
114
+
115
+ describe('createWorktree', () => {
116
+ it('执行正确的 git 命令', () => {
117
+ mockedExecCommand.mockReturnValue('');
118
+ createWorktree('feature', '/path/to/worktree');
119
+ expect(mockedExecCommand).toHaveBeenCalledWith(
120
+ 'git worktree add -b feature "/path/to/worktree"',
121
+ { cwd: undefined },
122
+ );
123
+ });
124
+ });
125
+
126
+ describe('removeWorktreeByPath', () => {
127
+ it('执行强制移除命令', () => {
128
+ mockedExecCommand.mockReturnValue('');
129
+ removeWorktreeByPath('/path/to/worktree');
130
+ expect(mockedExecCommand).toHaveBeenCalledWith(
131
+ 'git worktree remove -f "/path/to/worktree"',
132
+ { cwd: undefined },
133
+ );
134
+ });
135
+ });
136
+
137
+ describe('deleteBranch', () => {
138
+ it('执行强制删除分支命令', () => {
139
+ mockedExecCommand.mockReturnValue('');
140
+ deleteBranch('feature');
141
+ expect(mockedExecCommand).toHaveBeenCalledWith('git branch -D feature', { cwd: undefined });
142
+ });
143
+ });
144
+
145
+ describe('getStatusPorcelain', () => {
146
+ it('返回 porcelain 输出', () => {
147
+ mockedExecCommand.mockReturnValue('M file.txt');
148
+ expect(getStatusPorcelain()).toBe('M file.txt');
149
+ });
150
+ });
151
+
152
+ describe('isWorkingDirClean', () => {
153
+ it('干净时返回 true', () => {
154
+ mockedExecCommand.mockReturnValue('');
155
+ expect(isWorkingDirClean()).toBe(true);
156
+ });
157
+
158
+ it('有修改时返回 false', () => {
159
+ mockedExecCommand.mockReturnValue('M file.txt');
160
+ expect(isWorkingDirClean()).toBe(false);
161
+ });
162
+ });
163
+
164
+ describe('gitAddAll', () => {
165
+ it('执行 git add .', () => {
166
+ gitAddAll('/repo');
167
+ expect(mockedExecCommand).toHaveBeenCalledWith('git add .', { cwd: '/repo' });
168
+ });
169
+ });
170
+
171
+ describe('gitCommit', () => {
172
+ it('执行 git commit 并转义单引号', () => {
173
+ gitCommit("it's a test");
174
+ expect(mockedExecCommand).toHaveBeenCalledWith(
175
+ expect.stringContaining('git commit -m'),
176
+ { cwd: undefined },
177
+ );
178
+ });
179
+ });
180
+
181
+ describe('gitMerge', () => {
182
+ it('执行 git merge', () => {
183
+ gitMerge('feature');
184
+ expect(mockedExecCommand).toHaveBeenCalledWith('git merge feature', { cwd: undefined });
185
+ });
186
+ });
187
+
188
+ describe('hasMergeConflict', () => {
189
+ it('UU 冲突返回 true', () => {
190
+ mockedExecCommand.mockReturnValue('UU file.txt');
191
+ expect(hasMergeConflict()).toBe(true);
192
+ });
193
+
194
+ it('AA 冲突返回 true', () => {
195
+ mockedExecCommand.mockReturnValue('AA file.txt');
196
+ expect(hasMergeConflict()).toBe(true);
197
+ });
198
+
199
+ it('DD 冲突返回 true', () => {
200
+ mockedExecCommand.mockReturnValue('DD file.txt');
201
+ expect(hasMergeConflict()).toBe(true);
202
+ });
203
+
204
+ it('DU 冲突返回 true', () => {
205
+ mockedExecCommand.mockReturnValue('DU file.txt');
206
+ expect(hasMergeConflict()).toBe(true);
207
+ });
208
+
209
+ it('UD 冲突返回 true', () => {
210
+ mockedExecCommand.mockReturnValue('UD file.txt');
211
+ expect(hasMergeConflict()).toBe(true);
212
+ });
213
+
214
+ it('AU 冲突返回 true', () => {
215
+ mockedExecCommand.mockReturnValue('AU file.txt');
216
+ expect(hasMergeConflict()).toBe(true);
217
+ });
218
+
219
+ it('UA 冲突返回 true', () => {
220
+ mockedExecCommand.mockReturnValue('UA file.txt');
221
+ expect(hasMergeConflict()).toBe(true);
222
+ });
223
+
224
+ it('无冲突返回 false', () => {
225
+ mockedExecCommand.mockReturnValue('M file.txt');
226
+ expect(hasMergeConflict()).toBe(false);
227
+ });
228
+
229
+ it('空状态返回 false', () => {
230
+ mockedExecCommand.mockReturnValue('');
231
+ expect(hasMergeConflict()).toBe(false);
232
+ });
233
+ });
234
+
235
+ describe('gitResetHard', () => {
236
+ it('执行 git reset --hard HEAD', () => {
237
+ gitResetHard('/repo');
238
+ expect(mockedExecCommand).toHaveBeenCalledWith('git reset --hard HEAD', { cwd: '/repo' });
239
+ });
240
+ });
241
+
242
+ describe('gitCleanForce', () => {
243
+ it('执行 git clean -fd', () => {
244
+ gitCleanForce('/repo');
245
+ expect(mockedExecCommand).toHaveBeenCalledWith('git clean -fd', { cwd: '/repo' });
246
+ });
247
+ });
248
+
249
+ describe('gitStashPush', () => {
250
+ it('执行 git stash push -m', () => {
251
+ gitStashPush('auto-stash', '/repo');
252
+ expect(mockedExecCommand).toHaveBeenCalledWith('git stash push -m "auto-stash"', { cwd: '/repo' });
253
+ });
254
+ });
255
+
256
+ describe('gitStashApply', () => {
257
+ it('执行 git stash apply', () => {
258
+ gitStashApply();
259
+ expect(mockedExecCommand).toHaveBeenCalledWith('git stash apply', { cwd: undefined });
260
+ });
261
+ });
262
+
263
+ describe('gitStashPop', () => {
264
+ it('默认弹出 stash@{0}', () => {
265
+ gitStashPop();
266
+ expect(mockedExecCommand).toHaveBeenCalledWith('git stash pop stash@{0}', { cwd: undefined });
267
+ });
268
+
269
+ it('指定索引', () => {
270
+ gitStashPop(2);
271
+ expect(mockedExecCommand).toHaveBeenCalledWith('git stash pop stash@{2}', { cwd: undefined });
272
+ });
273
+ });
274
+
275
+ describe('gitStashDrop', () => {
276
+ it('默认删除 stash@{0}', () => {
277
+ gitStashDrop();
278
+ expect(mockedExecCommand).toHaveBeenCalledWith('git stash drop stash@{0}', { cwd: undefined });
279
+ });
280
+ });
281
+
282
+ describe('gitStashList', () => {
283
+ it('返回 stash 列表', () => {
284
+ mockedExecCommand.mockReturnValue('stash@{0}: WIP');
285
+ expect(gitStashList()).toBe('stash@{0}: WIP');
286
+ });
287
+
288
+ it('命令失败时返回空字符串', () => {
289
+ mockedExecCommand.mockImplementation(() => { throw new Error('fail'); });
290
+ expect(gitStashList()).toBe('');
291
+ });
292
+ });
293
+
294
+ describe('gitRestoreStaged', () => {
295
+ it('执行 git restore --staged .', () => {
296
+ mockedExecCommand.mockReturnValue('');
297
+ gitRestoreStaged('/repo');
298
+ expect(mockedExecCommand).toHaveBeenCalledWith('git restore --staged .', { cwd: '/repo' });
299
+ });
300
+ });
301
+
302
+ describe('gitWorktreeList', () => {
303
+ it('返回 worktree 列表', () => {
304
+ mockedExecCommand.mockReturnValue('/repo abc123 [main]');
305
+ expect(gitWorktreeList()).toBe('/repo abc123 [main]');
306
+ });
307
+ });
308
+
309
+ describe('gitWorktreePrune', () => {
310
+ it('执行 git worktree prune', () => {
311
+ gitWorktreePrune();
312
+ expect(mockedExecCommand).toHaveBeenCalledWith('git worktree prune', { cwd: undefined });
313
+ });
314
+ });
315
+
316
+ describe('hasLocalCommits', () => {
317
+ it('有本地提交时返回 true', () => {
318
+ mockedExecCommand.mockReturnValue('abc123 some commit');
319
+ expect(hasLocalCommits('feature')).toBe(true);
320
+ });
321
+
322
+ it('无本地提交时返回 false', () => {
323
+ mockedExecCommand.mockReturnValue('');
324
+ expect(hasLocalCommits('feature')).toBe(false);
325
+ });
326
+
327
+ it('命令失败时返回 false', () => {
328
+ mockedExecCommand.mockImplementation(() => { throw new Error('fail'); });
329
+ expect(hasLocalCommits('feature')).toBe(false);
330
+ });
331
+ });
332
+
333
+ describe('getCommitCountAhead', () => {
334
+ it('返回正确的提交数', () => {
335
+ mockedExecCommand.mockReturnValue('5');
336
+ expect(getCommitCountAhead('feature')).toBe(5);
337
+ });
338
+
339
+ it('返回 0 当输出无法解析', () => {
340
+ mockedExecCommand.mockReturnValue('');
341
+ expect(getCommitCountAhead('feature')).toBe(0);
342
+ });
343
+ });
344
+
345
+ describe('getDiffStat(间接测试 parseShortStat)', () => {
346
+ it('解析标准 shortstat 输出', () => {
347
+ mockedExecCommand.mockReturnValue(' 3 files changed, 42 insertions(+), 10 deletions(-)');
348
+ const result = getDiffStat('/repo');
349
+ expect(result).toEqual({ insertions: 42, deletions: 10 });
350
+ });
351
+
352
+ it('仅有 insertions', () => {
353
+ mockedExecCommand.mockReturnValue(' 1 file changed, 5 insertions(+)');
354
+ const result = getDiffStat('/repo');
355
+ expect(result).toEqual({ insertions: 5, deletions: 0 });
356
+ });
357
+
358
+ it('仅有 deletions', () => {
359
+ mockedExecCommand.mockReturnValue(' 1 file changed, 3 deletions(-)');
360
+ const result = getDiffStat('/repo');
361
+ expect(result).toEqual({ insertions: 0, deletions: 3 });
362
+ });
363
+
364
+ it('空输出(无变更)', () => {
365
+ mockedExecCommand.mockReturnValue('');
366
+ const result = getDiffStat('/repo');
367
+ expect(result).toEqual({ insertions: 0, deletions: 0 });
368
+ });
369
+
370
+ it('单数形式 (1 insertion)', () => {
371
+ mockedExecCommand.mockReturnValue(' 1 file changed, 1 insertion(+)');
372
+ const result = getDiffStat('/repo');
373
+ expect(result).toEqual({ insertions: 1, deletions: 0 });
374
+ });
375
+ });
376
+
377
+ describe('gitDiffCachedBinary', () => {
378
+ it('调用 execSync 获取 binary diff', () => {
379
+ const buffer = Buffer.from('diff content');
380
+ mockedExecSync.mockReturnValue(buffer);
381
+ const result = gitDiffCachedBinary('/repo');
382
+ expect(result).toBe(buffer);
383
+ expect(mockedExecSync).toHaveBeenCalledWith('git diff --cached --binary', expect.objectContaining({
384
+ cwd: '/repo',
385
+ }));
386
+ });
387
+ });
388
+
389
+ describe('gitApplyCachedFromStdin', () => {
390
+ it('调用 execCommandWithInput 传递 patch', () => {
391
+ const patch = Buffer.from('patch content');
392
+ gitApplyCachedFromStdin(patch, '/repo');
393
+ expect(mockedExecCommandWithInput).toHaveBeenCalledWith('git', ['apply', '--cached'], {
394
+ input: patch,
395
+ cwd: '/repo',
396
+ });
397
+ });
398
+ });
399
+
400
+ describe('getCurrentBranch', () => {
401
+ it('返回当前分支名', () => {
402
+ mockedExecCommand.mockReturnValue('main');
403
+ expect(getCurrentBranch()).toBe('main');
404
+ });
405
+ });
406
+
407
+ describe('getHeadCommitHash', () => {
408
+ it('返回 HEAD commit hash', () => {
409
+ mockedExecCommand.mockReturnValue('abc123def456');
410
+ expect(getHeadCommitHash()).toBe('abc123def456');
411
+ });
412
+ });
413
+
414
+ describe('gitDiffBinaryAgainstBranch', () => {
415
+ it('调用 execSync 执行三点 diff', () => {
416
+ const buffer = Buffer.from('diff');
417
+ mockedExecSync.mockReturnValue(buffer);
418
+ const result = gitDiffBinaryAgainstBranch('feature', '/repo');
419
+ expect(result).toBe(buffer);
420
+ expect(mockedExecSync).toHaveBeenCalledWith('git diff HEAD...feature --binary', expect.objectContaining({
421
+ cwd: '/repo',
422
+ }));
423
+ });
424
+ });
425
+
426
+ describe('gitApplyFromStdin', () => {
427
+ it('调用 execCommandWithInput 不带 --cached', () => {
428
+ const patch = Buffer.from('patch');
429
+ gitApplyFromStdin(patch, '/repo');
430
+ expect(mockedExecCommandWithInput).toHaveBeenCalledWith('git', ['apply'], {
431
+ input: patch,
432
+ cwd: '/repo',
433
+ });
434
+ });
435
+ });
436
+
437
+ describe('gitResetSoft', () => {
438
+ it('默认 reset 1 个 commit', () => {
439
+ gitResetSoft();
440
+ expect(mockedExecCommand).toHaveBeenCalledWith('git reset --soft HEAD~1', { cwd: undefined });
441
+ });
442
+
443
+ it('指定 count', () => {
444
+ gitResetSoft(3, '/repo');
445
+ expect(mockedExecCommand).toHaveBeenCalledWith('git reset --soft HEAD~3', { cwd: '/repo' });
446
+ });
447
+ });
448
+
449
+ describe('gitMergeBase', () => {
450
+ it('返回 merge-base hash', () => {
451
+ mockedExecCommand.mockReturnValue('abc123');
452
+ expect(gitMergeBase('main', 'feature')).toBe('abc123');
453
+ });
454
+ });
455
+
456
+ describe('hasCommitWithMessage', () => {
457
+ it('匹配前缀返回 true', () => {
458
+ mockedExecCommand.mockReturnValue('clawt:auto-save\nother commit');
459
+ expect(hasCommitWithMessage('feature', 'clawt:')).toBe(true);
460
+ });
461
+
462
+ it('不匹配前缀返回 false', () => {
463
+ mockedExecCommand.mockReturnValue('normal commit\nother commit');
464
+ expect(hasCommitWithMessage('feature', 'clawt:')).toBe(false);
465
+ });
466
+
467
+ it('空输出返回 false', () => {
468
+ mockedExecCommand.mockReturnValue('');
469
+ expect(hasCommitWithMessage('feature', 'clawt:')).toBe(false);
470
+ });
471
+
472
+ it('命令失败返回 false', () => {
473
+ mockedExecCommand.mockImplementation(() => { throw new Error('fail'); });
474
+ expect(hasCommitWithMessage('feature', 'clawt:')).toBe(false);
475
+ });
476
+ });
477
+
478
+ describe('gitResetSoftTo', () => {
479
+ it('执行 git reset --soft <hash>', () => {
480
+ mockedExecCommand.mockReturnValue('');
481
+ gitResetSoftTo('abc123', '/repo');
482
+ expect(mockedExecCommand).toHaveBeenCalledWith('git reset --soft abc123', { cwd: '/repo' });
483
+ });
484
+ });
485
+
486
+ describe('gitWriteTree', () => {
487
+ it('返回 tree hash', () => {
488
+ mockedExecCommand.mockReturnValue('tree123');
489
+ expect(gitWriteTree('/repo')).toBe('tree123');
490
+ });
491
+ });
492
+
493
+ describe('gitReadTree', () => {
494
+ it('执行 git read-tree', () => {
495
+ gitReadTree('tree123', '/repo');
496
+ expect(mockedExecCommand).toHaveBeenCalledWith('git read-tree tree123', { cwd: '/repo' });
497
+ });
498
+ });
499
+
500
+ describe('getCommitTreeHash', () => {
501
+ it('返回 commit 对应的 tree hash', () => {
502
+ mockedExecCommand.mockReturnValue('treehash456');
503
+ expect(getCommitTreeHash('commithash123')).toBe('treehash456');
504
+ });
505
+ });
506
+
507
+ describe('gitDiffTree', () => {
508
+ it('调用 execSync 获取 tree diff', () => {
509
+ const buffer = Buffer.from('diff');
510
+ mockedExecSync.mockReturnValue(buffer);
511
+ const result = gitDiffTree('base123', 'target456', '/repo');
512
+ expect(result).toBe(buffer);
513
+ expect(mockedExecSync).toHaveBeenCalledWith(
514
+ 'git diff-tree -p --binary base123 target456',
515
+ expect.objectContaining({ cwd: '/repo' }),
516
+ );
517
+ });
518
+ });
519
+
520
+ describe('gitApplyCachedCheck', () => {
521
+ it('patch 可应用时返回 true', () => {
522
+ const patch = Buffer.from('patch');
523
+ mockedExecCommandWithInput.mockReturnValue('');
524
+ expect(gitApplyCachedCheck(patch, '/repo')).toBe(true);
525
+ });
526
+
527
+ it('patch 不可应用时返回 false', () => {
528
+ const patch = Buffer.from('bad patch');
529
+ mockedExecCommandWithInput.mockImplementation(() => { throw new Error('conflict'); });
530
+ expect(gitApplyCachedCheck(patch, '/repo')).toBe(false);
531
+ });
532
+ });
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+
3
+ // mock node:child_process
4
+ vi.mock('node:child_process', () => ({
5
+ execSync: vi.fn(),
6
+ execFileSync: vi.fn(),
7
+ spawn: vi.fn(),
8
+ }));
9
+
10
+ // mock logger
11
+ vi.mock('../../../src/logger/index.js', () => ({
12
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
13
+ }));
14
+
15
+ import { execSync, execFileSync, spawn } from 'node:child_process';
16
+ import { execCommand, execCommandWithInput, spawnProcess, killAllChildProcesses } from '../../../src/utils/shell.js';
17
+
18
+ const mockedExecSync = vi.mocked(execSync);
19
+ const mockedExecFileSync = vi.mocked(execFileSync);
20
+ const mockedSpawn = vi.mocked(spawn);
21
+
22
+ describe('execCommand', () => {
23
+ it('返回 trim 后的字符串', () => {
24
+ mockedExecSync.mockReturnValue(' result \n');
25
+ expect(execCommand('git status')).toBe('result');
26
+ });
27
+
28
+ it('传递 cwd 选项', () => {
29
+ mockedExecSync.mockReturnValue('ok');
30
+ execCommand('git log', { cwd: '/some/path' });
31
+ expect(mockedExecSync).toHaveBeenCalledWith('git log', expect.objectContaining({
32
+ cwd: '/some/path',
33
+ }));
34
+ });
35
+
36
+ it('命令失败时抛出异常', () => {
37
+ mockedExecSync.mockImplementation(() => { throw new Error('command failed'); });
38
+ expect(() => execCommand('invalid-cmd')).toThrow('command failed');
39
+ });
40
+
41
+ it('不传 cwd 时使用 undefined', () => {
42
+ mockedExecSync.mockReturnValue('ok');
43
+ execCommand('git status');
44
+ expect(mockedExecSync).toHaveBeenCalledWith('git status', expect.objectContaining({
45
+ cwd: undefined,
46
+ }));
47
+ });
48
+ });
49
+
50
+ describe('execCommandWithInput', () => {
51
+ it('正确传递 input 和 args', () => {
52
+ mockedExecFileSync.mockReturnValue('applied');
53
+ const input = Buffer.from('patch content');
54
+ execCommandWithInput('git', ['apply', '--cached'], { input, cwd: '/repo' });
55
+ expect(mockedExecFileSync).toHaveBeenCalledWith('git', ['apply', '--cached'], expect.objectContaining({
56
+ input,
57
+ cwd: '/repo',
58
+ }));
59
+ });
60
+
61
+ it('返回 trim 后的字符串', () => {
62
+ mockedExecFileSync.mockReturnValue(' result \n');
63
+ const result = execCommandWithInput('git', ['apply'], { input: Buffer.from('data') });
64
+ expect(result).toBe('result');
65
+ });
66
+ });
67
+
68
+ describe('spawnProcess', () => {
69
+ it('调用 spawn 并返回结果', () => {
70
+ const fakeChild = { pid: 123 };
71
+ mockedSpawn.mockReturnValue(fakeChild as any);
72
+ const result = spawnProcess('claude', ['--help'], { cwd: '/repo' });
73
+ expect(result).toBe(fakeChild);
74
+ expect(mockedSpawn).toHaveBeenCalledWith('claude', ['--help'], expect.objectContaining({
75
+ cwd: '/repo',
76
+ }));
77
+ });
78
+
79
+ it('默认 stdio 为 pipe', () => {
80
+ mockedSpawn.mockReturnValue({ pid: 1 } as any);
81
+ spawnProcess('cmd', []);
82
+ expect(mockedSpawn).toHaveBeenCalledWith('cmd', [], expect.objectContaining({
83
+ stdio: ['pipe', 'pipe', 'pipe'],
84
+ }));
85
+ });
86
+ });
87
+
88
+ describe('killAllChildProcesses', () => {
89
+ it('终止未 killed 的进程', () => {
90
+ const child1 = { killed: false, kill: vi.fn() };
91
+ const child2 = { killed: false, kill: vi.fn() };
92
+ killAllChildProcesses([child1, child2] as any);
93
+ expect(child1.kill).toHaveBeenCalledWith('SIGTERM');
94
+ expect(child2.kill).toHaveBeenCalledWith('SIGTERM');
95
+ });
96
+
97
+ it('跳过已 killed 的进程', () => {
98
+ const child1 = { killed: true, kill: vi.fn() };
99
+ const child2 = { killed: false, kill: vi.fn() };
100
+ killAllChildProcesses([child1, child2] as any);
101
+ expect(child1.kill).not.toHaveBeenCalled();
102
+ expect(child2.kill).toHaveBeenCalledWith('SIGTERM');
103
+ });
104
+
105
+ it('空数组时不报错', () => {
106
+ expect(() => killAllChildProcesses([])).not.toThrow();
107
+ });
108
+ });