ccmanager 1.2.0 → 1.3.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.
@@ -1,10 +1,13 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import { SessionManager } from './sessionManager.js';
3
- import { configurationManager } from './configurationManager.js';
4
2
  import { spawn } from 'node-pty';
5
3
  import { EventEmitter } from 'events';
4
+ import { exec } from 'child_process';
6
5
  // Mock node-pty
7
6
  vi.mock('node-pty');
7
+ // Mock child_process
8
+ vi.mock('child_process', () => ({
9
+ exec: vi.fn(),
10
+ }));
8
11
  // Mock configuration manager
9
12
  vi.mock('./configurationManager.js', () => ({
10
13
  configurationManager: {
@@ -28,6 +31,10 @@ vi.mock('@xterm/headless', () => ({
28
31
  })),
29
32
  },
30
33
  }));
34
+ // Mock worktreeService
35
+ vi.mock('./worktreeService.js', () => ({
36
+ WorktreeService: vi.fn(),
37
+ }));
31
38
  // Create a mock IPty class
32
39
  class MockPty extends EventEmitter {
33
40
  constructor() {
@@ -71,53 +78,140 @@ class MockPty extends EventEmitter {
71
78
  describe('SessionManager', () => {
72
79
  let sessionManager;
73
80
  let mockPty;
74
- beforeEach(() => {
81
+ let SessionManager;
82
+ let configurationManager;
83
+ beforeEach(async () => {
75
84
  vi.clearAllMocks();
85
+ // Dynamically import after mocks are set up
86
+ const sessionManagerModule = await import('./sessionManager.js');
87
+ const configManagerModule = await import('./configurationManager.js');
88
+ SessionManager = sessionManagerModule.SessionManager;
89
+ configurationManager = configManagerModule.configurationManager;
76
90
  sessionManager = new SessionManager();
77
91
  mockPty = new MockPty();
78
92
  });
79
93
  afterEach(() => {
80
94
  sessionManager.destroy();
81
95
  });
82
- describe('createSession with command configuration', () => {
83
- it('should create session with default command when no args configured', async () => {
84
- // Setup mock configuration
85
- vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
96
+ describe('createSessionWithPreset', () => {
97
+ it('should use default preset when no preset ID specified', async () => {
98
+ // Setup mock preset
99
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
100
+ id: '1',
101
+ name: 'Main',
86
102
  command: 'claude',
103
+ args: ['--preset-arg'],
87
104
  });
88
105
  // Setup spawn mock
89
106
  vi.mocked(spawn).mockReturnValue(mockPty);
90
- // Create session
91
- await sessionManager.createSession('/test/worktree');
92
- // Verify spawn was called with correct arguments
93
- expect(spawn).toHaveBeenCalledWith('claude', [], {
107
+ // Create session with preset
108
+ await sessionManager.createSessionWithPreset('/test/worktree');
109
+ // Verify spawn was called with preset config
110
+ expect(spawn).toHaveBeenCalledWith('claude', ['--preset-arg'], {
94
111
  name: 'xterm-color',
95
112
  cols: expect.any(Number),
96
113
  rows: expect.any(Number),
97
114
  cwd: '/test/worktree',
98
115
  env: process.env,
99
116
  });
100
- // Session creation verified by spawn being called
101
117
  });
102
- it('should create session with configured arguments', async () => {
103
- // Setup mock configuration with args
104
- vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
118
+ it('should use specific preset when ID provided', async () => {
119
+ // Setup mock preset
120
+ vi.mocked(configurationManager.getPresetById).mockReturnValue({
121
+ id: '2',
122
+ name: 'Development',
105
123
  command: 'claude',
106
- args: ['--resume', '--model', 'opus'],
124
+ args: ['--resume', '--dev'],
125
+ fallbackArgs: ['--no-mcp'],
107
126
  });
108
127
  // Setup spawn mock
109
128
  vi.mocked(spawn).mockReturnValue(mockPty);
110
- // Create session
111
- await sessionManager.createSession('/test/worktree');
112
- // Verify spawn was called with configured arguments
113
- expect(spawn).toHaveBeenCalledWith('claude', ['--resume', '--model', 'opus'], expect.objectContaining({
129
+ // Create session with specific preset
130
+ await sessionManager.createSessionWithPreset('/test/worktree', '2');
131
+ // Verify getPresetById was called with correct ID
132
+ expect(configurationManager.getPresetById).toHaveBeenCalledWith('2');
133
+ // Verify spawn was called with preset config
134
+ expect(spawn).toHaveBeenCalledWith('claude', ['--resume', '--dev'], {
135
+ name: 'xterm-color',
136
+ cols: expect.any(Number),
137
+ rows: expect.any(Number),
114
138
  cwd: '/test/worktree',
115
- }));
116
- // Session creation verified by spawn being called
139
+ env: process.env,
140
+ });
141
+ });
142
+ it('should fall back to default preset if specified preset not found', async () => {
143
+ // Setup mocks
144
+ vi.mocked(configurationManager.getPresetById).mockReturnValue(undefined);
145
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
146
+ id: '1',
147
+ name: 'Main',
148
+ command: 'claude',
149
+ });
150
+ // Setup spawn mock
151
+ vi.mocked(spawn).mockReturnValue(mockPty);
152
+ // Create session with non-existent preset
153
+ await sessionManager.createSessionWithPreset('/test/worktree', 'invalid');
154
+ // Verify fallback to default preset
155
+ expect(configurationManager.getDefaultPreset).toHaveBeenCalled();
156
+ expect(spawn).toHaveBeenCalledWith('claude', [], expect.any(Object));
157
+ });
158
+ it('should throw error when spawn fails with preset', async () => {
159
+ // Setup mock preset with fallback
160
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
161
+ id: '1',
162
+ name: 'Main',
163
+ command: 'claude',
164
+ args: ['--bad-flag'],
165
+ fallbackArgs: ['--good-flag'],
166
+ });
167
+ // Mock spawn to fail
168
+ vi.mocked(spawn).mockImplementation(() => {
169
+ throw new Error('Command failed');
170
+ });
171
+ // Expect createSessionWithPreset to throw
172
+ await expect(sessionManager.createSessionWithPreset('/test/worktree')).rejects.toThrow('Command failed');
173
+ // Verify only one spawn attempt was made
174
+ expect(spawn).toHaveBeenCalledTimes(1);
175
+ expect(spawn).toHaveBeenCalledWith('claude', ['--bad-flag'], expect.any(Object));
176
+ });
177
+ it('should return existing session if already created', async () => {
178
+ // Setup mock preset
179
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
180
+ id: '1',
181
+ name: 'Main',
182
+ command: 'claude',
183
+ });
184
+ // Setup spawn mock
185
+ vi.mocked(spawn).mockReturnValue(mockPty);
186
+ // Create session twice
187
+ const session1 = await sessionManager.createSessionWithPreset('/test/worktree');
188
+ const session2 = await sessionManager.createSessionWithPreset('/test/worktree');
189
+ // Should return the same session
190
+ expect(session1).toBe(session2);
191
+ // Spawn should only be called once
192
+ expect(spawn).toHaveBeenCalledTimes(1);
193
+ });
194
+ it('should throw error when spawn fails with fallback args', async () => {
195
+ // Setup mock preset with fallback
196
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
197
+ id: '1',
198
+ name: 'Main',
199
+ command: 'nonexistent-command',
200
+ args: ['--flag1'],
201
+ fallbackArgs: ['--flag2'],
202
+ });
203
+ // Mock spawn to always throw error
204
+ vi.mocked(spawn).mockImplementation(() => {
205
+ throw new Error('Command not found');
206
+ });
207
+ // Expect createSessionWithPreset to throw the original error
208
+ await expect(sessionManager.createSessionWithPreset('/test/worktree')).rejects.toThrow('Command not found');
117
209
  });
118
210
  it('should use fallback args when main command exits with code 1', async () => {
119
- // Setup mock configuration with fallback
120
- vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
211
+ // Setup mock preset with fallback
212
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
213
+ id: '1',
214
+ name: 'Main',
121
215
  command: 'claude',
122
216
  args: ['--invalid-flag'],
123
217
  fallbackArgs: ['--resume'],
@@ -130,7 +224,7 @@ describe('SessionManager', () => {
130
224
  .mockReturnValueOnce(firstMockPty)
131
225
  .mockReturnValueOnce(secondMockPty);
132
226
  // Create session
133
- const session = await sessionManager.createSession('/test/worktree');
227
+ const session = await sessionManager.createSessionWithPreset('/test/worktree');
134
228
  // Verify initial spawn
135
229
  expect(spawn).toHaveBeenCalledTimes(1);
136
230
  expect(spawn).toHaveBeenCalledWith('claude', ['--invalid-flag'], expect.objectContaining({ cwd: '/test/worktree' }));
@@ -145,38 +239,11 @@ describe('SessionManager', () => {
145
239
  expect(session.process).toBe(secondMockPty);
146
240
  expect(session.isPrimaryCommand).toBe(false);
147
241
  });
148
- it('should throw error when spawn fails and no fallback configured', async () => {
149
- // Setup mock configuration without fallback
150
- vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
151
- command: 'claude',
152
- args: ['--invalid-flag'],
153
- });
154
- // Mock spawn to throw error
155
- vi.mocked(spawn).mockImplementation(() => {
156
- throw new Error('spawn failed');
157
- });
158
- // Expect createSession to throw
159
- await expect(sessionManager.createSession('/test/worktree')).rejects.toThrow('spawn failed');
160
- });
161
- it('should handle custom command configuration', async () => {
162
- // Setup mock configuration with custom command
163
- vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
164
- command: 'my-custom-claude',
165
- args: ['--config', '/path/to/config'],
166
- });
167
- // Setup spawn mock
168
- vi.mocked(spawn).mockReturnValue(mockPty);
169
- // Create session
170
- await sessionManager.createSession('/test/worktree');
171
- // Verify spawn was called with custom command
172
- expect(spawn).toHaveBeenCalledWith('my-custom-claude', ['--config', '/path/to/config'], expect.objectContaining({
173
- cwd: '/test/worktree',
174
- }));
175
- // Session creation verified by spawn being called
176
- });
177
242
  it('should not use fallback if main command succeeds', async () => {
178
- // Setup mock configuration with fallback
179
- vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
243
+ // Setup mock preset with fallback
244
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
245
+ id: '1',
246
+ name: 'Main',
180
247
  command: 'claude',
181
248
  args: ['--resume'],
182
249
  fallbackArgs: ['--other-flag'],
@@ -184,53 +251,90 @@ describe('SessionManager', () => {
184
251
  // Setup spawn mock - process doesn't exit early
185
252
  vi.mocked(spawn).mockReturnValue(mockPty);
186
253
  // Create session
187
- await sessionManager.createSession('/test/worktree');
254
+ await sessionManager.createSessionWithPreset('/test/worktree');
188
255
  // Wait a bit to ensure no early exit
189
256
  await new Promise(resolve => setTimeout(resolve, 600));
190
257
  // Verify only one spawn attempt
191
258
  expect(spawn).toHaveBeenCalledTimes(1);
192
259
  expect(spawn).toHaveBeenCalledWith('claude', ['--resume'], expect.objectContaining({ cwd: '/test/worktree' }));
193
- // Session creation verified by spawn being called
194
260
  });
195
- it('should return existing session if already created', async () => {
196
- // Setup mock configuration
197
- vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
261
+ it('should use empty args as fallback when no fallback args specified', async () => {
262
+ // Setup mock preset without fallback args
263
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
264
+ id: '1',
265
+ name: 'Main',
198
266
  command: 'claude',
267
+ args: ['--invalid-flag'],
268
+ // No fallbackArgs
269
+ });
270
+ // First spawn attempt - will exit with code 1
271
+ const firstMockPty = new MockPty();
272
+ // Second spawn attempt - succeeds
273
+ const secondMockPty = new MockPty();
274
+ vi.mocked(spawn)
275
+ .mockReturnValueOnce(firstMockPty)
276
+ .mockReturnValueOnce(secondMockPty);
277
+ // Create session
278
+ const session = await sessionManager.createSessionWithPreset('/test/worktree');
279
+ // Verify initial spawn
280
+ expect(spawn).toHaveBeenCalledTimes(1);
281
+ expect(spawn).toHaveBeenCalledWith('claude', ['--invalid-flag'], expect.objectContaining({ cwd: '/test/worktree' }));
282
+ // Simulate exit with code 1 on first attempt
283
+ firstMockPty.emit('exit', { exitCode: 1 });
284
+ // Wait for fallback to occur
285
+ await new Promise(resolve => setTimeout(resolve, 50));
286
+ // Verify fallback spawn was called with empty args
287
+ expect(spawn).toHaveBeenCalledTimes(2);
288
+ expect(spawn).toHaveBeenNthCalledWith(2, 'claude', [], // Empty args
289
+ expect.objectContaining({ cwd: '/test/worktree' }));
290
+ // Verify session process was replaced
291
+ expect(session.process).toBe(secondMockPty);
292
+ expect(session.isPrimaryCommand).toBe(false);
293
+ });
294
+ it('should handle custom command configuration', async () => {
295
+ // Setup mock preset with custom command
296
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
297
+ id: '1',
298
+ name: 'Main',
299
+ command: 'my-custom-claude',
300
+ args: ['--config', '/path/to/config'],
199
301
  });
200
302
  // Setup spawn mock
201
303
  vi.mocked(spawn).mockReturnValue(mockPty);
202
- // Create session twice
203
- const session1 = await sessionManager.createSession('/test/worktree');
204
- const session2 = await sessionManager.createSession('/test/worktree');
205
- // Should return the same session
206
- expect(session1).toBe(session2);
207
- // Spawn should only be called once
208
- expect(spawn).toHaveBeenCalledTimes(1);
304
+ // Create session
305
+ await sessionManager.createSessionWithPreset('/test/worktree');
306
+ // Verify spawn was called with custom command
307
+ expect(spawn).toHaveBeenCalledWith('my-custom-claude', ['--config', '/path/to/config'], expect.objectContaining({
308
+ cwd: '/test/worktree',
309
+ }));
209
310
  });
210
- it('should throw error when spawn fails with fallback args', async () => {
211
- // Setup mock configuration with fallback
212
- vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
213
- command: 'nonexistent-command',
214
- args: ['--flag1'],
215
- fallbackArgs: ['--flag2'],
311
+ it('should throw error when spawn fails and no fallback configured', async () => {
312
+ // Setup mock preset without fallback
313
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
314
+ id: '1',
315
+ name: 'Main',
316
+ command: 'claude',
317
+ args: ['--invalid-flag'],
216
318
  });
217
- // Mock spawn to always throw error
319
+ // Mock spawn to throw error
218
320
  vi.mocked(spawn).mockImplementation(() => {
219
- throw new Error('Command not found');
321
+ throw new Error('spawn failed');
220
322
  });
221
- // Expect createSession to throw the original error
222
- await expect(sessionManager.createSession('/test/worktree')).rejects.toThrow('Command not found');
323
+ // Expect createSessionWithPreset to throw
324
+ await expect(sessionManager.createSessionWithPreset('/test/worktree')).rejects.toThrow('spawn failed');
223
325
  });
224
326
  });
225
327
  describe('session lifecycle', () => {
226
328
  it('should destroy session and clean up resources', async () => {
227
329
  // Setup
228
- vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
330
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
331
+ id: '1',
332
+ name: 'Main',
229
333
  command: 'claude',
230
334
  });
231
335
  vi.mocked(spawn).mockReturnValue(mockPty);
232
336
  // Create and destroy session
233
- await sessionManager.createSession('/test/worktree');
337
+ await sessionManager.createSessionWithPreset('/test/worktree');
234
338
  sessionManager.destroySession('/test/worktree');
235
339
  // Verify cleanup
236
340
  expect(mockPty.kill).toHaveBeenCalled();
@@ -238,7 +342,9 @@ describe('SessionManager', () => {
238
342
  });
239
343
  it('should handle session exit event', async () => {
240
344
  // Setup
241
- vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
345
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
346
+ id: '1',
347
+ name: 'Main',
242
348
  command: 'claude',
243
349
  });
244
350
  vi.mocked(spawn).mockReturnValue(mockPty);
@@ -248,7 +354,7 @@ describe('SessionManager', () => {
248
354
  exitedSession = session;
249
355
  });
250
356
  // Create session
251
- const createdSession = await sessionManager.createSession('/test/worktree');
357
+ const createdSession = await sessionManager.createSessionWithPreset('/test/worktree');
252
358
  // Simulate process exit after successful creation
253
359
  setTimeout(() => {
254
360
  mockPty.emit('exit', { exitCode: 0 });
@@ -259,27 +365,46 @@ describe('SessionManager', () => {
259
365
  expect(sessionManager.getSession('/test/worktree')).toBeUndefined();
260
366
  });
261
367
  });
262
- describe('createSession with presets', () => {
263
- it('should use default preset when no preset ID specified', async () => {
368
+ describe('createSessionWithDevcontainer', () => {
369
+ beforeEach(() => {
370
+ // Reset shouldFail flag
371
+ const mockExec = vi.mocked(exec);
372
+ mockExec.shouldFail = false;
373
+ // Setup exec mock to work with promisify
374
+ mockExec.mockImplementation(((...args) => {
375
+ const [command, , callback] = args;
376
+ if (callback) {
377
+ // Handle callback style
378
+ if (command.includes('devcontainer up')) {
379
+ if (mockExec.shouldFail) {
380
+ callback(new Error('Container startup failed'));
381
+ }
382
+ else {
383
+ callback(null, '', '');
384
+ }
385
+ }
386
+ }
387
+ }));
388
+ });
389
+ it('should execute devcontainer up command before creating session', async () => {
264
390
  // Setup mock preset
265
391
  vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
266
392
  id: '1',
267
393
  name: 'Main',
268
394
  command: 'claude',
269
- args: ['--preset-arg'],
395
+ args: ['--resume'],
270
396
  });
271
397
  // Setup spawn mock
272
398
  vi.mocked(spawn).mockReturnValue(mockPty);
273
- // Create session with preset
274
- await sessionManager.createSessionWithPreset('/test/worktree');
275
- // Verify spawn was called with preset config
276
- expect(spawn).toHaveBeenCalledWith('claude', ['--preset-arg'], {
277
- name: 'xterm-color',
278
- cols: expect.any(Number),
279
- rows: expect.any(Number),
280
- cwd: '/test/worktree',
281
- env: process.env,
282
- });
399
+ // Create session with devcontainer
400
+ const devcontainerConfig = {
401
+ upCommand: 'devcontainer up --workspace-folder .',
402
+ execCommand: 'devcontainer exec --workspace-folder .',
403
+ };
404
+ await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig);
405
+ // Verify spawn was called correctly which proves devcontainer up succeeded
406
+ // Verify spawn was called with devcontainer exec
407
+ expect(spawn).toHaveBeenCalledWith('devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude', '--resume'], expect.objectContaining({ cwd: '/test/worktree' }));
283
408
  });
284
409
  it('should use specific preset when ID provided', async () => {
285
410
  // Setup mock preset
@@ -288,76 +413,285 @@ describe('SessionManager', () => {
288
413
  name: 'Development',
289
414
  command: 'claude',
290
415
  args: ['--resume', '--dev'],
291
- fallbackArgs: ['--no-mcp'],
292
416
  });
293
417
  // Setup spawn mock
294
418
  vi.mocked(spawn).mockReturnValue(mockPty);
295
- // Create session with specific preset
296
- await sessionManager.createSessionWithPreset('/test/worktree', '2');
297
- // Verify getPresetById was called with correct ID
419
+ // Create session with devcontainer and specific preset
420
+ const devcontainerConfig = {
421
+ upCommand: 'devcontainer up',
422
+ execCommand: 'devcontainer exec',
423
+ };
424
+ await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig, '2');
425
+ // Verify correct preset was used
298
426
  expect(configurationManager.getPresetById).toHaveBeenCalledWith('2');
299
- // Verify spawn was called with preset config
300
- expect(spawn).toHaveBeenCalledWith('claude', ['--resume', '--dev'], {
301
- name: 'xterm-color',
302
- cols: expect.any(Number),
303
- rows: expect.any(Number),
304
- cwd: '/test/worktree',
305
- env: process.env,
427
+ expect(spawn).toHaveBeenCalledWith('devcontainer', ['exec', '--', 'claude', '--resume', '--dev'], expect.any(Object));
428
+ });
429
+ it('should throw error when devcontainer up fails', async () => {
430
+ // Setup exec to fail
431
+ const mockExec = vi.mocked(exec);
432
+ mockExec.shouldFail = true;
433
+ // Create session with devcontainer
434
+ const devcontainerConfig = {
435
+ upCommand: 'devcontainer up',
436
+ execCommand: 'devcontainer exec',
437
+ };
438
+ await expect(sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig)).rejects.toThrow('Failed to start devcontainer: Container startup failed');
439
+ });
440
+ it('should return existing session if already created', async () => {
441
+ // Setup mock preset
442
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
443
+ id: '1',
444
+ name: 'Main',
445
+ command: 'claude',
306
446
  });
447
+ // Setup spawn mock
448
+ vi.mocked(spawn).mockReturnValue(mockPty);
449
+ const devcontainerConfig = {
450
+ upCommand: 'devcontainer up',
451
+ execCommand: 'devcontainer exec',
452
+ };
453
+ // Create session twice
454
+ const session1 = await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig);
455
+ const session2 = await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig);
456
+ // Should return the same session
457
+ expect(session1).toBe(session2);
458
+ // spawn should only be called once
459
+ expect(spawn).toHaveBeenCalledTimes(1);
307
460
  });
308
- it('should fall back to default preset if specified preset not found', async () => {
309
- // Setup mocks
310
- vi.mocked(configurationManager.getPresetById).mockReturnValue(undefined);
461
+ it('should handle complex exec commands with multiple arguments', async () => {
462
+ // Setup mock preset
311
463
  vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
312
464
  id: '1',
313
465
  name: 'Main',
314
466
  command: 'claude',
467
+ args: ['--model', 'opus'],
315
468
  });
316
469
  // Setup spawn mock
317
470
  vi.mocked(spawn).mockReturnValue(mockPty);
318
- // Create session with non-existent preset
319
- await sessionManager.createSessionWithPreset('/test/worktree', 'invalid');
320
- // Verify fallback to default preset
321
- expect(configurationManager.getDefaultPreset).toHaveBeenCalled();
322
- expect(spawn).toHaveBeenCalledWith('claude', [], expect.any(Object));
471
+ // Create session with complex exec command
472
+ const devcontainerConfig = {
473
+ upCommand: 'devcontainer up --workspace-folder . --log-level debug',
474
+ execCommand: 'devcontainer exec --workspace-folder . --container-name mycontainer',
475
+ };
476
+ await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig);
477
+ // Verify spawn was called with properly parsed exec command
478
+ expect(spawn).toHaveBeenCalledWith('devcontainer', [
479
+ 'exec',
480
+ '--workspace-folder',
481
+ '.',
482
+ '--container-name',
483
+ 'mycontainer',
484
+ '--',
485
+ 'claude',
486
+ '--model',
487
+ 'opus',
488
+ ], expect.any(Object));
323
489
  });
324
- it('should try fallback args with preset if main command fails', async () => {
325
- // Setup mock preset with fallback
490
+ it('should spawn process with devcontainer exec command', async () => {
491
+ // Create a new session manager and reset mocks
492
+ vi.clearAllMocks();
493
+ sessionManager = new SessionManager();
326
494
  vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
327
495
  id: '1',
328
496
  name: 'Main',
329
497
  command: 'claude',
330
- args: ['--bad-flag'],
331
- fallbackArgs: ['--good-flag'],
498
+ args: [],
332
499
  });
333
- // Mock spawn to fail first, succeed second
334
- let callCount = 0;
335
- vi.mocked(spawn).mockImplementation(() => {
336
- callCount++;
337
- if (callCount === 1) {
338
- throw new Error('Command failed');
500
+ // Setup spawn mock
501
+ vi.mocked(spawn).mockReturnValue(mockPty);
502
+ const mockExec = vi.mocked(exec);
503
+ mockExec.mockImplementation((cmd, options, callback) => {
504
+ if (typeof options === 'function') {
505
+ callback = options;
506
+ options = undefined;
507
+ }
508
+ if (callback && typeof callback === 'function') {
509
+ callback(null, 'Container started', '');
339
510
  }
340
- return mockPty;
511
+ return {};
341
512
  });
342
- // Create session
343
- await sessionManager.createSessionWithPreset('/test/worktree');
344
- // Verify both attempts were made
513
+ await sessionManager.createSessionWithDevcontainer('/test/worktree2', {
514
+ upCommand: 'devcontainer up --workspace-folder .',
515
+ execCommand: 'devcontainer exec --workspace-folder .',
516
+ });
517
+ // Should spawn with devcontainer exec command
518
+ expect(spawn).toHaveBeenCalledWith('devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude'], expect.objectContaining({
519
+ cwd: '/test/worktree2',
520
+ }));
521
+ });
522
+ it('should use preset with devcontainer', async () => {
523
+ const mockExec = vi.mocked(exec);
524
+ mockExec.mockImplementation((cmd, options, callback) => {
525
+ if (typeof options === 'function') {
526
+ callback = options;
527
+ options = undefined;
528
+ }
529
+ if (callback && typeof callback === 'function') {
530
+ callback(null, 'Container started', '');
531
+ }
532
+ return {};
533
+ });
534
+ await sessionManager.createSessionWithDevcontainer('/test/worktree', {
535
+ upCommand: 'devcontainer up --workspace-folder .',
536
+ execCommand: 'devcontainer exec --workspace-folder .',
537
+ }, 'custom-preset');
538
+ // Should call createSessionWithPreset internally
539
+ const session = sessionManager.getSession('/test/worktree');
540
+ expect(session).toBeDefined();
541
+ expect(session?.devcontainerConfig).toEqual({
542
+ upCommand: 'devcontainer up --workspace-folder .',
543
+ execCommand: 'devcontainer exec --workspace-folder .',
544
+ });
545
+ });
546
+ it('should parse exec command and append preset command', async () => {
547
+ const mockExec = vi.mocked(exec);
548
+ mockExec.mockImplementation((cmd, options, callback) => {
549
+ if (typeof options === 'function') {
550
+ callback = options;
551
+ options = undefined;
552
+ }
553
+ if (callback && typeof callback === 'function') {
554
+ callback(null, 'Container started', '');
555
+ }
556
+ return {};
557
+ });
558
+ const config = {
559
+ upCommand: 'devcontainer up --workspace-folder /path/to/project',
560
+ execCommand: 'devcontainer exec --workspace-folder /path/to/project --user vscode',
561
+ };
562
+ await sessionManager.createSessionWithDevcontainer('/test/worktree', config);
563
+ expect(spawn).toHaveBeenCalledWith('devcontainer', [
564
+ 'exec',
565
+ '--workspace-folder',
566
+ '/path/to/project',
567
+ '--user',
568
+ 'vscode',
569
+ '--',
570
+ 'claude',
571
+ ], expect.any(Object));
572
+ });
573
+ it('should handle preset with args in devcontainer', async () => {
574
+ const mockExec = vi.mocked(exec);
575
+ mockExec.mockImplementation((cmd, options, callback) => {
576
+ if (typeof options === 'function') {
577
+ callback = options;
578
+ options = undefined;
579
+ }
580
+ if (callback && typeof callback === 'function') {
581
+ callback(null, 'Container started', '');
582
+ }
583
+ return {};
584
+ });
585
+ vi.mocked(configurationManager.getPresetById).mockReturnValue({
586
+ id: 'claude-with-args',
587
+ name: 'Claude with Args',
588
+ command: 'claude',
589
+ args: ['-m', 'claude-3-opus'],
590
+ });
591
+ await sessionManager.createSessionWithDevcontainer('/test/worktree', {
592
+ upCommand: 'devcontainer up --workspace-folder .',
593
+ execCommand: 'devcontainer exec --workspace-folder .',
594
+ }, 'claude-with-args');
595
+ expect(spawn).toHaveBeenCalledWith('devcontainer', [
596
+ 'exec',
597
+ '--workspace-folder',
598
+ '.',
599
+ '--',
600
+ 'claude',
601
+ '-m',
602
+ 'claude-3-opus',
603
+ ], expect.any(Object));
604
+ });
605
+ it('should use empty args as fallback in devcontainer when no fallback args specified', async () => {
606
+ const mockExec = vi.mocked(exec);
607
+ mockExec.mockImplementation((cmd, options, callback) => {
608
+ if (typeof options === 'function') {
609
+ callback = options;
610
+ options = undefined;
611
+ }
612
+ if (callback && typeof callback === 'function') {
613
+ callback(null, 'Container started', '');
614
+ }
615
+ return {};
616
+ });
617
+ // Setup preset without fallback args
618
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
619
+ id: '1',
620
+ name: 'Main',
621
+ command: 'claude',
622
+ args: ['--invalid-flag'],
623
+ // No fallbackArgs
624
+ });
625
+ // First spawn attempt - will exit with code 1
626
+ const firstMockPty = new MockPty();
627
+ // Second spawn attempt - succeeds
628
+ const secondMockPty = new MockPty();
629
+ vi.mocked(spawn)
630
+ .mockReturnValueOnce(firstMockPty)
631
+ .mockReturnValueOnce(secondMockPty);
632
+ const session = await sessionManager.createSessionWithDevcontainer('/test/worktree', {
633
+ upCommand: 'devcontainer up --workspace-folder .',
634
+ execCommand: 'devcontainer exec --workspace-folder .',
635
+ });
636
+ // Verify initial spawn
637
+ expect(spawn).toHaveBeenCalledTimes(1);
638
+ expect(spawn).toHaveBeenCalledWith('devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude', '--invalid-flag'], expect.objectContaining({ cwd: '/test/worktree' }));
639
+ // Simulate exit with code 1 on first attempt
640
+ firstMockPty.emit('exit', { exitCode: 1 });
641
+ // Wait for fallback to occur
642
+ await new Promise(resolve => setTimeout(resolve, 50));
643
+ // Verify fallback spawn was called with empty args
345
644
  expect(spawn).toHaveBeenCalledTimes(2);
346
- expect(spawn).toHaveBeenNthCalledWith(1, 'claude', ['--bad-flag'], expect.any(Object));
347
- expect(spawn).toHaveBeenNthCalledWith(2, 'claude', ['--good-flag'], expect.any(Object));
645
+ expect(spawn).toHaveBeenNthCalledWith(2, 'devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude'], // No args after claude
646
+ expect.objectContaining({ cwd: '/test/worktree' }));
647
+ // Verify session process was replaced
648
+ expect(session.process).toBe(secondMockPty);
649
+ expect(session.isPrimaryCommand).toBe(false);
348
650
  });
349
- it('should maintain backward compatibility with createSession', async () => {
350
- // Setup legacy config
351
- vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
651
+ it('should use fallback args in devcontainer when primary command exits with code 1', async () => {
652
+ const mockExec = vi.mocked(exec);
653
+ mockExec.mockImplementation((cmd, options, callback) => {
654
+ if (typeof options === 'function') {
655
+ callback = options;
656
+ options = undefined;
657
+ }
658
+ if (callback && typeof callback === 'function') {
659
+ callback(null, 'Container started', '');
660
+ }
661
+ return {};
662
+ });
663
+ // Setup preset with fallback
664
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
665
+ id: '1',
666
+ name: 'Main',
352
667
  command: 'claude',
353
- args: ['--legacy'],
668
+ args: ['--bad-flag'],
669
+ fallbackArgs: ['--good-flag'],
354
670
  });
355
- // Setup spawn mock
356
- vi.mocked(spawn).mockReturnValue(mockPty);
357
- // Create session using legacy method
358
- await sessionManager.createSession('/test/worktree');
359
- // Verify legacy method still works
360
- expect(spawn).toHaveBeenCalledWith('claude', ['--legacy'], expect.any(Object));
671
+ // First spawn attempt - will exit with code 1
672
+ const firstMockPty = new MockPty();
673
+ // Second spawn attempt - succeeds
674
+ const secondMockPty = new MockPty();
675
+ vi.mocked(spawn)
676
+ .mockReturnValueOnce(firstMockPty)
677
+ .mockReturnValueOnce(secondMockPty);
678
+ const session = await sessionManager.createSessionWithDevcontainer('/test/worktree', {
679
+ upCommand: 'devcontainer up --workspace-folder .',
680
+ execCommand: 'devcontainer exec --workspace-folder .',
681
+ });
682
+ // Verify initial spawn
683
+ expect(spawn).toHaveBeenCalledTimes(1);
684
+ expect(spawn).toHaveBeenCalledWith('devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude', '--bad-flag'], expect.objectContaining({ cwd: '/test/worktree' }));
685
+ // Simulate exit with code 1 on first attempt
686
+ firstMockPty.emit('exit', { exitCode: 1 });
687
+ // Wait for fallback to occur
688
+ await new Promise(resolve => setTimeout(resolve, 50));
689
+ // Verify fallback spawn was called
690
+ expect(spawn).toHaveBeenCalledTimes(2);
691
+ expect(spawn).toHaveBeenNthCalledWith(2, 'devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude', '--good-flag'], expect.objectContaining({ cwd: '/test/worktree' }));
692
+ // Verify session process was replaced
693
+ expect(session.process).toBe(secondMockPty);
694
+ expect(session.isPrimaryCommand).toBe(false);
361
695
  });
362
696
  });
363
697
  });