clawt 2.16.3 → 2.16.5

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.
@@ -58,21 +58,40 @@ describe('ProgressRenderer', () => {
58
58
  expect(showCursorCount).toBe(1);
59
59
  });
60
60
 
61
- it('渲染面板包含分支名、运行状态和路径', () => {
61
+ it('渲染面板第二列显示路径和运行状态', () => {
62
62
  const renderer = new ProgressRenderer(['feat-1', 'feat-2'], ['/path/feat-1', '/path/feat-2']);
63
63
  renderer.start();
64
64
 
65
65
  const allOutput = writeSpy.mock.calls.map((c) => c[0]).join('');
66
- expect(allOutput).toContain('feat-1');
67
- expect(allOutput).toContain('feat-2');
68
- expect(allOutput).toContain('运行中');
66
+ // 第二列应显示路径
69
67
  expect(allOutput).toContain('/path/feat-1');
70
68
  expect(allOutput).toContain('/path/feat-2');
69
+ expect(allOutput).toContain('运行中');
70
+ // running 状态下不显示额外路径信息
71
+
72
+ renderer.stop();
73
+ });
74
+
75
+ it('markDone 后渲染显示完成状态和结果预览', () => {
76
+ const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
77
+ renderer.start();
78
+ writeSpy.mockClear();
71
79
 
80
+ renderer.markDone(0, 5000, 0.05, '任务已成功完成');
72
81
  renderer.stop();
82
+
83
+ const allOutput = writeSpy.mock.calls.map((c) => c[0]).join('');
84
+ expect(allOutput).toContain('✓');
85
+ expect(allOutput).toContain('完成');
86
+ expect(allOutput).toContain('5.0s');
87
+ expect(allOutput).toContain('$0.05');
88
+ // 第二列显示路径
89
+ expect(allOutput).toContain('/path/feat-1');
90
+ // 末尾显示结果预览
91
+ expect(allOutput).toContain('任务已成功完成');
73
92
  });
74
93
 
75
- it('markDone 后渲染显示完成状态和路径', () => {
94
+ it('markDone 无 resultPreview 时末尾不显示预览', () => {
76
95
  const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
77
96
  renderer.start();
78
97
  writeSpy.mockClear();
@@ -85,10 +104,29 @@ describe('ProgressRenderer', () => {
85
104
  expect(allOutput).toContain('完成');
86
105
  expect(allOutput).toContain('5.0s');
87
106
  expect(allOutput).toContain('$0.05');
107
+ // 第二列仍显示路径
88
108
  expect(allOutput).toContain('/path/feat-1');
89
109
  });
90
110
 
91
- it('markFailed 后渲染显示失败状态和路径', () => {
111
+ it('markFailed 后渲染显示失败状态和结果预览', () => {
112
+ const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
113
+ renderer.start();
114
+ writeSpy.mockClear();
115
+
116
+ renderer.markFailed(0, 3000, '执行过程中发生错误');
117
+ renderer.stop();
118
+
119
+ const allOutput = writeSpy.mock.calls.map((c) => c[0]).join('');
120
+ expect(allOutput).toContain('✗');
121
+ expect(allOutput).toContain('失败');
122
+ expect(allOutput).toContain('3.0s');
123
+ // 第二列显示路径
124
+ expect(allOutput).toContain('/path/feat-1');
125
+ // 末尾显示结果预览
126
+ expect(allOutput).toContain('执行过程中发生错误');
127
+ });
128
+
129
+ it('markFailed 无 resultPreview 时末尾不显示预览', () => {
92
130
  const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
93
131
  renderer.start();
94
132
  writeSpy.mockClear();
@@ -100,6 +138,7 @@ describe('ProgressRenderer', () => {
100
138
  expect(allOutput).toContain('✗');
101
139
  expect(allOutput).toContain('失败');
102
140
  expect(allOutput).toContain('3.0s');
141
+ // 第二列仍显示路径
103
142
  expect(allOutput).toContain('/path/feat-1');
104
143
  });
105
144
 
@@ -167,6 +206,123 @@ describe('ProgressRenderer', () => {
167
206
  // 应包含:1/3 完成, 1/3 运行中, 1/3 排队中
168
207
  expect(allOutput).toContain('1/3');
169
208
  });
209
+
210
+ it('updateActivityText 更新活动文本后渲染显示活动信息', () => {
211
+ const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
212
+ renderer.start();
213
+ writeSpy.mockClear();
214
+
215
+ renderer.updateActivityText(0, '正在读取 git.ts');
216
+ renderer.stop();
217
+
218
+ const allOutput = writeSpy.mock.calls.map((c) => c[0]).join('');
219
+ expect(allOutput).toContain('正在读取 git.ts');
220
+ });
221
+
222
+ it('活动文本为 null 时 running 状态不显示额外信息', () => {
223
+ const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
224
+ renderer.start();
225
+ // 不调用 updateActivityText,activity 保持为 null
226
+ writeSpy.mockClear();
227
+
228
+ renderer.stop();
229
+
230
+ const allOutput = writeSpy.mock.calls.map((c) => c[0]).join('');
231
+ // 第二列显示路径
232
+ expect(allOutput).toContain('/path/feat-1');
233
+ expect(allOutput).toContain('运行中');
234
+ });
235
+
236
+ it('任务完成后活动文本不再显示', () => {
237
+ const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
238
+ renderer.start();
239
+ renderer.updateActivityText(0, '正在读取 git.ts');
240
+ renderer.markDone(0, 5000, 0.05, '代码审查完成');
241
+ writeSpy.mockClear();
242
+
243
+ renderer.stop();
244
+
245
+ const allOutput = writeSpy.mock.calls.map((c) => c[0]).join('');
246
+ // 完成状态第二列显示路径,末尾显示结果预览
247
+ expect(allOutput).toContain('/path/feat-1');
248
+ expect(allOutput).toContain('✓');
249
+ expect(allOutput).toContain('代码审查完成');
250
+ });
251
+
252
+ it('updateActivityText 不触发额外渲染(等待定时器)', () => {
253
+ const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
254
+ renderer.start();
255
+ const callCountBefore = writeSpy.mock.calls.length;
256
+
257
+ renderer.updateActivityText(0, '正在编辑 index.ts');
258
+
259
+ // updateActivityText 不应触发额外的 write 调用
260
+ expect(writeSpy.mock.calls.length).toBe(callCountBefore);
261
+
262
+ renderer.stop();
263
+ });
264
+
265
+ it('pending 状态不显示路径末尾信息', () => {
266
+ const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1'], false);
267
+ renderer.start();
268
+
269
+ const allOutput = writeSpy.mock.calls.map((c) => c[0]).join('');
270
+ // pending 状态第二列应显示路径,末尾无额外路径
271
+ expect(allOutput).toContain('/path/feat-1');
272
+ expect(allOutput).toContain('排队中');
273
+
274
+ renderer.stop();
275
+ });
276
+ });
277
+
278
+ describe('TTY 模式 — 窄终端宽度', () => {
279
+ let originalColumns: number | undefined;
280
+
281
+ beforeEach(() => {
282
+ Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true });
283
+ originalColumns = process.stdout.columns;
284
+ });
285
+
286
+ afterEach(() => {
287
+ Object.defineProperty(process.stdout, 'columns', { value: originalColumns, writable: true, configurable: true });
288
+ });
289
+
290
+ it('输出中包含 CLEAR_SCREEN 序列', () => {
291
+ Object.defineProperty(process.stdout, 'columns', { value: 120, writable: true, configurable: true });
292
+ const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
293
+ renderer.start();
294
+ renderer.stop();
295
+
296
+ const allOutput = writeSpy.mock.calls.map((c) => c[0]).join('');
297
+ // CLEAR_SCREEN = '\x1B[2J'
298
+ expect(allOutput).toContain('\x1B[2J');
299
+ });
300
+
301
+ it('窄终端下输出行的可见宽度不超过终端列数', () => {
302
+ Object.defineProperty(process.stdout, 'columns', { value: 40, writable: true, configurable: true });
303
+ const renderer = new ProgressRenderer(
304
+ ['feat-very-long-branch-name'],
305
+ ['/very/long/path/to/worktree/feat-very-long-branch-name'],
306
+ );
307
+ renderer.start();
308
+ renderer.stop();
309
+
310
+ // 检查每个以换行结尾的输出行
311
+ for (const call of writeSpy.mock.calls) {
312
+ const output = call[0] as string;
313
+ if (typeof output === 'string' && output.endsWith('\n')) {
314
+ // 移除 ANSI 转义码后检查可见宽度
315
+ const line = output.replace(/\n$/, '');
316
+ // 跳过纯控制序列行(如 CURSOR_HIDE、CLEAR_SCREEN、CURSOR_HOME)
317
+ if (line.length > 0 && !line.match(/^\x1B\[/)) {
318
+ // 使用 strip-ansi 计算可见宽度不超过 40
319
+ const stripped = line.replace(/\x1B\[[0-9;]*m/g, '');
320
+ expect(stripped.length).toBeLessThanOrEqual(40);
321
+ }
322
+ }
323
+ }
324
+ });
325
+
170
326
  });
171
327
 
172
328
  describe('非 TTY 模式', () => {
@@ -186,24 +342,58 @@ describe('ProgressRenderer', () => {
186
342
  renderer.stop();
187
343
  });
188
344
 
189
- it('markDone 时输出完成信息和路径', () => {
345
+ it('markDone 时输出完成信息和结果预览', () => {
190
346
  const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
191
347
  renderer.start();
192
348
  logSpy.mockClear();
193
349
 
194
- renderer.markDone(0, 5000, 0.05);
350
+ renderer.markDone(0, 5000, 0.05, '任务已成功完成');
195
351
 
196
352
  expect(logSpy).toHaveBeenCalledTimes(1);
197
353
  expect(logSpy.mock.calls[0][0]).toContain('✓');
198
354
  expect(logSpy.mock.calls[0][0]).toContain('完成');
199
355
  expect(logSpy.mock.calls[0][0]).toContain('5.0s');
200
356
  expect(logSpy.mock.calls[0][0]).toContain('$0.05');
357
+ // 末尾显示结果预览
358
+ expect(logSpy.mock.calls[0][0]).toContain('任务已成功完成');
359
+
360
+ renderer.stop();
361
+ });
362
+
363
+ it('markDone 无 resultPreview 时回退显示路径', () => {
364
+ const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
365
+ renderer.start();
366
+ logSpy.mockClear();
367
+
368
+ renderer.markDone(0, 5000, 0.05);
369
+
370
+ expect(logSpy).toHaveBeenCalledTimes(1);
371
+ expect(logSpy.mock.calls[0][0]).toContain('✓');
372
+ expect(logSpy.mock.calls[0][0]).toContain('完成');
373
+ // 无 resultPreview 时回退到 path
201
374
  expect(logSpy.mock.calls[0][0]).toContain('/path/feat-1');
202
375
 
203
376
  renderer.stop();
204
377
  });
205
378
 
206
- it('markFailed 时输出失败信息和路径', () => {
379
+ it('markFailed 时输出失败信息和结果预览', () => {
380
+ const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
381
+ renderer.start();
382
+ logSpy.mockClear();
383
+
384
+ renderer.markFailed(0, 3000, '执行过程中发生错误');
385
+
386
+ expect(logSpy).toHaveBeenCalledTimes(1);
387
+ expect(logSpy.mock.calls[0][0]).toContain('✗');
388
+ expect(logSpy.mock.calls[0][0]).toContain('失败');
389
+ expect(logSpy.mock.calls[0][0]).toContain('3.0s');
390
+ // 末尾显示结果预览
391
+ expect(logSpy.mock.calls[0][0]).toContain('执行过程中发生错误');
392
+
393
+ renderer.stop();
394
+ });
395
+
396
+ it('markFailed 无 resultPreview 时回退显示路径', () => {
207
397
  const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
208
398
  renderer.start();
209
399
  logSpy.mockClear();
@@ -214,6 +404,7 @@ describe('ProgressRenderer', () => {
214
404
  expect(logSpy.mock.calls[0][0]).toContain('✗');
215
405
  expect(logSpy.mock.calls[0][0]).toContain('失败');
216
406
  expect(logSpy.mock.calls[0][0]).toContain('3.0s');
407
+ // 无 resultPreview 时回退到 path
217
408
  expect(logSpy.mock.calls[0][0]).toContain('/path/feat-1');
218
409
 
219
410
  renderer.stop();
@@ -251,5 +442,195 @@ describe('ProgressRenderer', () => {
251
442
 
252
443
  renderer.stop();
253
444
  });
445
+
446
+ it('非 TTY 环境下 updateActivityText 不输出活动信息', () => {
447
+ const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
448
+ renderer.start();
449
+ logSpy.mockClear();
450
+
451
+ renderer.updateActivityText(0, '正在读取 git.ts');
452
+
453
+ // 非 TTY 模式不应输出活动文本
454
+ expect(logSpy).not.toHaveBeenCalled();
455
+
456
+ renderer.stop();
457
+ });
458
+ });
459
+
460
+ describe('TTY 模式 — 动态终端宽度', () => {
461
+ let originalColumns: number | undefined;
462
+ let onSpy: ReturnType<typeof vi.spyOn>;
463
+ let removeListenerSpy: ReturnType<typeof vi.spyOn>;
464
+
465
+ beforeEach(() => {
466
+ Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true });
467
+ originalColumns = process.stdout.columns;
468
+ onSpy = vi.spyOn(process.stdout, 'on');
469
+ removeListenerSpy = vi.spyOn(process.stdout, 'removeListener');
470
+ });
471
+
472
+ afterEach(() => {
473
+ Object.defineProperty(process.stdout, 'columns', { value: originalColumns, writable: true, configurable: true });
474
+ onSpy.mockRestore();
475
+ removeListenerSpy.mockRestore();
476
+ });
477
+
478
+ it('start 注册 resize 监听,stop 移除', () => {
479
+ Object.defineProperty(process.stdout, 'columns', { value: 120, writable: true, configurable: true });
480
+ const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
481
+ renderer.start();
482
+
483
+ // 验证 resize 监听已注册
484
+ const resizeCalls = onSpy.mock.calls.filter((c) => c[0] === 'resize');
485
+ expect(resizeCalls.length).toBeGreaterThanOrEqual(1);
486
+
487
+ renderer.stop();
488
+
489
+ // 验证 resize 监听已移除
490
+ const removeCalls = removeListenerSpy.mock.calls.filter((c) => c[0] === 'resize');
491
+ expect(removeCalls.length).toBeGreaterThanOrEqual(1);
492
+ });
493
+
494
+ it('start 时进入备选屏幕缓冲区', () => {
495
+ Object.defineProperty(process.stdout, 'columns', { value: 120, writable: true, configurable: true });
496
+ const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
497
+ renderer.start();
498
+ renderer.stop();
499
+
500
+ const allOutput = writeSpy.mock.calls.map((c) => c[0]).join('');
501
+ // ALT_SCREEN_ENTER = '\x1B[?1049h'
502
+ expect(allOutput).toContain('\x1B[?1049h');
503
+ });
504
+
505
+ it('stop 时退出备选屏幕缓冲区', () => {
506
+ Object.defineProperty(process.stdout, 'columns', { value: 120, writable: true, configurable: true });
507
+ const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
508
+ renderer.start();
509
+ renderer.stop();
510
+
511
+ const allOutput = writeSpy.mock.calls.map((c) => c[0]).join('');
512
+ // ALT_SCREEN_LEAVE = '\x1B[?1049l'
513
+ expect(allOutput).toContain('\x1B[?1049l');
514
+ });
515
+
516
+ it('stop 后在主屏幕输出最终面板状态', () => {
517
+ Object.defineProperty(process.stdout, 'columns', { value: 120, writable: true, configurable: true });
518
+ const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
519
+ renderer.start();
520
+ renderer.markDone(0, 5000, 0.05, '代码审查完成');
521
+ renderer.stop();
522
+
523
+ // 找到 ALT_SCREEN_LEAVE 之后的输出(即主屏幕上的最终状态)
524
+ const allCalls = writeSpy.mock.calls.map((c) => c[0] as string);
525
+ const leaveIndex = allCalls.findIndex((s) => s === '\x1B[?1049l');
526
+ expect(leaveIndex).toBeGreaterThan(-1);
527
+
528
+ // ALT_SCREEN_LEAVE 之后应有面板最终状态输出
529
+ const afterLeave = allCalls.slice(leaveIndex + 1).join('');
530
+ expect(afterLeave).toContain('✓');
531
+ expect(afterLeave).toContain('完成');
532
+ expect(afterLeave).toContain('/path/feat-1');
533
+ expect(afterLeave).toContain('代码审查完成');
534
+ });
535
+
536
+ it('exit 兜底处理器包含退出备选屏幕', () => {
537
+ Object.defineProperty(process.stdout, 'columns', { value: 120, writable: true, configurable: true });
538
+ const processOnSpy = vi.spyOn(process, 'on');
539
+
540
+ const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
541
+ renderer.start();
542
+
543
+ // 提取 exit 处理器
544
+ const exitCall = processOnSpy.mock.calls.find((c) => c[0] === 'exit');
545
+ expect(exitCall).toBeDefined();
546
+ const exitHandler = exitCall![1] as () => void;
547
+
548
+ // 清空已有调用记录
549
+ writeSpy.mockClear();
550
+
551
+ // 模拟调用 exit 处理器
552
+ exitHandler();
553
+
554
+ const allOutput = writeSpy.mock.calls.map((c) => c[0]).join('');
555
+ // 应包含恢复行换行、显示光标、退出备选屏幕
556
+ expect(allOutput).toContain('\x1B[?7h');
557
+ expect(allOutput).toContain('\x1B[?25h');
558
+ expect(allOutput).toContain('\x1B[?1049l');
559
+
560
+ renderer.stop();
561
+ processOnSpy.mockRestore();
562
+ });
563
+
564
+ it('resize 后面板正确重绘(输出包含 CLEAR_SCREEN + CURSOR_HOME)', () => {
565
+ // 初始终端 120 列
566
+ Object.defineProperty(process.stdout, 'columns', { value: 120, writable: true, configurable: true });
567
+ const renderer = new ProgressRenderer(
568
+ ['feat-1', 'feat-2'],
569
+ ['/path/to/worktree/feat-1', '/path/to/worktree/feat-2'],
570
+ );
571
+ renderer.start();
572
+
573
+ // 第一帧渲染完成后,清空记录
574
+ writeSpy.mockClear();
575
+
576
+ // 将终端缩窄到 60 列,触发 stop(内部再 render 一次)
577
+ Object.defineProperty(process.stdout, 'columns', { value: 60, writable: true, configurable: true });
578
+ renderer.stop();
579
+
580
+ const allOutput = writeSpy.mock.calls.map((c) => c[0]).join('');
581
+ // 备选屏幕模式下使用 CLEAR_SCREEN + CURSOR_HOME 而非 CURSOR_UP
582
+ expect(allOutput).toContain('\x1B[2J');
583
+ expect(allOutput).toContain('\x1B[H');
584
+ // 不应包含 CURSOR_UP 序列
585
+ expect(allOutput).not.toMatch(/\x1B\[\d+A/);
586
+ });
587
+
588
+ it('start 时输出 LINE_WRAP_DISABLE,stop 时输出 LINE_WRAP_ENABLE', () => {
589
+ Object.defineProperty(process.stdout, 'columns', { value: 120, writable: true, configurable: true });
590
+ const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
591
+ renderer.start();
592
+ renderer.stop();
593
+
594
+ const allOutput = writeSpy.mock.calls.map((c) => c[0]).join('');
595
+ // start 时禁用行换行
596
+ expect(allOutput).toContain('\x1B[?7l');
597
+ // stop 时恢复行换行
598
+ expect(allOutput).toContain('\x1B[?7h');
599
+ });
600
+
601
+ it('render 输出中包含 SYNC_OUTPUT_START 和 SYNC_OUTPUT_END', () => {
602
+ Object.defineProperty(process.stdout, 'columns', { value: 120, writable: true, configurable: true });
603
+ const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
604
+ renderer.start();
605
+ renderer.stop();
606
+
607
+ const allOutput = writeSpy.mock.calls.map((c) => c[0]).join('');
608
+ // 同步输出开启
609
+ expect(allOutput).toContain('\x1B[?2026h');
610
+ // 同步输出关闭
611
+ expect(allOutput).toContain('\x1B[?2026l');
612
+ });
613
+
614
+ it('start 注册 exit 兜底处理器,stop 移除', () => {
615
+ Object.defineProperty(process.stdout, 'columns', { value: 120, writable: true, configurable: true });
616
+ const processOnSpy = vi.spyOn(process, 'on');
617
+ const processRemoveListenerSpy = vi.spyOn(process, 'removeListener');
618
+
619
+ const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
620
+ renderer.start();
621
+
622
+ // 验证 exit 监听已注册
623
+ const exitOnCalls = processOnSpy.mock.calls.filter((c) => c[0] === 'exit');
624
+ expect(exitOnCalls.length).toBeGreaterThanOrEqual(1);
625
+
626
+ renderer.stop();
627
+
628
+ // 验证 exit 监听已移除
629
+ const exitRemoveCalls = processRemoveListenerSpy.mock.calls.filter((c) => c[0] === 'exit');
630
+ expect(exitRemoveCalls.length).toBeGreaterThanOrEqual(1);
631
+
632
+ processOnSpy.mockRestore();
633
+ processRemoveListenerSpy.mockRestore();
634
+ });
254
635
  });
255
- });
636
+ });