clawt 3.7.1 → 3.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.clawt/postCreate.sh +2 -0
- package/CLAUDE.md +10 -0
- package/README.md +19 -2
- package/dist/index.js +172 -26
- package/dist/postinstall.js +30 -1
- package/docs/create.md +6 -2
- package/docs/init.md +2 -2
- package/docs/post-create-hook.md +142 -0
- package/docs/project-config.md +9 -2
- package/docs/run.md +10 -6
- package/docs/spec.md +4 -1
- package/docs/tasks.md +4 -4
- package/package.json +1 -1
- package/src/commands/create.ts +5 -0
- package/src/commands/run.ts +12 -0
- package/src/commands/tasks.ts +1 -1
- package/src/constants/config.ts +1 -1
- package/src/constants/messages/index.ts +2 -0
- package/src/constants/messages/post-create.ts +29 -0
- package/src/constants/project-config.ts +4 -0
- package/src/constants/tasks-template.ts +1 -1
- package/src/hooks/index.ts +1 -0
- package/src/hooks/post-create.ts +198 -0
- package/src/types/command.ts +4 -0
- package/src/types/index.ts +1 -0
- package/src/types/postCreateHook.ts +24 -0
- package/src/types/projectConfig.ts +2 -0
- package/src/utils/claude.ts +2 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/task-executor.ts +5 -1
- package/tests/unit/commands/create.test.ts +1 -0
- package/tests/unit/commands/run.test.ts +1 -0
- package/tests/unit/commands/tasks.test.ts +5 -5
- package/tests/unit/constants/messages-post-create.test.ts +112 -0
- package/tests/unit/hooks/post-create.test.ts +434 -0
- package/tests/unit/utils/claude.test.ts +76 -1
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// mock logger(避免测试时写日志文件)
|
|
4
|
+
vi.mock('../../../src/logger/index.js', () => ({
|
|
5
|
+
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
// mock node:fs
|
|
9
|
+
vi.mock('node:fs', () => ({
|
|
10
|
+
existsSync: vi.fn(),
|
|
11
|
+
accessSync: vi.fn(),
|
|
12
|
+
chmodSync: vi.fn(),
|
|
13
|
+
constants: { X_OK: 1 },
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
// mock node:child_process
|
|
17
|
+
vi.mock('node:child_process', () => ({
|
|
18
|
+
spawn: vi.fn(),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
// mock project-config
|
|
22
|
+
vi.mock('../../../src/utils/project-config.js', () => ({
|
|
23
|
+
loadProjectConfig: vi.fn(),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
// mock git
|
|
27
|
+
vi.mock('../../../src/utils/git.js', () => ({
|
|
28
|
+
getMainWorktreePath: vi.fn(),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
// mock formatter
|
|
32
|
+
vi.mock('../../../src/utils/formatter.js', () => ({
|
|
33
|
+
printInfo: vi.fn(),
|
|
34
|
+
printSuccess: vi.fn(),
|
|
35
|
+
printWarning: vi.fn(),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
import { EventEmitter } from 'node:events';
|
|
39
|
+
import { existsSync, accessSync, chmodSync } from 'node:fs';
|
|
40
|
+
import { spawn } from 'node:child_process';
|
|
41
|
+
import { loadProjectConfig } from '../../../src/utils/project-config.js';
|
|
42
|
+
import { getMainWorktreePath } from '../../../src/utils/git.js';
|
|
43
|
+
import { printInfo, printSuccess, printWarning } from '../../../src/utils/formatter.js';
|
|
44
|
+
import { logger } from '../../../src/logger/index.js';
|
|
45
|
+
import {
|
|
46
|
+
resolvePostCreateHook,
|
|
47
|
+
executePostCreateHooks,
|
|
48
|
+
runPostCreateHooks,
|
|
49
|
+
} from '../../../src/hooks/post-create.js';
|
|
50
|
+
import { createWorktreeInfo, createWorktreeList } from '../../helpers/fixtures.js';
|
|
51
|
+
import type { ResolvedHook } from '../../../src/types/index.js';
|
|
52
|
+
|
|
53
|
+
const mockedLoadProjectConfig = vi.mocked(loadProjectConfig);
|
|
54
|
+
const mockedGetMainWorktreePath = vi.mocked(getMainWorktreePath);
|
|
55
|
+
const mockedExistsSync = vi.mocked(existsSync);
|
|
56
|
+
const mockedAccessSync = vi.mocked(accessSync);
|
|
57
|
+
const mockedChmodSync = vi.mocked(chmodSync);
|
|
58
|
+
const mockedSpawn = vi.mocked(spawn);
|
|
59
|
+
const mockedPrintInfo = vi.mocked(printInfo);
|
|
60
|
+
const mockedPrintSuccess = vi.mocked(printSuccess);
|
|
61
|
+
const mockedPrintWarning = vi.mocked(printWarning);
|
|
62
|
+
const mockedLoggerError = vi.mocked(logger.error);
|
|
63
|
+
const mockedLoggerInfo = vi.mocked(logger.info);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 创建模拟的 spawn 子进程
|
|
67
|
+
* @param {number} exitCode - 子进程退出码
|
|
68
|
+
* @returns {object} 模拟的子进程对象
|
|
69
|
+
*/
|
|
70
|
+
function createMockSpawnChild(exitCode: number) {
|
|
71
|
+
const child = new EventEmitter() as any;
|
|
72
|
+
child.pid = 12345;
|
|
73
|
+
child.killed = false;
|
|
74
|
+
child.kill = vi.fn();
|
|
75
|
+
|
|
76
|
+
// 延迟触发 close 事件
|
|
77
|
+
setTimeout(() => {
|
|
78
|
+
child.emit('close', exitCode);
|
|
79
|
+
}, 5);
|
|
80
|
+
|
|
81
|
+
return child;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 创建会触发 error 事件的模拟子进程
|
|
86
|
+
* @param {string} errorMessage - 错误信息
|
|
87
|
+
* @returns {object} 模拟的子进程对象
|
|
88
|
+
*/
|
|
89
|
+
function createMockSpawnChildWithError(errorMessage: string) {
|
|
90
|
+
const child = new EventEmitter() as any;
|
|
91
|
+
child.pid = 12345;
|
|
92
|
+
child.killed = false;
|
|
93
|
+
child.kill = vi.fn();
|
|
94
|
+
|
|
95
|
+
// 延迟触发 error 事件
|
|
96
|
+
setTimeout(() => {
|
|
97
|
+
child.emit('error', new Error(errorMessage));
|
|
98
|
+
}, 5);
|
|
99
|
+
|
|
100
|
+
return child;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
vi.clearAllMocks();
|
|
105
|
+
mockedGetMainWorktreePath.mockReturnValue('/repo');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ─────────────────────────────────────────────
|
|
109
|
+
// resolvePostCreateHook
|
|
110
|
+
// ─────────────────────────────────────────────
|
|
111
|
+
describe('resolvePostCreateHook', () => {
|
|
112
|
+
it('项目配置有 postCreate 字符串时返回 projectConfig 来源', () => {
|
|
113
|
+
mockedLoadProjectConfig.mockReturnValue({
|
|
114
|
+
clawtMainWorkBranch: 'main',
|
|
115
|
+
postCreate: 'npm install',
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const result = resolvePostCreateHook();
|
|
119
|
+
|
|
120
|
+
expect(result).toEqual({
|
|
121
|
+
command: 'npm install',
|
|
122
|
+
source: 'projectConfig',
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('项目配置 postCreate 为空字符串时回退到脚本检测', () => {
|
|
127
|
+
mockedLoadProjectConfig.mockReturnValue({
|
|
128
|
+
clawtMainWorkBranch: 'main',
|
|
129
|
+
postCreate: '',
|
|
130
|
+
});
|
|
131
|
+
mockedExistsSync.mockReturnValue(false);
|
|
132
|
+
|
|
133
|
+
const result = resolvePostCreateHook();
|
|
134
|
+
|
|
135
|
+
expect(result).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('无项目配置时检测 .clawt/postCreate.sh 脚本', () => {
|
|
139
|
+
mockedLoadProjectConfig.mockReturnValue(null);
|
|
140
|
+
mockedExistsSync.mockReturnValue(true);
|
|
141
|
+
// accessSync 不抛出表示可执行
|
|
142
|
+
mockedAccessSync.mockReturnValue(undefined);
|
|
143
|
+
|
|
144
|
+
const result = resolvePostCreateHook();
|
|
145
|
+
|
|
146
|
+
expect(result).toEqual({
|
|
147
|
+
command: '/repo/.clawt/postCreate.sh',
|
|
148
|
+
source: 'postCreateScript',
|
|
149
|
+
});
|
|
150
|
+
// 验证检测的路径包含 postCreate.sh(而非 setup.sh)
|
|
151
|
+
expect(mockedExistsSync).toHaveBeenCalledWith('/repo/.clawt/postCreate.sh');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('脚本存在但不可执行时自动 chmod +x 后返回 postCreateScript', () => {
|
|
155
|
+
mockedLoadProjectConfig.mockReturnValue(null);
|
|
156
|
+
mockedExistsSync.mockReturnValue(true);
|
|
157
|
+
mockedAccessSync.mockImplementation(() => {
|
|
158
|
+
throw new Error('EACCES');
|
|
159
|
+
});
|
|
160
|
+
mockedChmodSync.mockReturnValue(undefined);
|
|
161
|
+
|
|
162
|
+
const result = resolvePostCreateHook();
|
|
163
|
+
|
|
164
|
+
expect(result).toEqual({
|
|
165
|
+
command: '/repo/.clawt/postCreate.sh',
|
|
166
|
+
source: 'postCreateScript',
|
|
167
|
+
});
|
|
168
|
+
expect(mockedChmodSync).toHaveBeenCalledWith('/repo/.clawt/postCreate.sh', 0o755);
|
|
169
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(
|
|
170
|
+
expect.stringContaining('已自动添加执行权限'),
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('脚本不可执行且自动 chmod 失败时打印警告但仍返回 hook', () => {
|
|
175
|
+
mockedLoadProjectConfig.mockReturnValue(null);
|
|
176
|
+
mockedExistsSync.mockReturnValue(true);
|
|
177
|
+
mockedAccessSync.mockImplementation(() => {
|
|
178
|
+
throw new Error('EACCES');
|
|
179
|
+
});
|
|
180
|
+
mockedChmodSync.mockImplementation(() => {
|
|
181
|
+
throw new Error('EPERM');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const result = resolvePostCreateHook();
|
|
185
|
+
|
|
186
|
+
// 仍然返回 hook(脚本执行阶段会自然报错)
|
|
187
|
+
expect(result).toEqual({
|
|
188
|
+
command: '/repo/.clawt/postCreate.sh',
|
|
189
|
+
source: 'postCreateScript',
|
|
190
|
+
});
|
|
191
|
+
expect(mockedPrintWarning).toHaveBeenCalledWith(
|
|
192
|
+
expect.stringContaining('不可执行'),
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('无项目配置且脚本不存在时返回 null', () => {
|
|
197
|
+
mockedLoadProjectConfig.mockReturnValue(null);
|
|
198
|
+
mockedExistsSync.mockReturnValue(false);
|
|
199
|
+
|
|
200
|
+
const result = resolvePostCreateHook();
|
|
201
|
+
|
|
202
|
+
expect(result).toBeNull();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('项目配置优先级高于脚本', () => {
|
|
206
|
+
mockedLoadProjectConfig.mockReturnValue({
|
|
207
|
+
clawtMainWorkBranch: 'main',
|
|
208
|
+
postCreate: 'pnpm install',
|
|
209
|
+
});
|
|
210
|
+
// 即使脚本也存在,也应该返回 projectConfig 来源
|
|
211
|
+
mockedExistsSync.mockReturnValue(true);
|
|
212
|
+
mockedAccessSync.mockReturnValue(undefined);
|
|
213
|
+
|
|
214
|
+
const result = resolvePostCreateHook();
|
|
215
|
+
|
|
216
|
+
expect(result!.source).toBe('projectConfig');
|
|
217
|
+
expect(result!.command).toBe('pnpm install');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('字符串命令前后空白会被 trim', () => {
|
|
221
|
+
mockedLoadProjectConfig.mockReturnValue({
|
|
222
|
+
clawtMainWorkBranch: 'main',
|
|
223
|
+
postCreate: ' npm install ',
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const result = resolvePostCreateHook();
|
|
227
|
+
|
|
228
|
+
expect(result!.command).toBe('npm install');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ─────────────────────────────────────────────
|
|
234
|
+
// executePostCreateHooks(异步并行版本)
|
|
235
|
+
// ─────────────────────────────────────────────
|
|
236
|
+
describe('executePostCreateHooks', () => {
|
|
237
|
+
const hook: ResolvedHook = {
|
|
238
|
+
command: 'npm install',
|
|
239
|
+
source: 'projectConfig',
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
it('单个 worktree 执行成功', async () => {
|
|
243
|
+
const worktree = createWorktreeInfo({ path: '/wt/feat', branch: 'feat' });
|
|
244
|
+
mockedSpawn.mockReturnValue(createMockSpawnChild(0));
|
|
245
|
+
|
|
246
|
+
const results = await executePostCreateHooks([worktree], hook);
|
|
247
|
+
|
|
248
|
+
expect(results).toHaveLength(1);
|
|
249
|
+
expect(results[0].success).toBe(true);
|
|
250
|
+
expect(results[0].worktreePath).toBe('/wt/feat');
|
|
251
|
+
expect(results[0].branch).toBe('feat');
|
|
252
|
+
expect(results[0].source).toBe('projectConfig');
|
|
253
|
+
expect(results[0].error).toBeUndefined();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('命令退出码非零时标记失败', async () => {
|
|
257
|
+
const worktree = createWorktreeInfo({ path: '/wt/feat', branch: 'feat' });
|
|
258
|
+
mockedSpawn.mockReturnValue(createMockSpawnChild(1));
|
|
259
|
+
|
|
260
|
+
const results = await executePostCreateHooks([worktree], hook);
|
|
261
|
+
|
|
262
|
+
expect(results[0].success).toBe(false);
|
|
263
|
+
expect(results[0].error).toContain('退出码: 1');
|
|
264
|
+
expect(mockedLoggerError).toHaveBeenCalled();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('子进程触发 error 事件时标记失败', async () => {
|
|
268
|
+
const worktree = createWorktreeInfo({ path: '/wt/feat', branch: 'feat' });
|
|
269
|
+
mockedSpawn.mockReturnValue(createMockSpawnChildWithError('spawn ENOENT'));
|
|
270
|
+
|
|
271
|
+
const results = await executePostCreateHooks([worktree], hook);
|
|
272
|
+
|
|
273
|
+
expect(results[0].success).toBe(false);
|
|
274
|
+
expect(results[0].error).toContain('spawn ENOENT');
|
|
275
|
+
expect(mockedLoggerError).toHaveBeenCalled();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('多个 worktree 并行执行,互不影响', async () => {
|
|
279
|
+
const worktrees = createWorktreeList(3);
|
|
280
|
+
mockedSpawn
|
|
281
|
+
.mockReturnValueOnce(createMockSpawnChild(0))
|
|
282
|
+
.mockReturnValueOnce(createMockSpawnChild(1))
|
|
283
|
+
.mockReturnValueOnce(createMockSpawnChild(0));
|
|
284
|
+
|
|
285
|
+
const results = await executePostCreateHooks(worktrees, hook);
|
|
286
|
+
|
|
287
|
+
expect(results).toHaveLength(3);
|
|
288
|
+
expect(results[0].success).toBe(true);
|
|
289
|
+
expect(results[1].success).toBe(false);
|
|
290
|
+
expect(results[2].success).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('正确传递 cwd 给 spawn', async () => {
|
|
294
|
+
const worktree = createWorktreeInfo({ path: '/custom/path', branch: 'test' });
|
|
295
|
+
mockedSpawn.mockReturnValue(createMockSpawnChild(0));
|
|
296
|
+
|
|
297
|
+
await executePostCreateHooks([worktree], hook);
|
|
298
|
+
|
|
299
|
+
expect(mockedSpawn).toHaveBeenCalledWith(
|
|
300
|
+
'npm install',
|
|
301
|
+
expect.objectContaining({ cwd: '/custom/path' }),
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('spawn 使用 shell 模式和 stdio: ignore', async () => {
|
|
306
|
+
const worktree = createWorktreeInfo({ path: '/wt/feat', branch: 'feat' });
|
|
307
|
+
mockedSpawn.mockReturnValue(createMockSpawnChild(0));
|
|
308
|
+
|
|
309
|
+
await executePostCreateHooks([worktree], hook);
|
|
310
|
+
|
|
311
|
+
expect(mockedSpawn).toHaveBeenCalledWith(
|
|
312
|
+
'npm install',
|
|
313
|
+
expect.objectContaining({
|
|
314
|
+
stdio: 'ignore',
|
|
315
|
+
shell: true,
|
|
316
|
+
}),
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('退出码为 null 时视为成功(信号终止场景)', async () => {
|
|
321
|
+
const worktree = createWorktreeInfo({ path: '/wt/feat', branch: 'feat' });
|
|
322
|
+
// 创建退出码为 null 的子进程
|
|
323
|
+
const child = new EventEmitter() as any;
|
|
324
|
+
child.pid = 12345;
|
|
325
|
+
child.killed = false;
|
|
326
|
+
child.kill = vi.fn();
|
|
327
|
+
setTimeout(() => {
|
|
328
|
+
child.emit('close', null);
|
|
329
|
+
}, 5);
|
|
330
|
+
mockedSpawn.mockReturnValue(child);
|
|
331
|
+
|
|
332
|
+
const results = await executePostCreateHooks([worktree], hook);
|
|
333
|
+
|
|
334
|
+
expect(results[0].success).toBe(true);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('source 字段正确传递 postCreateScript', async () => {
|
|
338
|
+
const scriptHook: ResolvedHook = {
|
|
339
|
+
command: '/repo/.clawt/postCreate.sh',
|
|
340
|
+
source: 'postCreateScript',
|
|
341
|
+
};
|
|
342
|
+
const worktree = createWorktreeInfo({ path: '/wt/feat', branch: 'feat' });
|
|
343
|
+
mockedSpawn.mockReturnValue(createMockSpawnChild(0));
|
|
344
|
+
|
|
345
|
+
const results = await executePostCreateHooks([worktree], scriptHook);
|
|
346
|
+
|
|
347
|
+
expect(results[0].source).toBe('postCreateScript');
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// ─────────────────────────────────────────────
|
|
352
|
+
// runPostCreateHooks(完整入口,fire-and-forget)
|
|
353
|
+
// ─────────────────────────────────────────────
|
|
354
|
+
describe('runPostCreateHooks', () => {
|
|
355
|
+
const worktrees = createWorktreeList(2);
|
|
356
|
+
|
|
357
|
+
it('skip 为 true 时直接跳过', () => {
|
|
358
|
+
runPostCreateHooks(worktrees, true);
|
|
359
|
+
|
|
360
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(
|
|
361
|
+
expect.stringContaining('--no-post-create'),
|
|
362
|
+
);
|
|
363
|
+
// 不应调用任何配置加载或命令执行
|
|
364
|
+
expect(mockedLoadProjectConfig).not.toHaveBeenCalled();
|
|
365
|
+
expect(mockedSpawn).not.toHaveBeenCalled();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('无 hook 配置时提示未配置', () => {
|
|
369
|
+
mockedLoadProjectConfig.mockReturnValue(null);
|
|
370
|
+
mockedExistsSync.mockReturnValue(false);
|
|
371
|
+
|
|
372
|
+
runPostCreateHooks(worktrees, false);
|
|
373
|
+
|
|
374
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(
|
|
375
|
+
expect.stringContaining('未配置'),
|
|
376
|
+
);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('有 hook 配置时调用 spawn 启动后台执行', () => {
|
|
380
|
+
mockedLoadProjectConfig.mockReturnValue({
|
|
381
|
+
clawtMainWorkBranch: 'main',
|
|
382
|
+
postCreate: 'npm install',
|
|
383
|
+
});
|
|
384
|
+
mockedSpawn.mockReturnValue(createMockSpawnChild(0));
|
|
385
|
+
|
|
386
|
+
runPostCreateHooks(worktrees, false);
|
|
387
|
+
|
|
388
|
+
// fire-and-forget:spawn 应该被调用(后台异步执行)
|
|
389
|
+
expect(mockedSpawn).toHaveBeenCalledTimes(2);
|
|
390
|
+
// 验证后台执行提示已输出
|
|
391
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(
|
|
392
|
+
expect.stringContaining('后台执行'),
|
|
393
|
+
);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('返回值为 void', () => {
|
|
397
|
+
mockedLoadProjectConfig.mockReturnValue({
|
|
398
|
+
clawtMainWorkBranch: 'main',
|
|
399
|
+
postCreate: 'npm install',
|
|
400
|
+
});
|
|
401
|
+
mockedSpawn.mockReturnValue(createMockSpawnChild(0));
|
|
402
|
+
|
|
403
|
+
const result = runPostCreateHooks(worktrees, false);
|
|
404
|
+
|
|
405
|
+
expect(result).toBeUndefined();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('输出 hook 来源信息(projectConfig)', () => {
|
|
409
|
+
mockedLoadProjectConfig.mockReturnValue({
|
|
410
|
+
clawtMainWorkBranch: 'main',
|
|
411
|
+
postCreate: 'npm install',
|
|
412
|
+
});
|
|
413
|
+
mockedSpawn.mockReturnValue(createMockSpawnChild(0));
|
|
414
|
+
|
|
415
|
+
runPostCreateHooks(worktrees, false);
|
|
416
|
+
|
|
417
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(
|
|
418
|
+
expect.stringContaining('项目配置 (postCreate)'),
|
|
419
|
+
);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('输出 hook 来源信息(postCreateScript)', () => {
|
|
423
|
+
mockedLoadProjectConfig.mockReturnValue(null);
|
|
424
|
+
mockedExistsSync.mockReturnValue(true);
|
|
425
|
+
mockedAccessSync.mockReturnValue(undefined);
|
|
426
|
+
mockedSpawn.mockReturnValue(createMockSpawnChild(0));
|
|
427
|
+
|
|
428
|
+
runPostCreateHooks(worktrees, false);
|
|
429
|
+
|
|
430
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(
|
|
431
|
+
expect.stringContaining('.clawt/postCreate.sh'),
|
|
432
|
+
);
|
|
433
|
+
});
|
|
434
|
+
});
|
|
@@ -29,10 +29,11 @@ vi.mock('../../../src/utils/formatter.js', () => ({
|
|
|
29
29
|
|
|
30
30
|
import { spawnSync } from 'node:child_process';
|
|
31
31
|
import { existsSync, readdirSync } from 'node:fs';
|
|
32
|
-
import { launchInteractiveClaude, hasClaudeSessionHistory } from '../../../src/utils/claude.js';
|
|
32
|
+
import { launchInteractiveClaude, hasClaudeSessionHistory, buildClaudeCommand } from '../../../src/utils/claude.js';
|
|
33
33
|
import { getConfigValue } from '../../../src/utils/config.js';
|
|
34
34
|
import { printInfo, printWarning } from '../../../src/utils/formatter.js';
|
|
35
35
|
import { ClawtError } from '../../../src/errors/index.js';
|
|
36
|
+
import { APPEND_SYSTEM_PROMPT } from '../../../src/constants/config.js';
|
|
36
37
|
import { createWorktreeInfo } from '../../helpers/fixtures.js';
|
|
37
38
|
|
|
38
39
|
const mockedSpawnSync = vi.mocked(spawnSync);
|
|
@@ -283,4 +284,78 @@ describe('launchInteractiveClaude', () => {
|
|
|
283
284
|
const callArgs = mockedSpawnSync.mock.calls[0][1] as string[];
|
|
284
285
|
expect(callArgs).not.toContain('--continue');
|
|
285
286
|
});
|
|
287
|
+
|
|
288
|
+
it('固定使用 APPEND_SYSTEM_PROMPT 作为系统提示', () => {
|
|
289
|
+
mockedGetConfigValue.mockReturnValue('claude');
|
|
290
|
+
mockedExistsSync.mockReturnValue(false);
|
|
291
|
+
mockedSpawnSync.mockReturnValue({
|
|
292
|
+
status: 0,
|
|
293
|
+
error: undefined,
|
|
294
|
+
stdout: '',
|
|
295
|
+
stderr: '',
|
|
296
|
+
pid: 1234,
|
|
297
|
+
output: [],
|
|
298
|
+
signal: null,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
launchInteractiveClaude(worktree);
|
|
302
|
+
|
|
303
|
+
const callArgs = mockedSpawnSync.mock.calls[0][1] as string[];
|
|
304
|
+
const promptIndex = callArgs.indexOf('--append-system-prompt');
|
|
305
|
+
expect(callArgs[promptIndex + 1]).toBe(APPEND_SYSTEM_PROMPT);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe('buildClaudeCommand', () => {
|
|
310
|
+
const worktree = createWorktreeInfo({
|
|
311
|
+
path: '/tmp/test-worktree',
|
|
312
|
+
branch: 'feature-test',
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('生成包含 cd 和 claude 命令的字符串', () => {
|
|
316
|
+
mockedGetConfigValue.mockReturnValue('claude');
|
|
317
|
+
|
|
318
|
+
const cmd = buildClaudeCommand(worktree, false);
|
|
319
|
+
|
|
320
|
+
expect(cmd).toContain("cd '/tmp/test-worktree'");
|
|
321
|
+
expect(cmd).toContain('claude');
|
|
322
|
+
expect(cmd).toContain('--append-system-prompt');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('hasPreviousSession 为 true 时包含 --continue', () => {
|
|
326
|
+
mockedGetConfigValue.mockReturnValue('claude');
|
|
327
|
+
|
|
328
|
+
const cmd = buildClaudeCommand(worktree, true);
|
|
329
|
+
|
|
330
|
+
expect(cmd).toContain('--continue');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('hasPreviousSession 为 false 时不包含 --continue', () => {
|
|
334
|
+
mockedGetConfigValue.mockReturnValue('claude');
|
|
335
|
+
|
|
336
|
+
const cmd = buildClaudeCommand(worktree, false);
|
|
337
|
+
|
|
338
|
+
expect(cmd).not.toContain('--continue');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('固定使用 APPEND_SYSTEM_PROMPT 作为系统提示', () => {
|
|
342
|
+
mockedGetConfigValue.mockReturnValue('claude');
|
|
343
|
+
|
|
344
|
+
const cmd = buildClaudeCommand(worktree, false);
|
|
345
|
+
|
|
346
|
+
expect(cmd).toContain(APPEND_SYSTEM_PROMPT);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('路径中的单引号被正确转义', () => {
|
|
350
|
+
mockedGetConfigValue.mockReturnValue('claude');
|
|
351
|
+
const wtWithQuote = createWorktreeInfo({
|
|
352
|
+
path: "/tmp/test's-worktree",
|
|
353
|
+
branch: 'feat',
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const cmd = buildClaudeCommand(wtWithQuote, false);
|
|
357
|
+
|
|
358
|
+
// 单引号应被转义为 '\''
|
|
359
|
+
expect(cmd).toContain("test'\\''s-worktree");
|
|
360
|
+
});
|
|
286
361
|
});
|