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.
- package/.claude/agent-memory/docs-sync-updater/MEMORY.md +13 -1
- package/README.md +4 -0
- package/dist/index.js +379 -119
- package/dist/postinstall.js +12 -3
- package/docs/spec.md +97 -12
- package/package.json +2 -1
- package/src/commands/sync.ts +34 -14
- package/src/commands/validate.ts +50 -8
- package/src/constants/index.ts +12 -2
- package/src/constants/messages/run.ts +4 -4
- package/src/constants/messages/validate.ts +12 -0
- package/src/constants/progress.ts +36 -6
- package/src/utils/index.ts +2 -0
- package/src/utils/progress-render.ts +77 -13
- package/src/utils/progress.ts +110 -24
- package/src/utils/stream-parser.ts +251 -0
- package/src/utils/task-executor.ts +61 -27
- package/tests/unit/utils/progress-render.test.ts +96 -0
- package/tests/unit/utils/progress.test.ts +391 -10
- package/tests/unit/utils/stream-parser.test.ts +375 -0
|
@@ -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
|
-
|
|
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
|
+
});
|