clawt 2.10.1 → 2.11.1

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.
@@ -25,19 +25,67 @@ vi.mock('../../../src/constants/index.js', () => ({
25
25
  INTERRUPT_CONFIRM_CLEANUP: '是否清理已创建的 worktree?',
26
26
  INTERRUPT_CLEANED: (count: number) => `已清理 ${count} 个 worktree`,
27
27
  INTERRUPT_KEPT: '已保留 worktree',
28
+ CONCURRENCY_INFO: (concurrency: number, total: number) => `并发限制: ${concurrency},共 ${total} 个任务`,
29
+ CONCURRENCY_INVALID: '并发数必须为正整数',
30
+ FILE_AND_TASKS_CONFLICT: '--file 和 --tasks 不能同时使用',
31
+ BRANCH_OR_FILE_REQUIRED: '请指定 -b 或 -f',
32
+ TASK_FILE_LOADED: (count: number, path: string) => `✓ 从 ${path} 加载了 ${count} 个任务`,
33
+ TASK_FILE_MISSING_TASK_BY_INDEX: (blockIndex: number) => `第 ${blockIndex} 个任务块缺少任务描述`,
28
34
  },
29
35
  }));
30
36
 
31
- vi.mock('../../../src/utils/index.js', () => ({
32
- validateMainWorktree: vi.fn(),
33
- validateClaudeCodeInstalled: vi.fn(),
34
- createWorktrees: vi.fn(),
35
- sanitizeBranchName: vi.fn(),
36
- checkBranchExists: vi.fn(),
37
+ /**
38
+ * mock utils/index.js —— run.ts 的直接依赖
39
+ * 注意:executeBatchTasks 不在此 mock,由 utils/index.js 的 re-export 指向 task-executor.js 的真实实现
40
+ */
41
+ vi.mock('../../../src/utils/index.js', async (importOriginal) => {
42
+ const actual = await importOriginal<typeof import('../../../src/utils/index.js')>();
43
+ return {
44
+ ...actual,
45
+ validateMainWorktree: vi.fn(),
46
+ validateClaudeCodeInstalled: vi.fn(),
47
+ createWorktrees: vi.fn(),
48
+ sanitizeBranchName: vi.fn(),
49
+ checkBranchExists: vi.fn(),
50
+ getConfigValue: vi.fn().mockReturnValue(0),
51
+ printSuccess: vi.fn(),
52
+ printError: vi.fn(),
53
+ printWarning: vi.fn(),
54
+ printInfo: vi.fn(),
55
+ printSeparator: vi.fn(),
56
+ printDoubleSeparator: vi.fn(),
57
+ confirmAction: vi.fn(),
58
+ launchInteractiveClaude: vi.fn(),
59
+ loadTaskFile: vi.fn(),
60
+ createWorktreesByBranches: vi.fn(),
61
+ };
62
+ });
63
+
64
+ /** task-executor.ts 内部依赖的具体模块 mock */
65
+ vi.mock('../../../src/utils/shell.js', () => ({
37
66
  spawnProcess: vi.fn(),
38
67
  killAllChildProcesses: vi.fn(),
68
+ execCommand: vi.fn(),
69
+ execCommandWithInput: vi.fn(),
70
+ }));
71
+
72
+ vi.mock('../../../src/utils/worktree.js', () => ({
39
73
  cleanupWorktrees: vi.fn(),
40
- getConfigValue: vi.fn(),
74
+ createWorktrees: vi.fn(),
75
+ getProjectWorktrees: vi.fn(),
76
+ getProjectWorktreeDir: vi.fn(),
77
+ getWorktreeStatus: vi.fn(),
78
+ createWorktreesByBranches: vi.fn(),
79
+ }));
80
+
81
+ vi.mock('../../../src/utils/config.js', () => ({
82
+ getConfigValue: vi.fn().mockReturnValue(0),
83
+ loadConfig: vi.fn(),
84
+ writeDefaultConfig: vi.fn(),
85
+ ensureClawtDirs: vi.fn(),
86
+ }));
87
+
88
+ vi.mock('../../../src/utils/formatter.js', () => ({
41
89
  printSuccess: vi.fn(),
42
90
  printError: vi.fn(),
43
91
  printWarning: vi.fn(),
@@ -45,25 +93,47 @@ vi.mock('../../../src/utils/index.js', () => ({
45
93
  printSeparator: vi.fn(),
46
94
  printDoubleSeparator: vi.fn(),
47
95
  confirmAction: vi.fn(),
48
- launchInteractiveClaude: vi.fn(),
96
+ confirmDestructiveAction: vi.fn(),
97
+ formatWorktreeStatus: vi.fn(),
98
+ isWorktreeIdle: vi.fn(),
99
+ formatDuration: vi.fn(),
100
+ }));
101
+
102
+ vi.mock('../../../src/utils/progress.js', () => ({
103
+ ProgressRenderer: class {
104
+ start = vi.fn();
105
+ stop = vi.fn();
106
+ updateActivity = vi.fn();
107
+ markRunning = vi.fn();
108
+ markDone = vi.fn();
109
+ markFailed = vi.fn();
110
+ },
49
111
  }));
50
112
 
51
113
  import { registerRunCommand } from '../../../src/commands/run.js';
52
114
  import {
53
115
  createWorktrees,
116
+ createWorktreesByBranches,
54
117
  sanitizeBranchName,
55
118
  checkBranchExists,
56
- spawnProcess,
57
119
  printSuccess,
58
120
  launchInteractiveClaude,
121
+ getConfigValue,
122
+ loadTaskFile,
59
123
  } from '../../../src/utils/index.js';
124
+ import { spawnProcess } from '../../../src/utils/shell.js';
125
+ import { printInfo } from '../../../src/utils/formatter.js';
60
126
 
61
127
  const mockedCreateWorktrees = vi.mocked(createWorktrees);
128
+ const mockedCreateWorktreesByBranches = vi.mocked(createWorktreesByBranches);
62
129
  const mockedSanitizeBranchName = vi.mocked(sanitizeBranchName);
63
130
  const mockedCheckBranchExists = vi.mocked(checkBranchExists);
64
131
  const mockedSpawnProcess = vi.mocked(spawnProcess);
65
132
  const mockedPrintSuccess = vi.mocked(printSuccess);
133
+ const mockedPrintInfo = vi.mocked(printInfo);
66
134
  const mockedLaunchInteractiveClaude = vi.mocked(launchInteractiveClaude);
135
+ const mockedGetConfigValue = vi.mocked(getConfigValue);
136
+ const mockedLoadTaskFile = vi.mocked(loadTaskFile);
67
137
 
68
138
  /**
69
139
  * 创建模拟子进程
@@ -78,12 +148,14 @@ function createMockChildProcess(stdout: string, exitCode: number) {
78
148
  child.stdout = stdoutStream;
79
149
  child.stderr = stderrStream;
80
150
  child.pid = 12345;
151
+ child.exitCode = null;
81
152
 
82
153
  // 延迟触发 close 事件
83
154
  setTimeout(() => {
84
155
  stdoutStream.push(stdout);
85
156
  stdoutStream.push(null);
86
157
  stderrStream.push(null);
158
+ child.exitCode = exitCode;
87
159
  child.emit('close', exitCode);
88
160
  }, 10);
89
161
 
@@ -92,11 +164,18 @@ function createMockChildProcess(stdout: string, exitCode: number) {
92
164
 
93
165
  beforeEach(() => {
94
166
  mockedCreateWorktrees.mockReset();
167
+ mockedCreateWorktreesByBranches.mockReset();
95
168
  mockedSanitizeBranchName.mockReset();
96
169
  mockedCheckBranchExists.mockReset();
97
170
  mockedSpawnProcess.mockReset();
98
171
  mockedPrintSuccess.mockReset();
172
+ mockedPrintInfo.mockReset();
99
173
  mockedLaunchInteractiveClaude.mockReset();
174
+ mockedGetConfigValue.mockReset();
175
+ mockedGetConfigValue.mockReturnValue(0 as any);
176
+ mockedLoadTaskFile.mockReset();
177
+ // sanitizeBranchName 默认返回输入值
178
+ mockedSanitizeBranchName.mockImplementation((name: string) => name);
100
179
  });
101
180
 
102
181
  describe('registerRunCommand', () => {
@@ -160,7 +239,6 @@ describe('handleRun', () => {
160
239
 
161
240
  expect(mockedCreateWorktrees).toHaveBeenCalledWith('feat', 2);
162
241
  expect(mockedSpawnProcess).toHaveBeenCalledTimes(2);
163
- expect(mockedPrintSuccess).toHaveBeenCalled();
164
242
  });
165
243
 
166
244
  it('任务执行失败时在通知中报告', async () => {
@@ -192,6 +270,7 @@ describe('handleRun', () => {
192
270
  child.stdout = new Readable({ read() {} });
193
271
  child.stderr = new Readable({ read() {} });
194
272
  child.pid = 12345;
273
+ child.exitCode = null;
195
274
  setTimeout(() => {
196
275
  child.emit('error', new Error('spawn error'));
197
276
  }, 10);
@@ -204,4 +283,174 @@ describe('handleRun', () => {
204
283
 
205
284
  expect(mockedSpawnProcess).toHaveBeenCalledTimes(1);
206
285
  });
286
+
287
+ it('传 --concurrency 限制并发数', async () => {
288
+ const worktrees = [
289
+ { path: '/path/feat-1', branch: 'feat-1' },
290
+ { path: '/path/feat-2', branch: 'feat-2' },
291
+ { path: '/path/feat-3', branch: 'feat-3' },
292
+ ];
293
+ mockedCreateWorktrees.mockReturnValue(worktrees);
294
+
295
+ const jsonOutput = JSON.stringify({
296
+ is_error: false,
297
+ duration_ms: 5000,
298
+ total_cost_usd: 0.05,
299
+ });
300
+ mockedSpawnProcess
301
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0))
302
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0))
303
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0));
304
+
305
+ const program = new Command();
306
+ program.exitOverride();
307
+ registerRunCommand(program);
308
+ await program.parseAsync(['run', '-b', 'feat', '--tasks', 'task1', 'task2', 'task3', '-c', '1'], { from: 'user' });
309
+
310
+ expect(mockedCreateWorktrees).toHaveBeenCalledWith('feat', 3);
311
+ // 所有任务都应执行完毕
312
+ expect(mockedSpawnProcess).toHaveBeenCalledTimes(3);
313
+ // 应输出并发限制提示
314
+ expect(mockedPrintInfo).toHaveBeenCalledWith(expect.stringContaining('并发限制'));
315
+ });
316
+
317
+ it('--concurrency 为 0 时不限制并发', async () => {
318
+ const worktrees = [
319
+ { path: '/path/feat-1', branch: 'feat-1' },
320
+ { path: '/path/feat-2', branch: 'feat-2' },
321
+ ];
322
+ mockedCreateWorktrees.mockReturnValue(worktrees);
323
+
324
+ const jsonOutput = JSON.stringify({
325
+ is_error: false,
326
+ duration_ms: 5000,
327
+ total_cost_usd: 0.05,
328
+ });
329
+ mockedSpawnProcess
330
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0))
331
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0));
332
+
333
+ const program = new Command();
334
+ program.exitOverride();
335
+ registerRunCommand(program);
336
+ await program.parseAsync(['run', '-b', 'feat', '--tasks', 'task1', 'task2', '-c', '0'], { from: 'user' });
337
+
338
+ // 不限制并发时不输出并发限制提示
339
+ expect(mockedPrintInfo).not.toHaveBeenCalledWith(expect.stringContaining('并发限制'));
340
+ expect(mockedSpawnProcess).toHaveBeenCalledTimes(2);
341
+ });
342
+
343
+ it('未传 -c 时使用全局配置的 maxConcurrency', async () => {
344
+ mockedGetConfigValue.mockReturnValue(2 as any);
345
+
346
+ const worktrees = [
347
+ { path: '/path/feat-1', branch: 'feat-1' },
348
+ { path: '/path/feat-2', branch: 'feat-2' },
349
+ { path: '/path/feat-3', branch: 'feat-3' },
350
+ ];
351
+ mockedCreateWorktrees.mockReturnValue(worktrees);
352
+
353
+ const jsonOutput = JSON.stringify({
354
+ is_error: false,
355
+ duration_ms: 5000,
356
+ total_cost_usd: 0.05,
357
+ });
358
+ mockedSpawnProcess
359
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0))
360
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0))
361
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0));
362
+
363
+ const program = new Command();
364
+ program.exitOverride();
365
+ registerRunCommand(program);
366
+ await program.parseAsync(['run', '-b', 'feat', '--tasks', 'task1', 'task2', 'task3'], { from: 'user' });
367
+
368
+ // 应输出并发限制提示(使用配置值 2)
369
+ expect(mockedPrintInfo).toHaveBeenCalledWith(expect.stringContaining('并发限制'));
370
+ expect(mockedSpawnProcess).toHaveBeenCalledTimes(3);
371
+ });
372
+
373
+ it('-f 从文件加载任务并执行(无 -b,使用文件中分支名)', async () => {
374
+ mockedLoadTaskFile.mockReturnValue([
375
+ { branch: 'feat-login', task: '实现登录功能' },
376
+ { branch: 'fix-bug', task: '修复问题' },
377
+ ]);
378
+ const worktrees = [
379
+ { path: '/path/feat-login', branch: 'feat-login' },
380
+ { path: '/path/fix-bug', branch: 'fix-bug' },
381
+ ];
382
+ mockedCreateWorktreesByBranches.mockReturnValue(worktrees);
383
+
384
+ const jsonOutput = JSON.stringify({
385
+ is_error: false,
386
+ duration_ms: 5000,
387
+ total_cost_usd: 0.05,
388
+ });
389
+ mockedSpawnProcess
390
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0))
391
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0));
392
+
393
+ const program = new Command();
394
+ program.exitOverride();
395
+ registerRunCommand(program);
396
+ await program.parseAsync(['run', '-f', 'tasks.md'], { from: 'user' });
397
+
398
+ expect(mockedLoadTaskFile).toHaveBeenCalledWith('tasks.md', { branchRequired: true });
399
+ expect(mockedCreateWorktreesByBranches).toHaveBeenCalledWith(['feat-login', 'fix-bug']);
400
+ expect(mockedSpawnProcess).toHaveBeenCalledTimes(2);
401
+ });
402
+
403
+ it('-f + -b 模式使用 -b 自动编号', async () => {
404
+ mockedLoadTaskFile.mockReturnValue([
405
+ { task: '任务1' },
406
+ { task: '任务2' },
407
+ ]);
408
+ const worktrees = [
409
+ { path: '/path/feat-1', branch: 'feat-1' },
410
+ { path: '/path/feat-2', branch: 'feat-2' },
411
+ ];
412
+ mockedCreateWorktrees.mockReturnValue(worktrees);
413
+
414
+ const jsonOutput = JSON.stringify({
415
+ is_error: false,
416
+ duration_ms: 5000,
417
+ total_cost_usd: 0.05,
418
+ });
419
+ mockedSpawnProcess
420
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0))
421
+ .mockReturnValueOnce(createMockChildProcess(jsonOutput, 0));
422
+
423
+ const program = new Command();
424
+ program.exitOverride();
425
+ registerRunCommand(program);
426
+ await program.parseAsync(['run', '-b', 'feat', '-f', 'tasks.md'], { from: 'user' });
427
+
428
+ // 应使用 createWorktrees(带 -b 自动编号),而非 createWorktreesByBranches
429
+ expect(mockedLoadTaskFile).toHaveBeenCalledWith('tasks.md', { branchRequired: false });
430
+ expect(mockedCreateWorktrees).toHaveBeenCalledWith('feat', 2);
431
+ expect(mockedCreateWorktreesByBranches).not.toHaveBeenCalled();
432
+ expect(mockedSpawnProcess).toHaveBeenCalledTimes(2);
433
+ });
434
+
435
+ it('-f 和 --tasks 互斥时报错', async () => {
436
+ mockedLoadTaskFile.mockReturnValue([{ branch: 'feat', task: '任务' }]);
437
+
438
+ const program = new Command();
439
+ program.exitOverride();
440
+ registerRunCommand(program);
441
+
442
+ await expect(
443
+ program.parseAsync(['run', '-f', 'tasks.md', '--tasks', 'task1'], { from: 'user' }),
444
+ ).rejects.toThrow();
445
+ });
446
+
447
+ it('未传 -b 和 -f 时报错', async () => {
448
+ const program = new Command();
449
+ program.exitOverride();
450
+ registerRunCommand(program);
451
+
452
+ await expect(
453
+ program.parseAsync(['run', '--tasks', 'task1'], { from: 'user' }),
454
+ ).rejects.toThrow();
455
+ });
207
456
  });
@@ -30,6 +30,7 @@ describe('DEFAULT_CONFIG', () => {
30
30
  expect(DEFAULT_CONFIG.claudeCodeCommand).toBe('claude');
31
31
  expect(DEFAULT_CONFIG.autoPullPush).toBe(false);
32
32
  expect(DEFAULT_CONFIG.confirmDestructiveOps).toBe(true);
33
+ expect(DEFAULT_CONFIG.maxConcurrency).toBe(0);
33
34
  });
34
35
  });
35
36
 
@@ -10,6 +10,12 @@ vi.mock('node:child_process', () => ({
10
10
  spawnSync: vi.fn(),
11
11
  }));
12
12
 
13
+ // mock node:fs
14
+ vi.mock('node:fs', () => ({
15
+ existsSync: vi.fn(),
16
+ readdirSync: vi.fn(),
17
+ }));
18
+
13
19
  // mock config
14
20
  vi.mock('../../../src/utils/config.js', () => ({
15
21
  getConfigValue: vi.fn(),
@@ -22,7 +28,8 @@ vi.mock('../../../src/utils/formatter.js', () => ({
22
28
  }));
23
29
 
24
30
  import { spawnSync } from 'node:child_process';
25
- import { launchInteractiveClaude } from '../../../src/utils/claude.js';
31
+ import { existsSync, readdirSync } from 'node:fs';
32
+ import { launchInteractiveClaude, hasClaudeSessionHistory } from '../../../src/utils/claude.js';
26
33
  import { getConfigValue } from '../../../src/utils/config.js';
27
34
  import { printInfo, printWarning } from '../../../src/utils/formatter.js';
28
35
  import { ClawtError } from '../../../src/errors/index.js';
@@ -32,6 +39,45 @@ const mockedSpawnSync = vi.mocked(spawnSync);
32
39
  const mockedGetConfigValue = vi.mocked(getConfigValue);
33
40
  const mockedPrintInfo = vi.mocked(printInfo);
34
41
  const mockedPrintWarning = vi.mocked(printWarning);
42
+ const mockedExistsSync = vi.mocked(existsSync);
43
+ const mockedReaddirSync = vi.mocked(readdirSync);
44
+
45
+ describe('hasClaudeSessionHistory', () => {
46
+ it('项目目录不存在时返回 false', () => {
47
+ mockedExistsSync.mockReturnValue(false);
48
+
49
+ expect(hasClaudeSessionHistory('/Users/test/project')).toBe(false);
50
+ expect(mockedExistsSync).toHaveBeenCalled();
51
+ });
52
+
53
+ it('项目目录存在但无 .jsonl 文件时返回 false', () => {
54
+ mockedExistsSync.mockReturnValue(true);
55
+ mockedReaddirSync.mockReturnValue(['memory', 'CLAUDE.md'] as unknown as ReturnType<typeof readdirSync>);
56
+
57
+ expect(hasClaudeSessionHistory('/Users/test/project')).toBe(false);
58
+ });
59
+
60
+ it('项目目录存在且有 .jsonl 文件时返回 true', () => {
61
+ mockedExistsSync.mockReturnValue(true);
62
+ mockedReaddirSync.mockReturnValue([
63
+ 'abc-123.jsonl',
64
+ 'memory',
65
+ ] as unknown as ReturnType<typeof readdirSync>);
66
+
67
+ expect(hasClaudeSessionHistory('/Users/test/project')).toBe(true);
68
+ });
69
+
70
+ it('路径编码规则正确(非字母数字字符替换为 -)', () => {
71
+ mockedExistsSync.mockReturnValue(false);
72
+
73
+ hasClaudeSessionHistory('/Users/qihoo/.clawt/worktrees/clawt/resume');
74
+
75
+ // 验证 existsSync 被调用时路径包含编码后的目录名
76
+ // /Users/qihoo/.clawt/worktrees/clawt/resume → -Users-qihoo--clawt-worktrees-clawt-resume
77
+ const calledPath = mockedExistsSync.mock.calls[0][0] as string;
78
+ expect(calledPath).toContain('-Users-qihoo--clawt-worktrees-clawt-resume');
79
+ });
80
+ });
35
81
 
36
82
  describe('launchInteractiveClaude', () => {
37
83
  const worktree = createWorktreeInfo({
@@ -41,6 +87,7 @@ describe('launchInteractiveClaude', () => {
41
87
 
42
88
  it('正常启动 Claude Code(退出码为 0)', () => {
43
89
  mockedGetConfigValue.mockReturnValue('claude');
90
+ mockedExistsSync.mockReturnValue(false);
44
91
  mockedSpawnSync.mockReturnValue({
45
92
  status: 0,
46
93
  error: undefined,
@@ -66,6 +113,7 @@ describe('launchInteractiveClaude', () => {
66
113
 
67
114
  it('输出分支和路径信息', () => {
68
115
  mockedGetConfigValue.mockReturnValue('claude');
116
+ mockedExistsSync.mockReturnValue(false);
69
117
  mockedSpawnSync.mockReturnValue({
70
118
  status: 0,
71
119
  error: undefined,
@@ -84,6 +132,7 @@ describe('launchInteractiveClaude', () => {
84
132
 
85
133
  it('支持带参数的命令(如 npx claude)', () => {
86
134
  mockedGetConfigValue.mockReturnValue('npx claude');
135
+ mockedExistsSync.mockReturnValue(false);
87
136
  mockedSpawnSync.mockReturnValue({
88
137
  status: 0,
89
138
  error: undefined,
@@ -105,6 +154,7 @@ describe('launchInteractiveClaude', () => {
105
154
 
106
155
  it('spawnSync 返回 error 时抛出 ClawtError', () => {
107
156
  mockedGetConfigValue.mockReturnValue('claude');
157
+ mockedExistsSync.mockReturnValue(false);
108
158
  mockedSpawnSync.mockReturnValue({
109
159
  status: null,
110
160
  error: new Error('命令未找到'),
@@ -121,6 +171,7 @@ describe('launchInteractiveClaude', () => {
121
171
 
122
172
  it('非零退出码时调用 printWarning', () => {
123
173
  mockedGetConfigValue.mockReturnValue('claude');
174
+ mockedExistsSync.mockReturnValue(false);
124
175
  mockedSpawnSync.mockReturnValue({
125
176
  status: 1,
126
177
  error: undefined,
@@ -138,6 +189,7 @@ describe('launchInteractiveClaude', () => {
138
189
 
139
190
  it('退出码为 null 时不调用 printWarning', () => {
140
191
  mockedGetConfigValue.mockReturnValue('claude');
192
+ mockedExistsSync.mockReturnValue(false);
141
193
  mockedSpawnSync.mockReturnValue({
142
194
  status: null,
143
195
  error: undefined,
@@ -155,6 +207,7 @@ describe('launchInteractiveClaude', () => {
155
207
 
156
208
  it('退出码为 0 时不调用 printWarning', () => {
157
209
  mockedGetConfigValue.mockReturnValue('claude');
210
+ mockedExistsSync.mockReturnValue(false);
158
211
  mockedSpawnSync.mockReturnValue({
159
212
  status: 0,
160
213
  error: undefined,
@@ -169,4 +222,65 @@ describe('launchInteractiveClaude', () => {
169
222
 
170
223
  expect(mockedPrintWarning).not.toHaveBeenCalled();
171
224
  });
225
+
226
+ it('autoContinue 启用且有会话历史时追加 --continue 参数', () => {
227
+ mockedGetConfigValue.mockReturnValue('claude');
228
+ mockedExistsSync.mockReturnValue(true);
229
+ mockedReaddirSync.mockReturnValue(['session-abc.jsonl'] as unknown as ReturnType<typeof readdirSync>);
230
+ mockedSpawnSync.mockReturnValue({
231
+ status: 0,
232
+ error: undefined,
233
+ stdout: '',
234
+ stderr: '',
235
+ pid: 1234,
236
+ output: [],
237
+ signal: null,
238
+ });
239
+
240
+ launchInteractiveClaude(worktree, { autoContinue: true });
241
+
242
+ const callArgs = mockedSpawnSync.mock.calls[0][1] as string[];
243
+ expect(callArgs).toContain('--continue');
244
+ expect(mockedPrintInfo).toHaveBeenCalledWith(expect.stringContaining('继续上次对话'));
245
+ });
246
+
247
+ it('autoContinue 启用但无会话历史时不追加 --continue 参数', () => {
248
+ mockedGetConfigValue.mockReturnValue('claude');
249
+ mockedExistsSync.mockReturnValue(false);
250
+ mockedSpawnSync.mockReturnValue({
251
+ status: 0,
252
+ error: undefined,
253
+ stdout: '',
254
+ stderr: '',
255
+ pid: 1234,
256
+ output: [],
257
+ signal: null,
258
+ });
259
+
260
+ launchInteractiveClaude(worktree, { autoContinue: true });
261
+
262
+ const callArgs = mockedSpawnSync.mock.calls[0][1] as string[];
263
+ expect(callArgs).not.toContain('--continue');
264
+ expect(mockedPrintInfo).toHaveBeenCalledWith(expect.stringContaining('新对话'));
265
+ });
266
+
267
+ it('不传 autoContinue 时即使有会话历史也不追加 --continue', () => {
268
+ mockedGetConfigValue.mockReturnValue('claude');
269
+ mockedExistsSync.mockReturnValue(true);
270
+ mockedReaddirSync.mockReturnValue(['session-abc.jsonl'] as unknown as ReturnType<typeof readdirSync>);
271
+ mockedSpawnSync.mockReturnValue({
272
+ status: 0,
273
+ error: undefined,
274
+ stdout: '',
275
+ stderr: '',
276
+ pid: 1234,
277
+ output: [],
278
+ signal: null,
279
+ });
280
+
281
+ launchInteractiveClaude(worktree);
282
+
283
+ const callArgs = mockedSpawnSync.mock.calls[0][1] as string[];
284
+ expect(callArgs).not.toContain('--continue');
285
+ });
172
286
  });
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
- import { formatWorktreeStatus, printSuccess, printError, printWarning, printInfo, printSeparator, printDoubleSeparator, isWorktreeIdle } from '../../../src/utils/formatter.js';
2
+ import { formatWorktreeStatus, printSuccess, printError, printWarning, printInfo, printSeparator, printDoubleSeparator, isWorktreeIdle, formatDuration } from '../../../src/utils/formatter.js';
3
3
  import { createWorktreeStatus } from '../../helpers/fixtures.js';
4
4
 
5
5
  describe('formatWorktreeStatus', () => {
@@ -110,3 +110,29 @@ describe('print 函数', () => {
110
110
  expect(spy.mock.calls[0][0]).toBe('普通消息');
111
111
  });
112
112
  });
113
+
114
+ describe('formatDuration', () => {
115
+ it('小于 60 秒时显示秒数(保留一位小数)', () => {
116
+ expect(formatDuration(5200)).toBe('5.2s');
117
+ });
118
+
119
+ it('0 毫秒时显示 0.0s', () => {
120
+ expect(formatDuration(0)).toBe('0.0s');
121
+ });
122
+
123
+ it('59.9 秒时仍显示秒数', () => {
124
+ expect(formatDuration(59900)).toBe('59.9s');
125
+ });
126
+
127
+ it('大于等于 60 秒时显示分秒格式', () => {
128
+ expect(formatDuration(83000)).toBe('1m23s');
129
+ });
130
+
131
+ it('整分钟时秒数补零', () => {
132
+ expect(formatDuration(120000)).toBe('2m00s');
133
+ });
134
+
135
+ it('大数值时正确格式化', () => {
136
+ expect(formatDuration(3661000)).toBe('61m01s');
137
+ });
138
+ });