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.
- package/README.md +30 -0
- package/dist/cli.d.ts +8 -1
- package/dist/cli.js +31 -4
- package/dist/components/App.d.ts +5 -1
- package/dist/components/App.js +16 -5
- package/dist/components/Menu.d.ts +2 -0
- package/dist/components/Menu.js +13 -2
- package/dist/integration-tests/devcontainer.integration.test.d.ts +1 -0
- package/dist/integration-tests/devcontainer.integration.test.js +101 -0
- package/dist/services/sessionManager.d.ts +12 -2
- package/dist/services/sessionManager.js +90 -71
- package/dist/services/sessionManager.test.js +474 -140
- package/dist/types/index.d.ts +5 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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('
|
|
83
|
-
it('should
|
|
84
|
-
// Setup mock
|
|
85
|
-
vi.mocked(configurationManager.
|
|
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.
|
|
92
|
-
// Verify spawn was called with
|
|
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
|
|
103
|
-
// Setup mock
|
|
104
|
-
vi.mocked(configurationManager.
|
|
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', '--
|
|
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.
|
|
112
|
-
// Verify
|
|
113
|
-
expect(
|
|
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
|
-
|
|
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
|
|
120
|
-
vi.mocked(configurationManager.
|
|
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.
|
|
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
|
|
179
|
-
vi.mocked(configurationManager.
|
|
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.
|
|
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
|
|
196
|
-
// Setup mock
|
|
197
|
-
vi.mocked(configurationManager.
|
|
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
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
211
|
-
// Setup mock
|
|
212
|
-
vi.mocked(configurationManager.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
319
|
+
// Mock spawn to throw error
|
|
218
320
|
vi.mocked(spawn).mockImplementation(() => {
|
|
219
|
-
throw new Error('
|
|
321
|
+
throw new Error('spawn failed');
|
|
220
322
|
});
|
|
221
|
-
// Expect
|
|
222
|
-
await expect(sessionManager.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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('
|
|
263
|
-
|
|
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: ['--
|
|
395
|
+
args: ['--resume'],
|
|
270
396
|
});
|
|
271
397
|
// Setup spawn mock
|
|
272
398
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
273
|
-
// Create session with
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
309
|
-
// Setup
|
|
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
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
325
|
-
//
|
|
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: [
|
|
331
|
-
fallbackArgs: ['--good-flag'],
|
|
498
|
+
args: [],
|
|
332
499
|
});
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
vi.mocked(
|
|
336
|
-
|
|
337
|
-
if (
|
|
338
|
-
|
|
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
|
|
511
|
+
return {};
|
|
341
512
|
});
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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(
|
|
347
|
-
expect
|
|
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
|
|
350
|
-
|
|
351
|
-
|
|
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: ['--
|
|
668
|
+
args: ['--bad-flag'],
|
|
669
|
+
fallbackArgs: ['--good-flag'],
|
|
354
670
|
});
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
});
|