clawt 2.10.0 → 2.11.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.
- package/.claude/agent-memory/docs-sync-updater/MEMORY.md +14 -7
- package/.claude/agents/docs-sync-updater.md +11 -0
- package/README.md +80 -284
- package/dist/index.js +839 -307
- package/dist/postinstall.js +272 -0
- package/docs/spec.md +84 -22
- package/package.json +1 -1
- package/src/commands/remove.ts +21 -28
- package/src/commands/run.ts +68 -206
- package/src/constants/config.ts +4 -0
- package/src/constants/index.ts +11 -1
- package/src/constants/messages/common.ts +41 -0
- package/src/constants/messages/config.ts +5 -0
- package/src/constants/messages/create.ts +5 -0
- package/src/constants/messages/index.ts +29 -0
- package/src/constants/messages/merge.ts +42 -0
- package/src/constants/messages/remove.ts +15 -0
- package/src/constants/messages/reset.ts +7 -0
- package/src/constants/messages/resume.ts +12 -0
- package/src/constants/messages/run.ts +46 -0
- package/src/constants/messages/status.ts +25 -0
- package/src/constants/messages/sync.ts +24 -0
- package/src/constants/messages/validate.ts +25 -0
- package/src/constants/progress.ts +39 -0
- package/src/types/command.ts +4 -0
- package/src/types/config.ts +2 -0
- package/src/types/index.ts +1 -0
- package/src/types/taskFile.ts +13 -0
- package/src/utils/formatter.ts +16 -0
- package/src/utils/index.ts +8 -4
- package/src/utils/progress-render.ts +90 -0
- package/src/utils/progress.ts +213 -0
- package/src/utils/task-executor.ts +365 -0
- package/src/utils/task-file.ts +87 -0
- package/src/utils/worktree-matcher.ts +92 -0
- package/src/utils/worktree.ts +27 -0
- package/tests/unit/commands/config.test.ts +110 -0
- package/tests/unit/commands/create.test.ts +115 -0
- package/tests/unit/commands/list.test.ts +118 -0
- package/tests/unit/commands/merge.test.ts +323 -0
- package/tests/unit/commands/remove.test.ts +240 -0
- package/tests/unit/commands/reset.test.ts +124 -0
- package/tests/unit/commands/resume.test.ts +91 -0
- package/tests/unit/commands/run.test.ts +456 -0
- package/tests/unit/commands/status.test.ts +214 -0
- package/tests/unit/commands/sync.test.ts +208 -0
- package/tests/unit/commands/validate.test.ts +382 -0
- package/tests/unit/constants/config.test.ts +1 -0
- package/tests/unit/constants/messages.test.ts +1 -1
- package/tests/unit/utils/config.test.ts +21 -1
- package/tests/unit/utils/formatter.test.ts +70 -1
- package/tests/unit/utils/git.test.ts +44 -0
- package/tests/unit/utils/progress.test.ts +255 -0
- package/tests/unit/utils/task-file.test.ts +236 -0
- package/tests/unit/utils/validate-snapshot.test.ts +25 -0
- package/tests/unit/utils/worktree-matcher.test.ts +81 -5
- package/tests/unit/utils/worktree.test.ts +26 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { formatWorktreeStatus, printSuccess, printError, printWarning, printInfo } 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', () => {
|
|
@@ -38,6 +38,49 @@ describe('formatWorktreeStatus', () => {
|
|
|
38
38
|
});
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
describe('isWorktreeIdle', () => {
|
|
42
|
+
it('全部为零且无未提交修改时返回 true', () => {
|
|
43
|
+
const status = createWorktreeStatus({ commitCount: 0, insertions: 0, deletions: 0, hasDirtyFiles: false });
|
|
44
|
+
expect(isWorktreeIdle(status)).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('有提交时返回 false', () => {
|
|
48
|
+
const status = createWorktreeStatus({ commitCount: 1, insertions: 0, deletions: 0, hasDirtyFiles: false });
|
|
49
|
+
expect(isWorktreeIdle(status)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('有 insertions 时返回 false', () => {
|
|
53
|
+
const status = createWorktreeStatus({ commitCount: 0, insertions: 1, deletions: 0, hasDirtyFiles: false });
|
|
54
|
+
expect(isWorktreeIdle(status)).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('有 deletions 时返回 false', () => {
|
|
58
|
+
const status = createWorktreeStatus({ commitCount: 0, insertions: 0, deletions: 1, hasDirtyFiles: false });
|
|
59
|
+
expect(isWorktreeIdle(status)).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('有未提交修改时返回 false', () => {
|
|
63
|
+
const status = createWorktreeStatus({ commitCount: 0, insertions: 0, deletions: 0, hasDirtyFiles: true });
|
|
64
|
+
expect(isWorktreeIdle(status)).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('printSeparator', () => {
|
|
69
|
+
it('调用 console.log 输出分隔线', () => {
|
|
70
|
+
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
71
|
+
printSeparator();
|
|
72
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('printDoubleSeparator', () => {
|
|
77
|
+
it('调用 console.log 输出粗分隔线', () => {
|
|
78
|
+
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
79
|
+
printDoubleSeparator();
|
|
80
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
41
84
|
describe('print 函数', () => {
|
|
42
85
|
it('printSuccess 调用 console.log', () => {
|
|
43
86
|
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
@@ -67,3 +110,29 @@ describe('print 函数', () => {
|
|
|
67
110
|
expect(spy.mock.calls[0][0]).toBe('普通消息');
|
|
68
111
|
});
|
|
69
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
|
+
});
|
|
@@ -32,6 +32,8 @@ import {
|
|
|
32
32
|
gitCommit,
|
|
33
33
|
gitMerge,
|
|
34
34
|
hasMergeConflict,
|
|
35
|
+
gitPull,
|
|
36
|
+
gitPush,
|
|
35
37
|
gitResetHard,
|
|
36
38
|
gitCleanForce,
|
|
37
39
|
gitStashPush,
|
|
@@ -44,6 +46,7 @@ import {
|
|
|
44
46
|
gitWorktreePrune,
|
|
45
47
|
hasLocalCommits,
|
|
46
48
|
getCommitCountAhead,
|
|
49
|
+
getCommitCountBehind,
|
|
47
50
|
getDiffStat,
|
|
48
51
|
gitDiffCachedBinary,
|
|
49
52
|
gitApplyCachedFromStdin,
|
|
@@ -530,3 +533,44 @@ describe('gitApplyCachedCheck', () => {
|
|
|
530
533
|
expect(gitApplyCachedCheck(patch, '/repo')).toBe(false);
|
|
531
534
|
});
|
|
532
535
|
});
|
|
536
|
+
|
|
537
|
+
describe('gitPull', () => {
|
|
538
|
+
it('执行 git pull', () => {
|
|
539
|
+
gitPull('/repo');
|
|
540
|
+
expect(mockedExecCommand).toHaveBeenCalledWith('git pull', { cwd: '/repo' });
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it('不传 cwd 时 cwd 为 undefined', () => {
|
|
544
|
+
gitPull();
|
|
545
|
+
expect(mockedExecCommand).toHaveBeenCalledWith('git pull', { cwd: undefined });
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
describe('gitPush', () => {
|
|
550
|
+
it('执行 git push', () => {
|
|
551
|
+
gitPush('/repo');
|
|
552
|
+
expect(mockedExecCommand).toHaveBeenCalledWith('git push', { cwd: '/repo' });
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('不传 cwd 时 cwd 为 undefined', () => {
|
|
556
|
+
gitPush();
|
|
557
|
+
expect(mockedExecCommand).toHaveBeenCalledWith('git push', { cwd: undefined });
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
describe('getCommitCountBehind', () => {
|
|
562
|
+
it('返回正确的落后提交数', () => {
|
|
563
|
+
mockedExecCommand.mockReturnValue('3');
|
|
564
|
+
expect(getCommitCountBehind('feature')).toBe(3);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it('返回 0 当输出无法解析', () => {
|
|
568
|
+
mockedExecCommand.mockReturnValue('');
|
|
569
|
+
expect(getCommitCountBehind('feature')).toBe(0);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('命令失败时返回 0', () => {
|
|
573
|
+
mockedExecCommand.mockImplementation(() => { throw new Error('fail'); });
|
|
574
|
+
expect(getCommitCountBehind('feature')).toBe(0);
|
|
575
|
+
});
|
|
576
|
+
});
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { ProgressRenderer } from '../../../src/utils/progress.js';
|
|
3
|
+
|
|
4
|
+
describe('ProgressRenderer', () => {
|
|
5
|
+
let writeSpy: ReturnType<typeof vi.spyOn>;
|
|
6
|
+
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
7
|
+
let originalIsTTY: boolean | undefined;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
11
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
12
|
+
originalIsTTY = process.stdout.isTTY;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, writable: true });
|
|
17
|
+
writeSpy.mockRestore();
|
|
18
|
+
logSpy.mockRestore();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('TTY 模式', () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('start 时隐藏光标并渲染初始面板', () => {
|
|
27
|
+
const renderer = new ProgressRenderer(['feat-1', 'feat-2'], ['/path/feat-1', '/path/feat-2']);
|
|
28
|
+
renderer.start();
|
|
29
|
+
renderer.stop();
|
|
30
|
+
|
|
31
|
+
// 应有写入隐藏光标的调用
|
|
32
|
+
const allOutput = writeSpy.mock.calls.map((c) => c[0]).join('');
|
|
33
|
+
expect(allOutput).toContain('\x1B[?25l');
|
|
34
|
+
// 应有写入显示光标的调用
|
|
35
|
+
expect(allOutput).toContain('\x1B[?25h');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('start 后 stop 清除定时器并恢复光标', () => {
|
|
39
|
+
const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
|
|
40
|
+
renderer.start();
|
|
41
|
+
renderer.stop();
|
|
42
|
+
|
|
43
|
+
const allOutput = writeSpy.mock.calls.map((c) => c[0]).join('');
|
|
44
|
+
expect(allOutput).toContain('\x1B[?25h');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('stop 幂等,多次调用不报错', () => {
|
|
48
|
+
const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
|
|
49
|
+
renderer.start();
|
|
50
|
+
renderer.stop();
|
|
51
|
+
renderer.stop();
|
|
52
|
+
renderer.stop();
|
|
53
|
+
|
|
54
|
+
// 只有一次恢复光标
|
|
55
|
+
const showCursorCount = writeSpy.mock.calls
|
|
56
|
+
.map((c) => c[0])
|
|
57
|
+
.filter((s) => typeof s === 'string' && s.includes('\x1B[?25h')).length;
|
|
58
|
+
expect(showCursorCount).toBe(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('渲染面板包含分支名、运行状态和路径', () => {
|
|
62
|
+
const renderer = new ProgressRenderer(['feat-1', 'feat-2'], ['/path/feat-1', '/path/feat-2']);
|
|
63
|
+
renderer.start();
|
|
64
|
+
|
|
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('运行中');
|
|
69
|
+
expect(allOutput).toContain('/path/feat-1');
|
|
70
|
+
expect(allOutput).toContain('/path/feat-2');
|
|
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();
|
|
79
|
+
|
|
80
|
+
renderer.markDone(0, 5000, 0.05);
|
|
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
|
+
expect(allOutput).toContain('/path/feat-1');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('markFailed 后渲染显示失败状态和路径', () => {
|
|
92
|
+
const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
|
|
93
|
+
renderer.start();
|
|
94
|
+
writeSpy.mockClear();
|
|
95
|
+
|
|
96
|
+
renderer.markFailed(0, 3000);
|
|
97
|
+
renderer.stop();
|
|
98
|
+
|
|
99
|
+
const allOutput = writeSpy.mock.calls.map((c) => c[0]).join('');
|
|
100
|
+
expect(allOutput).toContain('✗');
|
|
101
|
+
expect(allOutput).toContain('失败');
|
|
102
|
+
expect(allOutput).toContain('3.0s');
|
|
103
|
+
expect(allOutput).toContain('/path/feat-1');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('updateActivity 更新后不立即渲染(等待定时器)', () => {
|
|
107
|
+
const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
|
|
108
|
+
renderer.start();
|
|
109
|
+
const callCountBefore = writeSpy.mock.calls.length;
|
|
110
|
+
|
|
111
|
+
renderer.updateActivity(0);
|
|
112
|
+
|
|
113
|
+
// updateActivity 不应触发额外的 write 调用
|
|
114
|
+
expect(writeSpy.mock.calls.length).toBe(callCountBefore);
|
|
115
|
+
|
|
116
|
+
renderer.stop();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('allRunning=false 时任务初始化为 pending 状态', () => {
|
|
120
|
+
const renderer = new ProgressRenderer(['feat-1', 'feat-2'], ['/path/feat-1', '/path/feat-2'], false);
|
|
121
|
+
renderer.start();
|
|
122
|
+
|
|
123
|
+
const allOutput = writeSpy.mock.calls.map((c) => c[0]).join('');
|
|
124
|
+
expect(allOutput).toContain('◦');
|
|
125
|
+
expect(allOutput).toContain('排队中');
|
|
126
|
+
|
|
127
|
+
renderer.stop();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('markRunning 将 pending 任务标记为运行中', () => {
|
|
131
|
+
const renderer = new ProgressRenderer(['feat-1', 'feat-2'], ['/path/feat-1', '/path/feat-2'], false);
|
|
132
|
+
renderer.start();
|
|
133
|
+
writeSpy.mockClear();
|
|
134
|
+
|
|
135
|
+
renderer.markRunning(0);
|
|
136
|
+
renderer.stop();
|
|
137
|
+
|
|
138
|
+
const allOutput = writeSpy.mock.calls.map((c) => c[0]).join('');
|
|
139
|
+
expect(allOutput).toContain('运行中');
|
|
140
|
+
// 第二个任务仍为排队中
|
|
141
|
+
expect(allOutput).toContain('排队中');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('allRunning=false 时面板包含汇总行', () => {
|
|
145
|
+
const renderer = new ProgressRenderer(['feat-1', 'feat-2', 'feat-3'], ['/path/feat-1', '/path/feat-2', '/path/feat-3'], false);
|
|
146
|
+
renderer.start();
|
|
147
|
+
|
|
148
|
+
const allOutput = writeSpy.mock.calls.map((c) => c[0]).join('');
|
|
149
|
+
// 汇总行应包含排队中的计数
|
|
150
|
+
expect(allOutput).toContain('3/3');
|
|
151
|
+
expect(allOutput).toContain('排队中');
|
|
152
|
+
|
|
153
|
+
renderer.stop();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('汇总行正确反映状态变化', () => {
|
|
157
|
+
const renderer = new ProgressRenderer(['feat-1', 'feat-2', 'feat-3'], ['/path/feat-1', '/path/feat-2', '/path/feat-3'], false);
|
|
158
|
+
renderer.start();
|
|
159
|
+
renderer.markRunning(0);
|
|
160
|
+
renderer.markDone(0, 5000, 0.05);
|
|
161
|
+
renderer.markRunning(1);
|
|
162
|
+
writeSpy.mockClear();
|
|
163
|
+
|
|
164
|
+
renderer.stop();
|
|
165
|
+
|
|
166
|
+
const allOutput = writeSpy.mock.calls.map((c) => c[0]).join('');
|
|
167
|
+
// 应包含:1/3 完成, 1/3 运行中, 1/3 排队中
|
|
168
|
+
expect(allOutput).toContain('1/3');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('非 TTY 模式', () => {
|
|
173
|
+
beforeEach(() => {
|
|
174
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('start 时逐行输出启动信息', () => {
|
|
178
|
+
const renderer = new ProgressRenderer(['feat-1', 'feat-2'], ['/path/feat-1', '/path/feat-2']);
|
|
179
|
+
renderer.start();
|
|
180
|
+
|
|
181
|
+
expect(logSpy).toHaveBeenCalledTimes(2);
|
|
182
|
+
expect(logSpy.mock.calls[0][0]).toContain('feat-1');
|
|
183
|
+
expect(logSpy.mock.calls[0][0]).toContain('启动');
|
|
184
|
+
expect(logSpy.mock.calls[1][0]).toContain('feat-2');
|
|
185
|
+
|
|
186
|
+
renderer.stop();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('markDone 时输出完成信息和路径', () => {
|
|
190
|
+
const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
|
|
191
|
+
renderer.start();
|
|
192
|
+
logSpy.mockClear();
|
|
193
|
+
|
|
194
|
+
renderer.markDone(0, 5000, 0.05);
|
|
195
|
+
|
|
196
|
+
expect(logSpy).toHaveBeenCalledTimes(1);
|
|
197
|
+
expect(logSpy.mock.calls[0][0]).toContain('✓');
|
|
198
|
+
expect(logSpy.mock.calls[0][0]).toContain('完成');
|
|
199
|
+
expect(logSpy.mock.calls[0][0]).toContain('5.0s');
|
|
200
|
+
expect(logSpy.mock.calls[0][0]).toContain('$0.05');
|
|
201
|
+
expect(logSpy.mock.calls[0][0]).toContain('/path/feat-1');
|
|
202
|
+
|
|
203
|
+
renderer.stop();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('markFailed 时输出失败信息和路径', () => {
|
|
207
|
+
const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
|
|
208
|
+
renderer.start();
|
|
209
|
+
logSpy.mockClear();
|
|
210
|
+
|
|
211
|
+
renderer.markFailed(0, 3000);
|
|
212
|
+
|
|
213
|
+
expect(logSpy).toHaveBeenCalledTimes(1);
|
|
214
|
+
expect(logSpy.mock.calls[0][0]).toContain('✗');
|
|
215
|
+
expect(logSpy.mock.calls[0][0]).toContain('失败');
|
|
216
|
+
expect(logSpy.mock.calls[0][0]).toContain('3.0s');
|
|
217
|
+
expect(logSpy.mock.calls[0][0]).toContain('/path/feat-1');
|
|
218
|
+
|
|
219
|
+
renderer.stop();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('不使用 ANSI 转义码', () => {
|
|
223
|
+
const renderer = new ProgressRenderer(['feat-1'], ['/path/feat-1']);
|
|
224
|
+
renderer.start();
|
|
225
|
+
renderer.markDone(0, 5000, 0.05);
|
|
226
|
+
renderer.stop();
|
|
227
|
+
|
|
228
|
+
// process.stdout.write 不应被调用(非 TTY 模式用 console.log)
|
|
229
|
+
expect(writeSpy).not.toHaveBeenCalled();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('allRunning=false 时 start 不输出 pending 任务', () => {
|
|
233
|
+
const renderer = new ProgressRenderer(['feat-1', 'feat-2'], ['/path/feat-1', '/path/feat-2'], false);
|
|
234
|
+
renderer.start();
|
|
235
|
+
|
|
236
|
+
// pending 任务不应输出启动信息
|
|
237
|
+
expect(logSpy).not.toHaveBeenCalled();
|
|
238
|
+
|
|
239
|
+
renderer.stop();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('markRunning 时输出启动信息', () => {
|
|
243
|
+
const renderer = new ProgressRenderer(['feat-1', 'feat-2'], ['/path/feat-1', '/path/feat-2'], false);
|
|
244
|
+
renderer.start();
|
|
245
|
+
|
|
246
|
+
renderer.markRunning(0);
|
|
247
|
+
|
|
248
|
+
expect(logSpy).toHaveBeenCalledTimes(1);
|
|
249
|
+
expect(logSpy.mock.calls[0][0]).toContain('feat-1');
|
|
250
|
+
expect(logSpy.mock.calls[0][0]).toContain('启动');
|
|
251
|
+
|
|
252
|
+
renderer.stop();
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { parseTaskFile, loadTaskFile } from '../../../src/utils/task-file.js';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
vi.mock('node:fs', () => ({
|
|
6
|
+
existsSync: vi.fn(),
|
|
7
|
+
readFileSync: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock('../../../src/errors/index.js', () => ({
|
|
11
|
+
ClawtError: class ClawtError extends Error {
|
|
12
|
+
exitCode: number;
|
|
13
|
+
constructor(message: string, exitCode = 1) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.exitCode = exitCode;
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock('../../../src/constants/index.js', () => ({
|
|
21
|
+
MESSAGES: {
|
|
22
|
+
TASK_FILE_NOT_FOUND: (path: string) => `任务文件不存在: ${path}`,
|
|
23
|
+
TASK_FILE_EMPTY: '任务文件中没有解析到有效任务',
|
|
24
|
+
TASK_FILE_MISSING_BRANCH: (blockIndex: number) => `第 ${blockIndex} 个任务块缺少分支名`,
|
|
25
|
+
TASK_FILE_MISSING_TASK: (branch: string) => `分支 ${branch} 缺少任务描述`,
|
|
26
|
+
TASK_FILE_MISSING_TASK_BY_INDEX: (blockIndex: number) => `第 ${blockIndex} 个任务块缺少任务描述`,
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
const mockedExistsSync = vi.mocked(existsSync);
|
|
31
|
+
const mockedReadFileSync = vi.mocked(readFileSync);
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
mockedExistsSync.mockReset();
|
|
35
|
+
mockedReadFileSync.mockReset();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('parseTaskFile', () => {
|
|
39
|
+
it('正常解析多个任务块', () => {
|
|
40
|
+
const content = `
|
|
41
|
+
<!-- CLAWT-TASKS:START -->
|
|
42
|
+
# branch: feat-login
|
|
43
|
+
实现用户登录功能
|
|
44
|
+
<!-- CLAWT-TASKS:END -->
|
|
45
|
+
|
|
46
|
+
<!-- CLAWT-TASKS:START -->
|
|
47
|
+
# branch: fix-bug
|
|
48
|
+
修复内存泄漏问题
|
|
49
|
+
<!-- CLAWT-TASKS:END -->
|
|
50
|
+
`;
|
|
51
|
+
const entries = parseTaskFile(content);
|
|
52
|
+
expect(entries).toHaveLength(2);
|
|
53
|
+
expect(entries[0].branch).toBe('feat-login');
|
|
54
|
+
expect(entries[0].task).toBe('实现用户登录功能');
|
|
55
|
+
expect(entries[1].branch).toBe('fix-bug');
|
|
56
|
+
expect(entries[1].task).toBe('修复内存泄漏问题');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('支持多行任务描述', () => {
|
|
60
|
+
const content = `
|
|
61
|
+
<!-- CLAWT-TASKS:START -->
|
|
62
|
+
# branch: feat-dashboard
|
|
63
|
+
开发数据仪表盘页面
|
|
64
|
+
包含图表和实时数据刷新
|
|
65
|
+
支持自定义布局
|
|
66
|
+
<!-- CLAWT-TASKS:END -->
|
|
67
|
+
`;
|
|
68
|
+
const entries = parseTaskFile(content);
|
|
69
|
+
expect(entries).toHaveLength(1);
|
|
70
|
+
expect(entries[0].branch).toBe('feat-dashboard');
|
|
71
|
+
expect(entries[0].task).toBe('开发数据仪表盘页面\n包含图表和实时数据刷新\n支持自定义布局');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('标签外内容被忽略', () => {
|
|
75
|
+
const content = `
|
|
76
|
+
这是说明文字,会被忽略
|
|
77
|
+
|
|
78
|
+
<!-- CLAWT-TASKS:START -->
|
|
79
|
+
# branch: feat-login
|
|
80
|
+
实现登录功能
|
|
81
|
+
<!-- CLAWT-TASKS:END -->
|
|
82
|
+
|
|
83
|
+
这也是说明文字
|
|
84
|
+
`;
|
|
85
|
+
const entries = parseTaskFile(content);
|
|
86
|
+
expect(entries).toHaveLength(1);
|
|
87
|
+
expect(entries[0].branch).toBe('feat-login');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('空文件返回空数组', () => {
|
|
91
|
+
const entries = parseTaskFile('');
|
|
92
|
+
expect(entries).toHaveLength(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('没有任务块时返回空数组', () => {
|
|
96
|
+
const entries = parseTaskFile('一些普通文本内容');
|
|
97
|
+
expect(entries).toHaveLength(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('缺少分支名时抛出错误', () => {
|
|
101
|
+
const content = `
|
|
102
|
+
<!-- CLAWT-TASKS:START -->
|
|
103
|
+
实现登录功能
|
|
104
|
+
<!-- CLAWT-TASKS:END -->
|
|
105
|
+
`;
|
|
106
|
+
expect(() => parseTaskFile(content)).toThrow('缺少分支名');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('缺少任务描述时抛出错误', () => {
|
|
110
|
+
const content = `
|
|
111
|
+
<!-- CLAWT-TASKS:START -->
|
|
112
|
+
# branch: feat-login
|
|
113
|
+
<!-- CLAWT-TASKS:END -->
|
|
114
|
+
`;
|
|
115
|
+
expect(() => parseTaskFile(content)).toThrow('缺少任务描述');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('branch 前缀支持不同空格格式', () => {
|
|
119
|
+
const content = `
|
|
120
|
+
<!-- CLAWT-TASKS:START -->
|
|
121
|
+
#branch:feat-login
|
|
122
|
+
实现登录功能
|
|
123
|
+
<!-- CLAWT-TASKS:END -->
|
|
124
|
+
|
|
125
|
+
<!-- CLAWT-TASKS:START -->
|
|
126
|
+
# branch: fix-bug
|
|
127
|
+
修复问题
|
|
128
|
+
<!-- CLAWT-TASKS:END -->
|
|
129
|
+
`;
|
|
130
|
+
const entries = parseTaskFile(content);
|
|
131
|
+
expect(entries).toHaveLength(2);
|
|
132
|
+
expect(entries[0].branch).toBe('feat-login');
|
|
133
|
+
expect(entries[1].branch).toBe('fix-bug');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('branchRequired: false 时缺少分支名不报错,branch 为 undefined', () => {
|
|
137
|
+
const content = `
|
|
138
|
+
<!-- CLAWT-TASKS:START -->
|
|
139
|
+
实现登录功能
|
|
140
|
+
<!-- CLAWT-TASKS:END -->
|
|
141
|
+
`;
|
|
142
|
+
const entries = parseTaskFile(content, { branchRequired: false });
|
|
143
|
+
expect(entries).toHaveLength(1);
|
|
144
|
+
expect(entries[0].branch).toBeUndefined();
|
|
145
|
+
expect(entries[0].task).toBe('实现登录功能');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('branchRequired: false 时有分支名仍能正确解析', () => {
|
|
149
|
+
const content = `
|
|
150
|
+
<!-- CLAWT-TASKS:START -->
|
|
151
|
+
# branch: feat-login
|
|
152
|
+
实现登录功能
|
|
153
|
+
<!-- CLAWT-TASKS:END -->
|
|
154
|
+
`;
|
|
155
|
+
const entries = parseTaskFile(content, { branchRequired: false });
|
|
156
|
+
expect(entries).toHaveLength(1);
|
|
157
|
+
expect(entries[0].branch).toBe('feat-login');
|
|
158
|
+
expect(entries[0].task).toBe('实现登录功能');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('branchRequired: false 时混合有无分支名的块', () => {
|
|
162
|
+
const content = `
|
|
163
|
+
<!-- CLAWT-TASKS:START -->
|
|
164
|
+
# branch: feat-login
|
|
165
|
+
实现登录功能
|
|
166
|
+
<!-- CLAWT-TASKS:END -->
|
|
167
|
+
|
|
168
|
+
<!-- CLAWT-TASKS:START -->
|
|
169
|
+
修复内存泄漏问题
|
|
170
|
+
<!-- CLAWT-TASKS:END -->
|
|
171
|
+
`;
|
|
172
|
+
const entries = parseTaskFile(content, { branchRequired: false });
|
|
173
|
+
expect(entries).toHaveLength(2);
|
|
174
|
+
expect(entries[0].branch).toBe('feat-login');
|
|
175
|
+
expect(entries[0].task).toBe('实现登录功能');
|
|
176
|
+
expect(entries[1].branch).toBeUndefined();
|
|
177
|
+
expect(entries[1].task).toBe('修复内存泄漏问题');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('branchRequired: false 时仍校验任务描述', () => {
|
|
181
|
+
const content = `
|
|
182
|
+
<!-- CLAWT-TASKS:START -->
|
|
183
|
+
<!-- CLAWT-TASKS:END -->
|
|
184
|
+
`;
|
|
185
|
+
expect(() => parseTaskFile(content, { branchRequired: false })).toThrow('缺少任务描述');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('不传 options 时默认要求分支名(向后兼容)', () => {
|
|
189
|
+
const content = `
|
|
190
|
+
<!-- CLAWT-TASKS:START -->
|
|
191
|
+
实现登录功能
|
|
192
|
+
<!-- CLAWT-TASKS:END -->
|
|
193
|
+
`;
|
|
194
|
+
expect(() => parseTaskFile(content)).toThrow('缺少分支名');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('loadTaskFile', () => {
|
|
199
|
+
it('文件不存在时抛出错误', () => {
|
|
200
|
+
mockedExistsSync.mockReturnValue(false);
|
|
201
|
+
expect(() => loadTaskFile('nonexistent.md')).toThrow('任务文件不存在');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('文件无有效任务时抛出错误', () => {
|
|
205
|
+
mockedExistsSync.mockReturnValue(true);
|
|
206
|
+
mockedReadFileSync.mockReturnValue('普通文本内容');
|
|
207
|
+
expect(() => loadTaskFile('empty.md')).toThrow('没有解析到有效任务');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('正常加载并解析文件', () => {
|
|
211
|
+
mockedExistsSync.mockReturnValue(true);
|
|
212
|
+
mockedReadFileSync.mockReturnValue(`
|
|
213
|
+
<!-- CLAWT-TASKS:START -->
|
|
214
|
+
# branch: feat-login
|
|
215
|
+
实现登录功能
|
|
216
|
+
<!-- CLAWT-TASKS:END -->
|
|
217
|
+
`);
|
|
218
|
+
const entries = loadTaskFile('tasks.md');
|
|
219
|
+
expect(entries).toHaveLength(1);
|
|
220
|
+
expect(entries[0].branch).toBe('feat-login');
|
|
221
|
+
expect(entries[0].task).toBe('实现登录功能');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('透传 branchRequired: false 选项', () => {
|
|
225
|
+
mockedExistsSync.mockReturnValue(true);
|
|
226
|
+
mockedReadFileSync.mockReturnValue(`
|
|
227
|
+
<!-- CLAWT-TASKS:START -->
|
|
228
|
+
实现登录功能
|
|
229
|
+
<!-- CLAWT-TASKS:END -->
|
|
230
|
+
`);
|
|
231
|
+
const entries = loadTaskFile('tasks.md', { branchRequired: false });
|
|
232
|
+
expect(entries).toHaveLength(1);
|
|
233
|
+
expect(entries[0].branch).toBeUndefined();
|
|
234
|
+
expect(entries[0].task).toBe('实现登录功能');
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
writeSnapshot,
|
|
40
40
|
removeSnapshot,
|
|
41
41
|
removeProjectSnapshots,
|
|
42
|
+
getProjectSnapshotBranches,
|
|
42
43
|
} from '../../../src/utils/validate-snapshot.js';
|
|
43
44
|
|
|
44
45
|
const mockedExistsSync = vi.mocked(existsSync);
|
|
@@ -149,3 +150,27 @@ describe('removeProjectSnapshots', () => {
|
|
|
149
150
|
expect(mockedUnlinkSync).not.toHaveBeenCalled();
|
|
150
151
|
});
|
|
151
152
|
});
|
|
153
|
+
|
|
154
|
+
describe('getProjectSnapshotBranches', () => {
|
|
155
|
+
it('返回所有存在快照的分支名', () => {
|
|
156
|
+
mockedExistsSync.mockReturnValue(true);
|
|
157
|
+
// @ts-expect-error readdirSync 返回类型简化
|
|
158
|
+
mockedReaddirSync.mockReturnValue(['feat-a.tree', 'feat-a.head', 'feat-b.tree', 'feat-b.head']);
|
|
159
|
+
const result = getProjectSnapshotBranches('proj');
|
|
160
|
+
expect(result).toEqual(['feat-a', 'feat-b']);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('没有 .tree 文件时返回空数组', () => {
|
|
164
|
+
mockedExistsSync.mockReturnValue(true);
|
|
165
|
+
// @ts-expect-error readdirSync 返回类型简化
|
|
166
|
+
mockedReaddirSync.mockReturnValue(['feat-a.head']);
|
|
167
|
+
const result = getProjectSnapshotBranches('proj');
|
|
168
|
+
expect(result).toEqual([]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('项目目录不存在时返回空数组', () => {
|
|
172
|
+
mockedExistsSync.mockReturnValue(false);
|
|
173
|
+
const result = getProjectSnapshotBranches('proj');
|
|
174
|
+
expect(result).toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
});
|