ccmanager 2.1.0 → 2.2.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.
@@ -0,0 +1,387 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { executeHook, executeWorktreePostCreationHook, executeStatusHook, } from './hookExecutor.js';
3
+ import { mkdtemp, rm, readFile } from 'fs/promises';
4
+ import { tmpdir } from 'os';
5
+ import { join } from 'path';
6
+ import { configurationManager } from '../services/configurationManager.js';
7
+ import { WorktreeService } from '../services/worktreeService.js';
8
+ // Mock the configurationManager
9
+ vi.mock('../services/configurationManager.js', () => ({
10
+ configurationManager: {
11
+ getStatusHooks: vi.fn(),
12
+ },
13
+ }));
14
+ // Mock the WorktreeService
15
+ vi.mock('../services/worktreeService.js', () => ({
16
+ WorktreeService: vi.fn(),
17
+ }));
18
+ // Note: This file contains integration tests that execute real commands
19
+ describe('hookExecutor Integration Tests', () => {
20
+ describe('executeHook (real execution)', () => {
21
+ it('should execute a simple echo command', async () => {
22
+ // Arrange
23
+ const tmpDir = await mkdtemp(join(tmpdir(), 'hook-test-'));
24
+ const environment = {
25
+ CCMANAGER_WORKTREE_PATH: tmpDir,
26
+ CCMANAGER_WORKTREE_BRANCH: 'test-branch',
27
+ CCMANAGER_GIT_ROOT: tmpDir,
28
+ };
29
+ try {
30
+ // Act & Assert - should not throw
31
+ await expect(executeHook('echo "Test successful"', tmpDir, environment)).resolves.toBeUndefined();
32
+ }
33
+ finally {
34
+ // Cleanup
35
+ await rm(tmpDir, { recursive: true });
36
+ }
37
+ });
38
+ it('should reject when command fails', async () => {
39
+ // Arrange
40
+ const tmpDir = await mkdtemp(join(tmpdir(), 'hook-test-'));
41
+ const environment = {
42
+ CCMANAGER_WORKTREE_PATH: tmpDir,
43
+ CCMANAGER_WORKTREE_BRANCH: 'test-branch',
44
+ CCMANAGER_GIT_ROOT: tmpDir,
45
+ };
46
+ try {
47
+ // Act & Assert
48
+ await expect(executeHook('exit 1', tmpDir, environment)).rejects.toThrow();
49
+ }
50
+ finally {
51
+ // Cleanup
52
+ await rm(tmpDir, { recursive: true });
53
+ }
54
+ });
55
+ it('should include stderr in error message when command fails', async () => {
56
+ // Arrange
57
+ const tmpDir = await mkdtemp(join(tmpdir(), 'hook-test-'));
58
+ const environment = {
59
+ CCMANAGER_WORKTREE_PATH: tmpDir,
60
+ CCMANAGER_WORKTREE_BRANCH: 'test-branch',
61
+ CCMANAGER_GIT_ROOT: tmpDir,
62
+ };
63
+ try {
64
+ // Act & Assert - command that writes to stderr and exits with error
65
+ await expect(executeHook('>&2 echo "Error details here"; exit 1', tmpDir, environment)).rejects.toThrow('Hook exited with code 1\nStderr: Error details here\n');
66
+ }
67
+ finally {
68
+ // Cleanup
69
+ await rm(tmpDir, { recursive: true });
70
+ }
71
+ });
72
+ it('should verify stderr handling in error messages', async () => {
73
+ // Arrange
74
+ const tmpDir = await mkdtemp(join(tmpdir(), 'hook-test-'));
75
+ const environment = {
76
+ CCMANAGER_WORKTREE_PATH: tmpDir,
77
+ CCMANAGER_WORKTREE_BRANCH: 'test-branch',
78
+ CCMANAGER_GIT_ROOT: tmpDir,
79
+ };
80
+ try {
81
+ // Test with multiline stderr
82
+ try {
83
+ await executeHook('>&2 echo "Line 1"; >&2 echo "Line 2"; exit 3', tmpDir, environment);
84
+ expect.fail('Should have thrown');
85
+ }
86
+ catch (error) {
87
+ expect(error).toBeInstanceOf(Error);
88
+ expect(error.message).toContain('Hook exited with code 3');
89
+ expect(error.message).toContain('Stderr: Line 1\nLine 2');
90
+ }
91
+ // Test with empty stderr
92
+ try {
93
+ await executeHook('exit 4', tmpDir, environment);
94
+ expect.fail('Should have thrown');
95
+ }
96
+ catch (error) {
97
+ expect(error).toBeInstanceOf(Error);
98
+ expect(error.message).toBe('Hook exited with code 4');
99
+ }
100
+ }
101
+ finally {
102
+ // Cleanup
103
+ await rm(tmpDir, { recursive: true });
104
+ }
105
+ });
106
+ it('should ignore stderr when command succeeds', async () => {
107
+ // Arrange
108
+ const tmpDir = await mkdtemp(join(tmpdir(), 'hook-test-'));
109
+ const environment = {
110
+ CCMANAGER_WORKTREE_PATH: tmpDir,
111
+ CCMANAGER_WORKTREE_BRANCH: 'test-branch',
112
+ CCMANAGER_GIT_ROOT: tmpDir,
113
+ };
114
+ try {
115
+ // Act - command that writes to stderr but exits successfully
116
+ // Should not throw even though there's stderr output
117
+ await expect(executeHook('>&2 echo "Warning message"; exit 0', tmpDir, environment)).resolves.toBeUndefined();
118
+ }
119
+ finally {
120
+ // Cleanup
121
+ await rm(tmpDir, { recursive: true });
122
+ }
123
+ });
124
+ it('should execute hook in the specified working directory', async () => {
125
+ // Arrange
126
+ const tmpDir = await mkdtemp(join(tmpdir(), 'hook-cwd-test-'));
127
+ const outputFile = join(tmpDir, 'cwd.txt');
128
+ const environment = {
129
+ CCMANAGER_WORKTREE_PATH: tmpDir,
130
+ CCMANAGER_WORKTREE_BRANCH: 'test-branch',
131
+ CCMANAGER_GIT_ROOT: '/some/other/path',
132
+ };
133
+ try {
134
+ // Act - write current directory to file
135
+ await executeHook(`pwd > "${outputFile}"`, tmpDir, environment);
136
+ // Read the output
137
+ const { readFile } = await import('fs/promises');
138
+ const output = await readFile(outputFile, 'utf-8');
139
+ // Assert - should be executed in tmpDir
140
+ expect(output.trim()).toBe(tmpDir);
141
+ }
142
+ finally {
143
+ // Cleanup
144
+ await rm(tmpDir, { recursive: true });
145
+ }
146
+ });
147
+ });
148
+ describe('executeWorktreePostCreationHook (real execution)', () => {
149
+ it('should not throw even when command fails', async () => {
150
+ // Arrange
151
+ const tmpDir = await mkdtemp(join(tmpdir(), 'hook-test-'));
152
+ const worktree = {
153
+ path: tmpDir,
154
+ branch: 'test-branch',
155
+ isMainWorktree: false,
156
+ hasSession: false,
157
+ };
158
+ try {
159
+ // Act & Assert - should not throw even with failing command
160
+ await expect(executeWorktreePostCreationHook('exit 1', worktree, tmpDir, 'main')).resolves.toBeUndefined();
161
+ }
162
+ finally {
163
+ // Cleanup
164
+ await rm(tmpDir, { recursive: true });
165
+ }
166
+ });
167
+ it('should execute worktree hook in the worktree path by default', async () => {
168
+ // Arrange
169
+ const tmpDir = await mkdtemp(join(tmpdir(), 'hook-worktree-test-'));
170
+ const outputFile = join(tmpDir, 'cwd.txt');
171
+ const worktree = {
172
+ path: tmpDir,
173
+ branch: 'test-branch',
174
+ isMainWorktree: false,
175
+ hasSession: false,
176
+ };
177
+ const gitRoot = '/different/git/root';
178
+ try {
179
+ // Act - write current directory to file
180
+ await executeWorktreePostCreationHook(`pwd > "${outputFile}"`, worktree, gitRoot, 'main');
181
+ // Read the output
182
+ const { readFile } = await import('fs/promises');
183
+ const output = await readFile(outputFile, 'utf-8');
184
+ // Assert - should be executed in worktree path, not git root
185
+ expect(output.trim()).toBe(tmpDir);
186
+ expect(output.trim()).not.toBe(gitRoot);
187
+ }
188
+ finally {
189
+ // Cleanup
190
+ await rm(tmpDir, { recursive: true });
191
+ }
192
+ });
193
+ it('should allow changing to git root using environment variable', async () => {
194
+ // Arrange
195
+ const tmpWorktreeDir = await mkdtemp(join(tmpdir(), 'hook-worktree-'));
196
+ const tmpGitRootDir = await mkdtemp(join(tmpdir(), 'hook-gitroot-'));
197
+ const outputFile = join(tmpWorktreeDir, 'gitroot.txt');
198
+ const worktree = {
199
+ path: tmpWorktreeDir,
200
+ branch: 'test-branch',
201
+ isMainWorktree: false,
202
+ hasSession: false,
203
+ };
204
+ try {
205
+ // Act - change to git root and write its path
206
+ await executeWorktreePostCreationHook(`cd "$CCMANAGER_GIT_ROOT" && pwd > "${outputFile}"`, worktree, tmpGitRootDir, 'main');
207
+ // Read the output
208
+ const { readFile } = await import('fs/promises');
209
+ const output = await readFile(outputFile, 'utf-8');
210
+ // Assert - should have changed to git root
211
+ expect(output.trim()).toBe(tmpGitRootDir);
212
+ }
213
+ finally {
214
+ // Cleanup
215
+ await rm(tmpWorktreeDir, { recursive: true });
216
+ await rm(tmpGitRootDir, { recursive: true });
217
+ }
218
+ });
219
+ it('should wait for all child processes to complete', async () => {
220
+ // Arrange
221
+ const tmpDir = await mkdtemp(join(tmpdir(), 'hook-wait-test-'));
222
+ const outputFile = join(tmpDir, 'delayed.txt');
223
+ const worktree = {
224
+ path: tmpDir,
225
+ branch: 'test-branch',
226
+ isMainWorktree: false,
227
+ hasSession: false,
228
+ };
229
+ try {
230
+ // Act - execute a command that spawns a background process with a delay
231
+ // The background process writes to a file after a delay
232
+ // We use a shell command that creates a background process and then exits
233
+ await executeWorktreePostCreationHook(`(sleep 0.1 && echo "completed" > "${outputFile}") & wait`, worktree, tmpDir, 'main');
234
+ // Read the output - this should exist because we waited for the background process
235
+ const { readFile } = await import('fs/promises');
236
+ const output = await readFile(outputFile, 'utf-8');
237
+ // Assert - the file should contain the expected content
238
+ expect(output.trim()).toBe('completed');
239
+ }
240
+ finally {
241
+ // Cleanup
242
+ await rm(tmpDir, { recursive: true });
243
+ }
244
+ });
245
+ });
246
+ describe('executeStatusHook', () => {
247
+ it('should wait for hook execution to complete', async () => {
248
+ // Arrange
249
+ const tmpDir = await mkdtemp(join(tmpdir(), 'status-hook-test-'));
250
+ const outputFile = join(tmpDir, 'status-hook-output.txt');
251
+ const mockSession = {
252
+ id: 'test-session-123',
253
+ worktreePath: tmpDir, // Use tmpDir as the worktree path
254
+ process: {},
255
+ terminal: {},
256
+ output: [],
257
+ outputHistory: [],
258
+ state: 'idle',
259
+ stateCheckInterval: undefined,
260
+ lastActivity: new Date(),
261
+ isActive: true,
262
+ };
263
+ // Mock WorktreeService to return a worktree with the tmpDir path
264
+ vi.mocked(WorktreeService).mockImplementation(() => ({
265
+ getWorktrees: vi.fn(() => [
266
+ {
267
+ path: tmpDir,
268
+ branch: 'test-branch',
269
+ isMainWorktree: false,
270
+ hasSession: true,
271
+ },
272
+ ]),
273
+ }));
274
+ // Configure mock to return a hook that writes to a file with delay
275
+ vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
276
+ busy: {
277
+ enabled: true,
278
+ command: `sleep 0.1 && echo "Hook executed" > "${outputFile}"`,
279
+ },
280
+ idle: { enabled: false, command: '' },
281
+ waiting_input: { enabled: false, command: '' },
282
+ });
283
+ try {
284
+ // Act - execute the hook and await it
285
+ await executeStatusHook('idle', 'busy', mockSession);
286
+ // Assert - file should exist because we awaited the hook
287
+ const content = await readFile(outputFile, 'utf-8');
288
+ expect(content.trim()).toBe('Hook executed');
289
+ }
290
+ finally {
291
+ // Cleanup
292
+ await rm(tmpDir, { recursive: true });
293
+ }
294
+ });
295
+ it('should handle hook execution errors gracefully', async () => {
296
+ // Arrange
297
+ const tmpDir = await mkdtemp(join(tmpdir(), 'status-hook-test-'));
298
+ const mockSession = {
299
+ id: 'test-session-456',
300
+ worktreePath: tmpDir, // Use tmpDir as the worktree path
301
+ process: {},
302
+ terminal: {},
303
+ output: [],
304
+ outputHistory: [],
305
+ state: 'idle',
306
+ stateCheckInterval: undefined,
307
+ lastActivity: new Date(),
308
+ isActive: true,
309
+ };
310
+ // Mock WorktreeService to return a worktree with the tmpDir path
311
+ vi.mocked(WorktreeService).mockImplementation(() => ({
312
+ getWorktrees: vi.fn(() => [
313
+ {
314
+ path: tmpDir,
315
+ branch: 'test-branch',
316
+ isMainWorktree: false,
317
+ hasSession: true,
318
+ },
319
+ ]),
320
+ }));
321
+ // Configure mock to return a hook that fails
322
+ vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
323
+ busy: {
324
+ enabled: true,
325
+ command: 'exit 1',
326
+ },
327
+ idle: { enabled: false, command: '' },
328
+ waiting_input: { enabled: false, command: '' },
329
+ });
330
+ try {
331
+ // Act & Assert - should not throw even when hook fails
332
+ await expect(executeStatusHook('idle', 'busy', mockSession)).resolves.toBeUndefined();
333
+ }
334
+ finally {
335
+ // Cleanup
336
+ await rm(tmpDir, { recursive: true });
337
+ }
338
+ });
339
+ it('should not execute disabled hooks', async () => {
340
+ // Arrange
341
+ const tmpDir = await mkdtemp(join(tmpdir(), 'status-hook-test-'));
342
+ const outputFile = join(tmpDir, 'should-not-exist.txt');
343
+ const mockSession = {
344
+ id: 'test-session-789',
345
+ worktreePath: tmpDir, // Use tmpDir as the worktree path
346
+ process: {},
347
+ terminal: {},
348
+ output: [],
349
+ outputHistory: [],
350
+ state: 'idle',
351
+ stateCheckInterval: undefined,
352
+ lastActivity: new Date(),
353
+ isActive: true,
354
+ };
355
+ // Mock WorktreeService to return a worktree with the tmpDir path
356
+ vi.mocked(WorktreeService).mockImplementation(() => ({
357
+ getWorktrees: vi.fn(() => [
358
+ {
359
+ path: tmpDir,
360
+ branch: 'test-branch',
361
+ isMainWorktree: false,
362
+ hasSession: true,
363
+ },
364
+ ]),
365
+ }));
366
+ // Configure mock to return a disabled hook
367
+ vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
368
+ busy: {
369
+ enabled: false,
370
+ command: `echo "Should not run" > "${outputFile}"`,
371
+ },
372
+ idle: { enabled: false, command: '' },
373
+ waiting_input: { enabled: false, command: '' },
374
+ });
375
+ try {
376
+ // Act
377
+ await executeStatusHook('idle', 'busy', mockSession);
378
+ // Assert - file should not exist because hook was disabled
379
+ await expect(readFile(outputFile, 'utf-8')).rejects.toThrow();
380
+ }
381
+ finally {
382
+ // Cleanup
383
+ await rm(tmpDir, { recursive: true });
384
+ }
385
+ });
386
+ });
387
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",
@@ -1,6 +0,0 @@
1
- import React from 'react';
2
- interface ConfigureHooksProps {
3
- onComplete: () => void;
4
- }
5
- declare const ConfigureHooks: React.FC<ConfigureHooksProps>;
6
- export default ConfigureHooks;