clawt 2.14.0 → 2.15.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 +8 -3
- package/README.md +43 -3
- package/dist/index.js +162 -49
- package/dist/postinstall.js +18 -2
- package/docs/spec.md +109 -23
- package/package.json +1 -1
- package/src/commands/config.ts +10 -5
- package/src/commands/run.ts +68 -21
- package/src/constants/config.ts +1 -1
- package/src/constants/index.ts +1 -0
- package/src/constants/messages/config.ts +3 -0
- package/src/constants/messages/index.ts +3 -1
- package/src/constants/messages/run.ts +16 -0
- package/src/types/command.ts +2 -0
- package/src/utils/config.ts +20 -0
- package/src/utils/dry-run.ts +89 -0
- package/src/utils/index.ts +3 -2
- package/src/utils/task-file.ts +18 -0
- package/tests/unit/commands/config.test.ts +34 -3
- package/tests/unit/commands/resume.test.ts +9 -9
- package/tests/unit/commands/run.test.ts +160 -6
- package/tests/unit/constants/messages.test.ts +5 -1
- package/tests/unit/utils/config.test.ts +23 -1
- package/tests/unit/utils/dry-run.test.ts +124 -0
- package/tests/unit/utils/task-file.test.ts +26 -1
|
@@ -2,15 +2,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
|
|
4
4
|
// mock enquirer(必须在所有 import 之前)
|
|
5
|
-
const { mockSelectRun, mockInputRun } = vi.hoisted(() => {
|
|
5
|
+
const { mockSelectRun, mockInputRun, mockSelectConstructorArgs } = vi.hoisted(() => {
|
|
6
6
|
const mockSelectRun = vi.fn();
|
|
7
7
|
const mockInputRun = vi.fn();
|
|
8
|
-
|
|
8
|
+
/** 用于捕获 Select 构造时传入的参数 */
|
|
9
|
+
const mockSelectConstructorArgs: unknown[] = [];
|
|
10
|
+
return { mockSelectRun, mockInputRun, mockSelectConstructorArgs };
|
|
9
11
|
});
|
|
10
12
|
|
|
11
13
|
vi.mock('enquirer', () => ({
|
|
12
14
|
default: {
|
|
13
|
-
Select: function MockSelect() { return { run: mockSelectRun }; },
|
|
15
|
+
Select: function MockSelect(opts: unknown) { mockSelectConstructorArgs.push(opts); return { run: mockSelectRun }; },
|
|
14
16
|
Input: function MockInput() { return { run: mockInputRun }; },
|
|
15
17
|
},
|
|
16
18
|
}));
|
|
@@ -48,6 +50,7 @@ vi.mock('../../../src/constants/index.js', () => ({
|
|
|
48
50
|
confirmDestructiveOps: true,
|
|
49
51
|
maxConcurrency: 0,
|
|
50
52
|
terminalApp: 'auto',
|
|
53
|
+
aliases: {},
|
|
51
54
|
},
|
|
52
55
|
CONFIG_DESCRIPTIONS: {
|
|
53
56
|
autoDeleteBranch: '自动删除分支',
|
|
@@ -56,6 +59,7 @@ vi.mock('../../../src/constants/index.js', () => ({
|
|
|
56
59
|
confirmDestructiveOps: '破坏性操作确认',
|
|
57
60
|
maxConcurrency: '最大并发数',
|
|
58
61
|
terminalApp: '终端应用',
|
|
62
|
+
aliases: '命令别名映射',
|
|
59
63
|
},
|
|
60
64
|
CONFIG_DEFINITIONS: {
|
|
61
65
|
autoDeleteBranch: { defaultValue: false, description: '自动删除分支' },
|
|
@@ -64,7 +68,9 @@ vi.mock('../../../src/constants/index.js', () => ({
|
|
|
64
68
|
confirmDestructiveOps: { defaultValue: true, description: '破坏性操作确认' },
|
|
65
69
|
maxConcurrency: { defaultValue: 0, description: '最大并发数' },
|
|
66
70
|
terminalApp: { defaultValue: 'auto', description: '终端应用', allowedValues: ['auto', 'iterm2', 'terminal'] },
|
|
71
|
+
aliases: { defaultValue: {}, description: '命令别名映射' },
|
|
67
72
|
},
|
|
73
|
+
CONFIG_ALIAS_DISABLED_HINT: '(通过 clawt alias 命令管理)',
|
|
68
74
|
MESSAGES: {
|
|
69
75
|
CONFIG_RESET_SUCCESS: '配置已恢复为默认值',
|
|
70
76
|
DESTRUCTIVE_OP_CANCELLED: '已取消操作',
|
|
@@ -104,6 +110,7 @@ function createMockConfig() {
|
|
|
104
110
|
confirmDestructiveOps: true,
|
|
105
111
|
maxConcurrency: 0,
|
|
106
112
|
terminalApp: 'auto',
|
|
113
|
+
aliases: {},
|
|
107
114
|
};
|
|
108
115
|
}
|
|
109
116
|
|
|
@@ -117,6 +124,7 @@ beforeEach(() => {
|
|
|
117
124
|
mockedConfirmDestructiveAction.mockReset();
|
|
118
125
|
mockSelectRun.mockReset();
|
|
119
126
|
mockInputRun.mockReset();
|
|
127
|
+
mockSelectConstructorArgs.length = 0;
|
|
120
128
|
});
|
|
121
129
|
|
|
122
130
|
describe('registerConfigCommand', () => {
|
|
@@ -374,6 +382,29 @@ describe('handleConfigSet — 交互模式', () => {
|
|
|
374
382
|
);
|
|
375
383
|
expect(mockedPrintSuccess).toHaveBeenCalled();
|
|
376
384
|
});
|
|
385
|
+
|
|
386
|
+
it('aliases 选项带 disabled 属性且不可选', async () => {
|
|
387
|
+
mockedLoadConfig.mockReturnValue(createMockConfig());
|
|
388
|
+
// 选择一个普通配置项完成交互流程
|
|
389
|
+
mockSelectRun.mockResolvedValueOnce('autoDeleteBranch');
|
|
390
|
+
mockSelectRun.mockResolvedValueOnce('true');
|
|
391
|
+
|
|
392
|
+
const program = new Command();
|
|
393
|
+
program.exitOverride();
|
|
394
|
+
registerConfigCommand(program);
|
|
395
|
+
await program.parseAsync(['config', 'set'], { from: 'user' });
|
|
396
|
+
|
|
397
|
+
// 捕获第一次 Select 构造参数(配置项选择列表)
|
|
398
|
+
const selectOpts = mockSelectConstructorArgs[0] as { choices: Array<{ name: string; disabled?: string }> };
|
|
399
|
+
const aliasesChoice = selectOpts.choices.find((c) => c.name === 'aliases');
|
|
400
|
+
expect(aliasesChoice).toBeDefined();
|
|
401
|
+
expect(aliasesChoice!.disabled).toBe('(通过 clawt alias 命令管理)');
|
|
402
|
+
|
|
403
|
+
// 普通配置项不应有 disabled 属性
|
|
404
|
+
const normalChoice = selectOpts.choices.find((c) => c.name === 'autoDeleteBranch');
|
|
405
|
+
expect(normalChoice).toBeDefined();
|
|
406
|
+
expect(normalChoice!.disabled).toBeUndefined();
|
|
407
|
+
});
|
|
377
408
|
});
|
|
378
409
|
|
|
379
410
|
describe('handleConfigGet', () => {
|
|
@@ -19,7 +19,7 @@ vi.mock('../../../src/utils/index.js', () => ({
|
|
|
19
19
|
validateClaudeCodeInstalled: vi.fn(),
|
|
20
20
|
getProjectWorktrees: vi.fn(),
|
|
21
21
|
launchInteractiveClaude: vi.fn(),
|
|
22
|
-
|
|
22
|
+
resolveTargetWorktrees: vi.fn(),
|
|
23
23
|
}));
|
|
24
24
|
|
|
25
25
|
import { registerResumeCommand } from '../../../src/commands/resume.js';
|
|
@@ -28,21 +28,21 @@ import {
|
|
|
28
28
|
validateClaudeCodeInstalled,
|
|
29
29
|
getProjectWorktrees,
|
|
30
30
|
launchInteractiveClaude,
|
|
31
|
-
|
|
31
|
+
resolveTargetWorktrees,
|
|
32
32
|
} from '../../../src/utils/index.js';
|
|
33
33
|
|
|
34
34
|
const mockedValidateMainWorktree = vi.mocked(validateMainWorktree);
|
|
35
35
|
const mockedValidateClaudeCodeInstalled = vi.mocked(validateClaudeCodeInstalled);
|
|
36
36
|
const mockedGetProjectWorktrees = vi.mocked(getProjectWorktrees);
|
|
37
37
|
const mockedLaunchInteractiveClaude = vi.mocked(launchInteractiveClaude);
|
|
38
|
-
const
|
|
38
|
+
const mockedResolveTargetWorktrees = vi.mocked(resolveTargetWorktrees);
|
|
39
39
|
|
|
40
40
|
beforeEach(() => {
|
|
41
41
|
mockedValidateMainWorktree.mockReset();
|
|
42
42
|
mockedValidateClaudeCodeInstalled.mockReset();
|
|
43
43
|
mockedGetProjectWorktrees.mockReset();
|
|
44
44
|
mockedLaunchInteractiveClaude.mockReset();
|
|
45
|
-
|
|
45
|
+
mockedResolveTargetWorktrees.mockReset();
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
describe('registerResumeCommand', () => {
|
|
@@ -58,7 +58,7 @@ describe('handleResume', () => {
|
|
|
58
58
|
it('成功恢复 Claude Code 会话', async () => {
|
|
59
59
|
const worktree = { path: '/path/feature', branch: 'feature' };
|
|
60
60
|
mockedGetProjectWorktrees.mockReturnValue([worktree]);
|
|
61
|
-
|
|
61
|
+
mockedResolveTargetWorktrees.mockResolvedValue([worktree]);
|
|
62
62
|
|
|
63
63
|
const program = new Command();
|
|
64
64
|
program.exitOverride();
|
|
@@ -67,14 +67,14 @@ describe('handleResume', () => {
|
|
|
67
67
|
|
|
68
68
|
expect(mockedValidateMainWorktree).toHaveBeenCalled();
|
|
69
69
|
expect(mockedValidateClaudeCodeInstalled).toHaveBeenCalled();
|
|
70
|
-
expect(
|
|
70
|
+
expect(mockedResolveTargetWorktrees).toHaveBeenCalled();
|
|
71
71
|
expect(mockedLaunchInteractiveClaude).toHaveBeenCalledWith(worktree, { autoContinue: true });
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
it('不传 -b 时也能调用
|
|
74
|
+
it('不传 -b 时也能调用 resolveTargetWorktrees', async () => {
|
|
75
75
|
const worktree = { path: '/path/feature', branch: 'feature' };
|
|
76
76
|
mockedGetProjectWorktrees.mockReturnValue([worktree]);
|
|
77
|
-
|
|
77
|
+
mockedResolveTargetWorktrees.mockResolvedValue([worktree]);
|
|
78
78
|
|
|
79
79
|
const program = new Command();
|
|
80
80
|
program.exitOverride();
|
|
@@ -82,7 +82,7 @@ describe('handleResume', () => {
|
|
|
82
82
|
await program.parseAsync(['resume'], { from: 'user' });
|
|
83
83
|
|
|
84
84
|
// branchName 参数为 undefined
|
|
85
|
-
expect(
|
|
85
|
+
expect(mockedResolveTargetWorktrees).toHaveBeenCalledWith(
|
|
86
86
|
expect.any(Array),
|
|
87
87
|
expect.any(Object),
|
|
88
88
|
undefined,
|
|
@@ -31,6 +31,14 @@ vi.mock('../../../src/constants/index.js', () => ({
|
|
|
31
31
|
BRANCH_OR_FILE_REQUIRED: '请指定 -b 或 -f',
|
|
32
32
|
TASK_FILE_LOADED: (count: number, path: string) => `✓ 从 ${path} 加载了 ${count} 个任务`,
|
|
33
33
|
TASK_FILE_MISSING_TASK_BY_INDEX: (blockIndex: number) => `第 ${blockIndex} 个任务块缺少任务描述`,
|
|
34
|
+
DRY_RUN_TITLE: 'Dry Run 预览',
|
|
35
|
+
DRY_RUN_TASK_COUNT: (count: number) => `任务数: ${count}`,
|
|
36
|
+
DRY_RUN_CONCURRENCY: (concurrency: number) => `并发数: ${concurrency === 0 ? '不限制' : concurrency}`,
|
|
37
|
+
DRY_RUN_WORKTREE_DIR: (dir: string) => `Worktree 目录: ${dir}`,
|
|
38
|
+
DRY_RUN_BRANCH_EXISTS_WARNING: (name: string) => `分支 ${name} 已存在`,
|
|
39
|
+
DRY_RUN_INTERACTIVE_MODE: '模式: 交互式(无预设任务)',
|
|
40
|
+
DRY_RUN_READY: '预览完成,无冲突。移除 --dry-run 即可正式执行。',
|
|
41
|
+
DRY_RUN_HAS_CONFLICT: '存在分支冲突,实际执行时将会报错。请先处理冲突的分支。',
|
|
34
42
|
},
|
|
35
43
|
}));
|
|
36
44
|
|
|
@@ -46,8 +54,11 @@ vi.mock('../../../src/utils/index.js', async (importOriginal) => {
|
|
|
46
54
|
validateClaudeCodeInstalled: vi.fn(),
|
|
47
55
|
createWorktrees: vi.fn(),
|
|
48
56
|
sanitizeBranchName: vi.fn(),
|
|
57
|
+
generateBranchNames: vi.fn(),
|
|
49
58
|
checkBranchExists: vi.fn(),
|
|
50
59
|
getConfigValue: vi.fn().mockReturnValue(0),
|
|
60
|
+
parseConcurrency: vi.fn().mockReturnValue(0),
|
|
61
|
+
getProjectWorktreeDir: vi.fn().mockReturnValue('/mock/.clawt/worktrees/test-project'),
|
|
51
62
|
printSuccess: vi.fn(),
|
|
52
63
|
printError: vi.fn(),
|
|
53
64
|
printWarning: vi.fn(),
|
|
@@ -57,7 +68,9 @@ vi.mock('../../../src/utils/index.js', async (importOriginal) => {
|
|
|
57
68
|
confirmAction: vi.fn(),
|
|
58
69
|
launchInteractiveClaude: vi.fn(),
|
|
59
70
|
loadTaskFile: vi.fn(),
|
|
71
|
+
parseTasksFromOptions: vi.fn(),
|
|
60
72
|
createWorktreesByBranches: vi.fn(),
|
|
73
|
+
printDryRunPreview: vi.fn(),
|
|
61
74
|
};
|
|
62
75
|
});
|
|
63
76
|
|
|
@@ -80,6 +93,7 @@ vi.mock('../../../src/utils/worktree.js', () => ({
|
|
|
80
93
|
|
|
81
94
|
vi.mock('../../../src/utils/config.js', () => ({
|
|
82
95
|
getConfigValue: vi.fn().mockReturnValue(0),
|
|
96
|
+
parseConcurrency: vi.fn().mockReturnValue(0),
|
|
83
97
|
loadConfig: vi.fn(),
|
|
84
98
|
writeDefaultConfig: vi.fn(),
|
|
85
99
|
ensureClawtDirs: vi.fn(),
|
|
@@ -110,30 +124,45 @@ vi.mock('../../../src/utils/progress.js', () => ({
|
|
|
110
124
|
},
|
|
111
125
|
}));
|
|
112
126
|
|
|
127
|
+
vi.mock('../../../src/utils/dry-run.js', () => ({
|
|
128
|
+
truncateTaskDesc: vi.fn(),
|
|
129
|
+
printDryRunPreview: vi.fn(),
|
|
130
|
+
}));
|
|
131
|
+
|
|
113
132
|
import { registerRunCommand } from '../../../src/commands/run.js';
|
|
114
133
|
import {
|
|
115
134
|
createWorktrees,
|
|
116
135
|
createWorktreesByBranches,
|
|
117
136
|
sanitizeBranchName,
|
|
137
|
+
generateBranchNames,
|
|
118
138
|
checkBranchExists,
|
|
139
|
+
parseConcurrency,
|
|
119
140
|
printSuccess,
|
|
141
|
+
printDryRunPreview,
|
|
120
142
|
launchInteractiveClaude,
|
|
121
143
|
getConfigValue,
|
|
122
144
|
loadTaskFile,
|
|
145
|
+
parseTasksFromOptions,
|
|
146
|
+
validateClaudeCodeInstalled,
|
|
123
147
|
} from '../../../src/utils/index.js';
|
|
124
148
|
import { spawnProcess } from '../../../src/utils/shell.js';
|
|
125
|
-
import { printInfo } from '../../../src/utils/formatter.js';
|
|
149
|
+
import { printInfo as formatterPrintInfo } from '../../../src/utils/formatter.js';
|
|
126
150
|
|
|
127
151
|
const mockedCreateWorktrees = vi.mocked(createWorktrees);
|
|
128
152
|
const mockedCreateWorktreesByBranches = vi.mocked(createWorktreesByBranches);
|
|
129
153
|
const mockedSanitizeBranchName = vi.mocked(sanitizeBranchName);
|
|
154
|
+
const mockedGenerateBranchNames = vi.mocked(generateBranchNames);
|
|
130
155
|
const mockedCheckBranchExists = vi.mocked(checkBranchExists);
|
|
156
|
+
const mockedParseConcurrency = vi.mocked(parseConcurrency);
|
|
131
157
|
const mockedSpawnProcess = vi.mocked(spawnProcess);
|
|
132
158
|
const mockedPrintSuccess = vi.mocked(printSuccess);
|
|
133
|
-
const
|
|
159
|
+
const mockedPrintDryRunPreview = vi.mocked(printDryRunPreview);
|
|
160
|
+
const mockedFormatterPrintInfo = vi.mocked(formatterPrintInfo);
|
|
134
161
|
const mockedLaunchInteractiveClaude = vi.mocked(launchInteractiveClaude);
|
|
135
162
|
const mockedGetConfigValue = vi.mocked(getConfigValue);
|
|
136
163
|
const mockedLoadTaskFile = vi.mocked(loadTaskFile);
|
|
164
|
+
const mockedParseTasksFromOptions = vi.mocked(parseTasksFromOptions);
|
|
165
|
+
const mockedValidateClaudeCodeInstalled = vi.mocked(validateClaudeCodeInstalled);
|
|
137
166
|
|
|
138
167
|
/**
|
|
139
168
|
* 创建模拟子进程
|
|
@@ -166,16 +195,33 @@ beforeEach(() => {
|
|
|
166
195
|
mockedCreateWorktrees.mockReset();
|
|
167
196
|
mockedCreateWorktreesByBranches.mockReset();
|
|
168
197
|
mockedSanitizeBranchName.mockReset();
|
|
198
|
+
mockedGenerateBranchNames.mockReset();
|
|
169
199
|
mockedCheckBranchExists.mockReset();
|
|
200
|
+
mockedParseConcurrency.mockReset();
|
|
201
|
+
mockedParseConcurrency.mockReturnValue(0);
|
|
170
202
|
mockedSpawnProcess.mockReset();
|
|
171
203
|
mockedPrintSuccess.mockReset();
|
|
172
|
-
|
|
204
|
+
mockedPrintDryRunPreview.mockReset();
|
|
205
|
+
mockedFormatterPrintInfo.mockReset();
|
|
173
206
|
mockedLaunchInteractiveClaude.mockReset();
|
|
174
207
|
mockedGetConfigValue.mockReset();
|
|
175
208
|
mockedGetConfigValue.mockReturnValue(0 as any);
|
|
176
209
|
mockedLoadTaskFile.mockReset();
|
|
210
|
+
mockedParseTasksFromOptions.mockReset();
|
|
211
|
+
mockedValidateClaudeCodeInstalled.mockReset();
|
|
177
212
|
// sanitizeBranchName 默认返回输入值
|
|
178
213
|
mockedSanitizeBranchName.mockImplementation((name: string) => name);
|
|
214
|
+
// generateBranchNames 默认使用真实逻辑
|
|
215
|
+
mockedGenerateBranchNames.mockImplementation((name: string, count: number) => {
|
|
216
|
+
if (count === 1) return [name];
|
|
217
|
+
return Array.from({ length: count }, (_, i) => `${name}-${i + 1}`);
|
|
218
|
+
});
|
|
219
|
+
// parseTasksFromOptions 默认使用真实逻辑
|
|
220
|
+
mockedParseTasksFromOptions.mockImplementation((rawTasks: string[]) => {
|
|
221
|
+
const tasks = rawTasks.map((t) => t.trim()).filter(Boolean);
|
|
222
|
+
if (tasks.length === 0) throw new Error('任务列表不能为空');
|
|
223
|
+
return tasks;
|
|
224
|
+
});
|
|
179
225
|
});
|
|
180
226
|
|
|
181
227
|
describe('registerRunCommand', () => {
|
|
@@ -285,6 +331,7 @@ describe('handleRun', () => {
|
|
|
285
331
|
});
|
|
286
332
|
|
|
287
333
|
it('传 --concurrency 限制并发数', async () => {
|
|
334
|
+
mockedParseConcurrency.mockReturnValue(1);
|
|
288
335
|
const worktrees = [
|
|
289
336
|
{ path: '/path/feat-1', branch: 'feat-1' },
|
|
290
337
|
{ path: '/path/feat-2', branch: 'feat-2' },
|
|
@@ -311,7 +358,7 @@ describe('handleRun', () => {
|
|
|
311
358
|
// 所有任务都应执行完毕
|
|
312
359
|
expect(mockedSpawnProcess).toHaveBeenCalledTimes(3);
|
|
313
360
|
// 应输出并发限制提示
|
|
314
|
-
expect(
|
|
361
|
+
expect(mockedFormatterPrintInfo).toHaveBeenCalledWith(expect.stringContaining('并发限制'));
|
|
315
362
|
});
|
|
316
363
|
|
|
317
364
|
it('--concurrency 为 0 时不限制并发', async () => {
|
|
@@ -336,12 +383,13 @@ describe('handleRun', () => {
|
|
|
336
383
|
await program.parseAsync(['run', '-b', 'feat', '--tasks', 'task1', 'task2', '-c', '0'], { from: 'user' });
|
|
337
384
|
|
|
338
385
|
// 不限制并发时不输出并发限制提示
|
|
339
|
-
expect(
|
|
386
|
+
expect(mockedFormatterPrintInfo).not.toHaveBeenCalledWith(expect.stringContaining('并发限制'));
|
|
340
387
|
expect(mockedSpawnProcess).toHaveBeenCalledTimes(2);
|
|
341
388
|
});
|
|
342
389
|
|
|
343
390
|
it('未传 -c 时使用全局配置的 maxConcurrency', async () => {
|
|
344
391
|
mockedGetConfigValue.mockReturnValue(2 as any);
|
|
392
|
+
mockedParseConcurrency.mockReturnValue(2);
|
|
345
393
|
|
|
346
394
|
const worktrees = [
|
|
347
395
|
{ path: '/path/feat-1', branch: 'feat-1' },
|
|
@@ -366,7 +414,7 @@ describe('handleRun', () => {
|
|
|
366
414
|
await program.parseAsync(['run', '-b', 'feat', '--tasks', 'task1', 'task2', 'task3'], { from: 'user' });
|
|
367
415
|
|
|
368
416
|
// 应输出并发限制提示(使用配置值 2)
|
|
369
|
-
expect(
|
|
417
|
+
expect(mockedFormatterPrintInfo).toHaveBeenCalledWith(expect.stringContaining('并发限制'));
|
|
370
418
|
expect(mockedSpawnProcess).toHaveBeenCalledTimes(3);
|
|
371
419
|
});
|
|
372
420
|
|
|
@@ -454,3 +502,109 @@ describe('handleRun', () => {
|
|
|
454
502
|
).rejects.toThrow();
|
|
455
503
|
});
|
|
456
504
|
});
|
|
505
|
+
|
|
506
|
+
describe('handleRun --dry-run', () => {
|
|
507
|
+
it('--dry-run + --tasks 展示任务预览,不创建 worktree 也不启动 Claude Code', async () => {
|
|
508
|
+
const program = new Command();
|
|
509
|
+
program.exitOverride();
|
|
510
|
+
registerRunCommand(program);
|
|
511
|
+
await program.parseAsync(['run', '-b', 'feat', '--tasks', '实现登录功能', '修复首页bug', '--dry-run'], { from: 'user' });
|
|
512
|
+
|
|
513
|
+
// 不创建 worktree
|
|
514
|
+
expect(mockedCreateWorktrees).not.toHaveBeenCalled();
|
|
515
|
+
expect(mockedCreateWorktreesByBranches).not.toHaveBeenCalled();
|
|
516
|
+
// 不启动 Claude Code
|
|
517
|
+
expect(mockedLaunchInteractiveClaude).not.toHaveBeenCalled();
|
|
518
|
+
expect(mockedSpawnProcess).not.toHaveBeenCalled();
|
|
519
|
+
// 应调用 generateBranchNames 生成分支名
|
|
520
|
+
expect(mockedGenerateBranchNames).toHaveBeenCalledWith('feat', 2);
|
|
521
|
+
// 应调用 printDryRunPreview 输出预览
|
|
522
|
+
expect(mockedPrintDryRunPreview).toHaveBeenCalledWith(['feat-1', 'feat-2'], ['实现登录功能', '修复首页bug'], 0);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('--dry-run 不调用 validateClaudeCodeInstalled', async () => {
|
|
526
|
+
const program = new Command();
|
|
527
|
+
program.exitOverride();
|
|
528
|
+
registerRunCommand(program);
|
|
529
|
+
await program.parseAsync(['run', '-b', 'feat', '--tasks', 'task1', '--dry-run'], { from: 'user' });
|
|
530
|
+
|
|
531
|
+
expect(mockedValidateClaudeCodeInstalled).not.toHaveBeenCalled();
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('--dry-run 交互式模式展示单个 worktree 信息', async () => {
|
|
535
|
+
const program = new Command();
|
|
536
|
+
program.exitOverride();
|
|
537
|
+
registerRunCommand(program);
|
|
538
|
+
await program.parseAsync(['run', '-b', 'feat', '--dry-run'], { from: 'user' });
|
|
539
|
+
|
|
540
|
+
// 不创建 worktree
|
|
541
|
+
expect(mockedCreateWorktrees).not.toHaveBeenCalled();
|
|
542
|
+
// 不启动 Claude Code
|
|
543
|
+
expect(mockedLaunchInteractiveClaude).not.toHaveBeenCalled();
|
|
544
|
+
// 交互式模式:分支名列表为单个,任务为空数组
|
|
545
|
+
expect(mockedPrintDryRunPreview).toHaveBeenCalledWith(['feat'], [], 0);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('--dry-run + -f 从任务文件展示预览', async () => {
|
|
549
|
+
mockedLoadTaskFile.mockReturnValue([
|
|
550
|
+
{ branch: 'feat-login', task: '实现登录功能' },
|
|
551
|
+
{ branch: 'fix-bug', task: '修复问题' },
|
|
552
|
+
]);
|
|
553
|
+
|
|
554
|
+
const program = new Command();
|
|
555
|
+
program.exitOverride();
|
|
556
|
+
registerRunCommand(program);
|
|
557
|
+
await program.parseAsync(['run', '-f', 'tasks.md', '--dry-run'], { from: 'user' });
|
|
558
|
+
|
|
559
|
+
// 应加载任务文件
|
|
560
|
+
expect(mockedLoadTaskFile).toHaveBeenCalledWith('tasks.md', { branchRequired: true });
|
|
561
|
+
// 不创建 worktree
|
|
562
|
+
expect(mockedCreateWorktrees).not.toHaveBeenCalled();
|
|
563
|
+
expect(mockedCreateWorktreesByBranches).not.toHaveBeenCalled();
|
|
564
|
+
// 不启动 Claude Code
|
|
565
|
+
expect(mockedSpawnProcess).not.toHaveBeenCalled();
|
|
566
|
+
// 应调用 printDryRunPreview
|
|
567
|
+
expect(mockedPrintDryRunPreview).toHaveBeenCalledWith(
|
|
568
|
+
['feat-login', 'fix-bug'],
|
|
569
|
+
['实现登录功能', '修复问题'],
|
|
570
|
+
0,
|
|
571
|
+
);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it('--dry-run + -f + -b 模式使用 -b 自动编号', async () => {
|
|
575
|
+
mockedLoadTaskFile.mockReturnValue([
|
|
576
|
+
{ task: '任务1' },
|
|
577
|
+
{ task: '任务2' },
|
|
578
|
+
]);
|
|
579
|
+
|
|
580
|
+
const program = new Command();
|
|
581
|
+
program.exitOverride();
|
|
582
|
+
registerRunCommand(program);
|
|
583
|
+
await program.parseAsync(['run', '-b', 'feat', '-f', 'tasks.md', '--dry-run'], { from: 'user' });
|
|
584
|
+
|
|
585
|
+
// 应使用 generateBranchNames 自动编号
|
|
586
|
+
expect(mockedGenerateBranchNames).toHaveBeenCalledWith('feat', 2);
|
|
587
|
+
// 不实际创建 worktree
|
|
588
|
+
expect(mockedCreateWorktrees).not.toHaveBeenCalled();
|
|
589
|
+
// 应调用 printDryRunPreview
|
|
590
|
+
expect(mockedPrintDryRunPreview).toHaveBeenCalled();
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it('--dry-run 传递并发配置给 printDryRunPreview', async () => {
|
|
594
|
+
mockedParseConcurrency.mockReturnValue(3);
|
|
595
|
+
|
|
596
|
+
const program = new Command();
|
|
597
|
+
program.exitOverride();
|
|
598
|
+
registerRunCommand(program);
|
|
599
|
+
await program.parseAsync(['run', '-b', 'feat', '--tasks', 'task1', 'task2', '-c', '3', '--dry-run'], { from: 'user' });
|
|
600
|
+
|
|
601
|
+
// 不创建 worktree
|
|
602
|
+
expect(mockedCreateWorktrees).not.toHaveBeenCalled();
|
|
603
|
+
// 应将并发数传递给 printDryRunPreview
|
|
604
|
+
expect(mockedPrintDryRunPreview).toHaveBeenCalledWith(
|
|
605
|
+
expect.any(Array),
|
|
606
|
+
expect.any(Array),
|
|
607
|
+
3,
|
|
608
|
+
);
|
|
609
|
+
});
|
|
610
|
+
});
|
|
@@ -12,7 +12,6 @@ describe('MESSAGES', () => {
|
|
|
12
12
|
'TARGET_WORKTREE_CLEAN',
|
|
13
13
|
'MERGE_CONFLICT',
|
|
14
14
|
'COMMIT_MESSAGE_REQUIRED',
|
|
15
|
-
'TARGET_WORKTREE_DIRTY_NO_MESSAGE',
|
|
16
15
|
'TARGET_WORKTREE_NO_CHANGES',
|
|
17
16
|
'INTERRUPTED',
|
|
18
17
|
'INTERRUPT_CONFIRM_CLEANUP',
|
|
@@ -49,6 +48,11 @@ describe('MESSAGES', () => {
|
|
|
49
48
|
});
|
|
50
49
|
|
|
51
50
|
describe('模板函数消息', () => {
|
|
51
|
+
it('TARGET_WORKTREE_DIRTY_NO_MESSAGE 包含 worktree 路径', () => {
|
|
52
|
+
const result = MESSAGES.TARGET_WORKTREE_DIRTY_NO_MESSAGE('/path/to/wt');
|
|
53
|
+
expect(result).toContain('/path/to/wt');
|
|
54
|
+
});
|
|
55
|
+
|
|
52
56
|
it('BRANCH_EXISTS 包含分支名', () => {
|
|
53
57
|
const result = MESSAGES.BRANCH_EXISTS('feature-a');
|
|
54
58
|
expect(result).toContain('feature-a');
|
|
@@ -18,7 +18,7 @@ vi.mock('../../../src/utils/fs.js', () => ({
|
|
|
18
18
|
}));
|
|
19
19
|
|
|
20
20
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
21
|
-
import { loadConfig, getConfigValue, writeDefaultConfig, writeConfig, saveConfig, ensureClawtDirs } from '../../../src/utils/config.js';
|
|
21
|
+
import { loadConfig, getConfigValue, writeDefaultConfig, writeConfig, saveConfig, ensureClawtDirs, parseConcurrency } from '../../../src/utils/config.js';
|
|
22
22
|
import { DEFAULT_CONFIG } from '../../../src/constants/index.js';
|
|
23
23
|
import { ensureDir } from '../../../src/utils/fs.js';
|
|
24
24
|
|
|
@@ -96,6 +96,28 @@ describe('ensureClawtDirs', () => {
|
|
|
96
96
|
});
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
+
describe('parseConcurrency', () => {
|
|
100
|
+
it('命令行参数 undefined 时返回配置值', () => {
|
|
101
|
+
expect(parseConcurrency(undefined, 5)).toBe(5);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('命令行参数为有效正整数时返回解析值', () => {
|
|
105
|
+
expect(parseConcurrency('3', 0)).toBe(3);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('命令行参数为 0 时返回 0(不限制)', () => {
|
|
109
|
+
expect(parseConcurrency('0', 5)).toBe(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('命令行参数为负数时抛出错误', () => {
|
|
113
|
+
expect(() => parseConcurrency('-1', 0)).toThrow();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('命令行参数为非数字时抛出错误', () => {
|
|
117
|
+
expect(() => parseConcurrency('abc', 0)).toThrow();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
99
121
|
describe('saveConfig', () => {
|
|
100
122
|
it('将配置对象写入配置文件', () => {
|
|
101
123
|
const customConfig = { ...DEFAULT_CONFIG, autoDeleteBranch: true };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('../../../src/constants/index.js', () => ({
|
|
4
|
+
MESSAGES: {
|
|
5
|
+
DRY_RUN_TITLE: 'Dry Run 预览',
|
|
6
|
+
DRY_RUN_TASK_COUNT: (count: number) => `任务数: ${count}`,
|
|
7
|
+
DRY_RUN_CONCURRENCY: (concurrency: number) => `并发数: ${concurrency === 0 ? '不限制' : concurrency}`,
|
|
8
|
+
DRY_RUN_WORKTREE_DIR: (dir: string) => `Worktree: ${dir}`,
|
|
9
|
+
DRY_RUN_BRANCH_EXISTS_WARNING: (name: string) => `分支 ${name} 已存在`,
|
|
10
|
+
DRY_RUN_INTERACTIVE_MODE: '模式: 交互式(无预设任务)',
|
|
11
|
+
DRY_RUN_READY: '预览完成,无冲突。移除 --dry-run 即可正式执行。',
|
|
12
|
+
DRY_RUN_HAS_CONFLICT: '存在分支冲突,实际执行时将会报错。请先处理冲突的分支。',
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock('../../../src/utils/git.js', () => ({
|
|
17
|
+
checkBranchExists: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock('../../../src/utils/worktree.js', () => ({
|
|
21
|
+
getProjectWorktreeDir: vi.fn().mockReturnValue('/mock/.clawt/worktrees/test-project'),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock('../../../src/utils/formatter.js', () => ({
|
|
25
|
+
printInfo: vi.fn(),
|
|
26
|
+
printDoubleSeparator: vi.fn(),
|
|
27
|
+
printSeparator: vi.fn(),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
import { truncateTaskDesc, printDryRunPreview } from '../../../src/utils/dry-run.js';
|
|
31
|
+
import { checkBranchExists } from '../../../src/utils/git.js';
|
|
32
|
+
import { printInfo } from '../../../src/utils/formatter.js';
|
|
33
|
+
|
|
34
|
+
const mockedCheckBranchExists = vi.mocked(checkBranchExists);
|
|
35
|
+
const mockedPrintInfo = vi.mocked(printInfo);
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
mockedCheckBranchExists.mockReset();
|
|
39
|
+
mockedPrintInfo.mockReset();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('truncateTaskDesc', () => {
|
|
43
|
+
it('短文本原样返回', () => {
|
|
44
|
+
expect(truncateTaskDesc('实现登录功能')).toBe('实现登录功能');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('超过80字符时截断并加省略号', () => {
|
|
48
|
+
const longTask = 'a'.repeat(100);
|
|
49
|
+
const result = truncateTaskDesc(longTask);
|
|
50
|
+
expect(result.length).toBe(83); // 80 + '...'
|
|
51
|
+
expect(result.endsWith('...')).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('恰好80字符时不截断', () => {
|
|
55
|
+
const task = 'a'.repeat(80);
|
|
56
|
+
expect(truncateTaskDesc(task)).toBe(task);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('多行文本合并为单行', () => {
|
|
60
|
+
const multiLine = '第一行\n第二行\n第三行';
|
|
61
|
+
expect(truncateTaskDesc(multiLine)).toBe('第一行 第二行 第三行');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('首尾空白被去除', () => {
|
|
65
|
+
expect(truncateTaskDesc(' hello ')).toBe('hello');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('printDryRunPreview', () => {
|
|
70
|
+
it('无冲突时输出预览完成结论', () => {
|
|
71
|
+
mockedCheckBranchExists.mockReturnValue(false);
|
|
72
|
+
|
|
73
|
+
printDryRunPreview(['feat-1', 'feat-2'], ['任务1', '任务2'], 0);
|
|
74
|
+
|
|
75
|
+
// 正常分支应包含 ✓ 标识
|
|
76
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(expect.stringContaining('✓'));
|
|
77
|
+
// 应输出预览完成结论
|
|
78
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(expect.stringContaining('预览完成'));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('有分支冲突时输出冲突警告', () => {
|
|
82
|
+
mockedCheckBranchExists
|
|
83
|
+
.mockReturnValueOnce(false)
|
|
84
|
+
.mockReturnValueOnce(true);
|
|
85
|
+
|
|
86
|
+
printDryRunPreview(['feat-1', 'feat-2'], ['任务1', '任务2'], 0);
|
|
87
|
+
|
|
88
|
+
// 冲突分支的序号行应同时包含分支名和"已存在"警告
|
|
89
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(expect.stringContaining('已存在'));
|
|
90
|
+
// 冲突分支应包含 ⚠ 标识
|
|
91
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(expect.stringContaining('⚠'));
|
|
92
|
+
// 应输出冲突结论
|
|
93
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(expect.stringContaining('分支冲突'));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('交互式模式显示交互提示', () => {
|
|
97
|
+
mockedCheckBranchExists.mockReturnValue(false);
|
|
98
|
+
|
|
99
|
+
printDryRunPreview(['feat'], [], 0);
|
|
100
|
+
|
|
101
|
+
// 交互式模式下应显示交互模式提示
|
|
102
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(expect.stringContaining('交互式'));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('非交互式模式显示任务描述', () => {
|
|
106
|
+
mockedCheckBranchExists.mockReturnValue(false);
|
|
107
|
+
|
|
108
|
+
printDryRunPreview(['feat-1'], ['实现登录功能'], 2);
|
|
109
|
+
|
|
110
|
+
// 应包含任务描述
|
|
111
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(expect.stringContaining('实现登录功能'));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('摘要行合并为一行并包含关键信息', () => {
|
|
115
|
+
mockedCheckBranchExists.mockReturnValue(false);
|
|
116
|
+
|
|
117
|
+
printDryRunPreview(['feat-1', 'feat-2'], ['任务1', '任务2'], 5);
|
|
118
|
+
|
|
119
|
+
// 摘要行应同时包含任务数、并发数和 Worktree 路径
|
|
120
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(expect.stringContaining('任务数: 2'));
|
|
121
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(expect.stringContaining('并发数: 5'));
|
|
122
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(expect.stringContaining('Worktree:'));
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { parseTaskFile, loadTaskFile } from '../../../src/utils/task-file.js';
|
|
2
|
+
import { parseTaskFile, loadTaskFile, parseTasksFromOptions } from '../../../src/utils/task-file.js';
|
|
3
3
|
import { existsSync, readFileSync } from 'node:fs';
|
|
4
4
|
|
|
5
5
|
vi.mock('node:fs', () => ({
|
|
@@ -234,3 +234,28 @@ describe('loadTaskFile', () => {
|
|
|
234
234
|
expect(entries[0].task).toBe('实现登录功能');
|
|
235
235
|
});
|
|
236
236
|
});
|
|
237
|
+
|
|
238
|
+
describe('parseTasksFromOptions', () => {
|
|
239
|
+
it('正常解析任务列表', () => {
|
|
240
|
+
const tasks = parseTasksFromOptions(['task1', 'task2', 'task3']);
|
|
241
|
+
expect(tasks).toEqual(['task1', 'task2', 'task3']);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('去除首尾空白', () => {
|
|
245
|
+
const tasks = parseTasksFromOptions([' task1 ', ' task2 ']);
|
|
246
|
+
expect(tasks).toEqual(['task1', 'task2']);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('过滤空字符串', () => {
|
|
250
|
+
const tasks = parseTasksFromOptions(['task1', '', ' ', 'task2']);
|
|
251
|
+
expect(tasks).toEqual(['task1', 'task2']);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('全部为空时抛出错误', () => {
|
|
255
|
+
expect(() => parseTasksFromOptions(['', ' '])).toThrow('任务列表不能为空');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('空数组时抛出错误', () => {
|
|
259
|
+
expect(() => parseTasksFromOptions([])).toThrow('任务列表不能为空');
|
|
260
|
+
});
|
|
261
|
+
});
|