ccmanager 2.8.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.test.js +13 -2
- package/dist/components/App.js +125 -50
- package/dist/components/App.test.js +270 -0
- package/dist/components/ConfigureShortcuts.js +82 -8
- package/dist/components/DeleteWorktree.js +39 -5
- package/dist/components/DeleteWorktree.test.d.ts +1 -0
- package/dist/components/DeleteWorktree.test.js +128 -0
- package/dist/components/LoadingSpinner.d.ts +8 -0
- package/dist/components/LoadingSpinner.js +37 -0
- package/dist/components/LoadingSpinner.test.d.ts +1 -0
- package/dist/components/LoadingSpinner.test.js +187 -0
- package/dist/components/Menu.js +64 -16
- package/dist/components/Menu.recent-projects.test.js +32 -11
- package/dist/components/Menu.test.js +136 -4
- package/dist/components/MergeWorktree.js +79 -18
- package/dist/components/MergeWorktree.test.d.ts +1 -0
- package/dist/components/MergeWorktree.test.js +227 -0
- package/dist/components/NewWorktree.js +88 -9
- package/dist/components/NewWorktree.test.d.ts +1 -0
- package/dist/components/NewWorktree.test.js +244 -0
- package/dist/components/ProjectList.js +44 -13
- package/dist/components/ProjectList.recent-projects.test.js +8 -3
- package/dist/components/ProjectList.test.js +105 -8
- package/dist/components/RemoteBranchSelector.test.js +3 -1
- package/dist/hooks/useGitStatus.d.ts +11 -0
- package/dist/hooks/useGitStatus.js +70 -12
- package/dist/hooks/useGitStatus.test.js +30 -23
- package/dist/services/configurationManager.d.ts +75 -0
- package/dist/services/configurationManager.effect.test.d.ts +1 -0
- package/dist/services/configurationManager.effect.test.js +407 -0
- package/dist/services/configurationManager.js +246 -0
- package/dist/services/globalSessionOrchestrator.test.js +0 -8
- package/dist/services/projectManager.d.ts +98 -2
- package/dist/services/projectManager.js +228 -59
- package/dist/services/projectManager.test.js +242 -2
- package/dist/services/sessionManager.d.ts +44 -2
- package/dist/services/sessionManager.effect.test.d.ts +1 -0
- package/dist/services/sessionManager.effect.test.js +321 -0
- package/dist/services/sessionManager.js +216 -65
- package/dist/services/sessionManager.statePersistence.test.js +18 -9
- package/dist/services/sessionManager.test.js +40 -36
- package/dist/services/worktreeService.d.ts +356 -26
- package/dist/services/worktreeService.js +793 -353
- package/dist/services/worktreeService.test.js +294 -313
- package/dist/types/errors.d.ts +74 -0
- package/dist/types/errors.js +31 -0
- package/dist/types/errors.test.d.ts +1 -0
- package/dist/types/errors.test.js +201 -0
- package/dist/types/index.d.ts +5 -17
- package/dist/utils/claudeDir.d.ts +58 -6
- package/dist/utils/claudeDir.js +103 -8
- package/dist/utils/claudeDir.test.d.ts +1 -0
- package/dist/utils/claudeDir.test.js +108 -0
- package/dist/utils/concurrencyLimit.d.ts +5 -0
- package/dist/utils/concurrencyLimit.js +11 -0
- package/dist/utils/concurrencyLimit.test.js +40 -1
- package/dist/utils/gitStatus.d.ts +36 -8
- package/dist/utils/gitStatus.js +170 -88
- package/dist/utils/gitStatus.test.js +12 -9
- package/dist/utils/hookExecutor.d.ts +41 -6
- package/dist/utils/hookExecutor.js +75 -32
- package/dist/utils/hookExecutor.test.js +73 -20
- package/dist/utils/terminalCapabilities.d.ts +18 -0
- package/dist/utils/terminalCapabilities.js +81 -0
- package/dist/utils/terminalCapabilities.test.d.ts +1 -0
- package/dist/utils/terminalCapabilities.test.js +104 -0
- package/dist/utils/testHelpers.d.ts +106 -0
- package/dist/utils/testHelpers.js +153 -0
- package/dist/utils/testHelpers.test.d.ts +1 -0
- package/dist/utils/testHelpers.test.js +114 -0
- package/dist/utils/worktreeConfig.d.ts +77 -2
- package/dist/utils/worktreeConfig.js +156 -16
- package/dist/utils/worktreeConfig.test.d.ts +1 -0
- package/dist/utils/worktreeConfig.test.js +39 -0
- package/package.json +4 -4
- package/dist/integration-tests/devcontainer.integration.test.js +0 -101
- /package/dist/{integration-tests/devcontainer.integration.test.d.ts → components/App.test.d.ts} +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
2
|
import * as fs from 'fs';
|
|
3
3
|
import * as path from 'path';
|
|
4
|
+
import { Effect, Either } from 'effect';
|
|
4
5
|
// Mock modules before any other imports that might use them
|
|
5
6
|
vi.mock('fs');
|
|
6
7
|
vi.mock('os', () => ({
|
|
@@ -10,6 +11,7 @@ vi.mock('os', () => ({
|
|
|
10
11
|
// Now import modules that depend on the mocked modules
|
|
11
12
|
import { ProjectManager } from './projectManager.js';
|
|
12
13
|
import { ENV_VARS } from '../constants/env.js';
|
|
14
|
+
import { FileSystemError, ConfigError } from '../types/errors.js';
|
|
13
15
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
14
16
|
const mockFs = fs;
|
|
15
17
|
describe('ProjectManager', () => {
|
|
@@ -78,7 +80,7 @@ describe('ProjectManager', () => {
|
|
|
78
80
|
throw new Error('Not found');
|
|
79
81
|
}),
|
|
80
82
|
};
|
|
81
|
-
await projectManager.
|
|
83
|
+
await Effect.runPromise(projectManager.refreshProjectsEffect());
|
|
82
84
|
expect(projectManager.projects).toHaveLength(2);
|
|
83
85
|
expect(projectManager.projects[0]).toMatchObject({
|
|
84
86
|
name: 'project1',
|
|
@@ -95,7 +97,15 @@ describe('ProjectManager', () => {
|
|
|
95
97
|
mockFs.promises = {
|
|
96
98
|
access: vi.fn().mockRejectedValue({ code: 'ENOENT' }),
|
|
97
99
|
};
|
|
98
|
-
await
|
|
100
|
+
const result = await Effect.runPromise(Effect.either(projectManager.refreshProjectsEffect()));
|
|
101
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
102
|
+
if (Either.isLeft(result)) {
|
|
103
|
+
const error = result.left;
|
|
104
|
+
expect(error._tag).toBe('FileSystemError');
|
|
105
|
+
if (error._tag === 'FileSystemError') {
|
|
106
|
+
expect(error.cause).toContain(`Projects directory does not exist: ${mockProjectsDir}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
99
109
|
});
|
|
100
110
|
});
|
|
101
111
|
describe('recent projects', () => {
|
|
@@ -338,4 +348,234 @@ describe('ProjectManager', () => {
|
|
|
338
348
|
expect(isValid).toBe(false);
|
|
339
349
|
});
|
|
340
350
|
});
|
|
351
|
+
describe('Effect-based API', () => {
|
|
352
|
+
beforeEach(() => {
|
|
353
|
+
process.env[ENV_VARS.MULTI_PROJECT_ROOT] = mockProjectsDir;
|
|
354
|
+
projectManager = new ProjectManager();
|
|
355
|
+
});
|
|
356
|
+
describe('discoverProjectsEffect', () => {
|
|
357
|
+
it('should return Effect with projects on success', async () => {
|
|
358
|
+
// Mock file system for project discovery
|
|
359
|
+
mockFs.promises = {
|
|
360
|
+
access: vi.fn().mockResolvedValue(undefined),
|
|
361
|
+
readdir: vi.fn().mockImplementation((dir) => {
|
|
362
|
+
if (dir === mockProjectsDir) {
|
|
363
|
+
return Promise.resolve([
|
|
364
|
+
{ name: 'project1', isDirectory: () => true },
|
|
365
|
+
{ name: 'project2', isDirectory: () => true },
|
|
366
|
+
]);
|
|
367
|
+
}
|
|
368
|
+
return Promise.resolve([]);
|
|
369
|
+
}),
|
|
370
|
+
stat: vi.fn().mockImplementation((path) => {
|
|
371
|
+
if (path.endsWith('.git')) {
|
|
372
|
+
return Promise.resolve({
|
|
373
|
+
isDirectory: () => true,
|
|
374
|
+
isFile: () => false,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
throw new Error('Not found');
|
|
378
|
+
}),
|
|
379
|
+
};
|
|
380
|
+
const effect = projectManager.discoverProjectsEffect(mockProjectsDir);
|
|
381
|
+
const projects = await Effect.runPromise(effect);
|
|
382
|
+
expect(projects).toHaveLength(2);
|
|
383
|
+
expect(projects[0]).toMatchObject({
|
|
384
|
+
name: 'project1',
|
|
385
|
+
isValid: true,
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
it('should return Effect with FileSystemError when directory does not exist', async () => {
|
|
389
|
+
mockFs.promises = {
|
|
390
|
+
access: vi.fn().mockRejectedValue({ code: 'ENOENT' }),
|
|
391
|
+
};
|
|
392
|
+
const effect = projectManager.discoverProjectsEffect(mockProjectsDir);
|
|
393
|
+
const result = await Effect.runPromise(Effect.either(effect));
|
|
394
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
395
|
+
if (Either.isLeft(result)) {
|
|
396
|
+
const error = result.left;
|
|
397
|
+
expect(error._tag).toBe('FileSystemError');
|
|
398
|
+
expect(error).toBeInstanceOf(FileSystemError);
|
|
399
|
+
expect(error.operation).toBe('read');
|
|
400
|
+
expect(error.path).toBe(mockProjectsDir);
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
it('should use Effect.all for parallel project scanning', async () => {
|
|
404
|
+
mockFs.promises = {
|
|
405
|
+
access: vi.fn().mockResolvedValue(undefined),
|
|
406
|
+
readdir: vi.fn().mockImplementation(() => {
|
|
407
|
+
return Promise.resolve([
|
|
408
|
+
{ name: 'project1', isDirectory: () => true },
|
|
409
|
+
{ name: 'project2', isDirectory: () => true },
|
|
410
|
+
{ name: 'project3', isDirectory: () => true },
|
|
411
|
+
]);
|
|
412
|
+
}),
|
|
413
|
+
stat: vi.fn().mockImplementation((path) => {
|
|
414
|
+
if (path.endsWith('.git')) {
|
|
415
|
+
return Promise.resolve({
|
|
416
|
+
isDirectory: () => true,
|
|
417
|
+
isFile: () => false,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
throw new Error('Not found');
|
|
421
|
+
}),
|
|
422
|
+
};
|
|
423
|
+
const effect = projectManager.discoverProjectsEffect(mockProjectsDir);
|
|
424
|
+
const projects = await Effect.runPromise(effect);
|
|
425
|
+
expect(projects).toHaveLength(3);
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
describe('loadRecentProjectsEffect', () => {
|
|
429
|
+
it('should return Effect with recent projects on success', async () => {
|
|
430
|
+
const mockRecentProjects = [
|
|
431
|
+
{
|
|
432
|
+
path: '/path/to/project1',
|
|
433
|
+
name: 'project1',
|
|
434
|
+
lastAccessed: Date.now(),
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
path: '/path/to/project2',
|
|
438
|
+
name: 'project2',
|
|
439
|
+
lastAccessed: Date.now() - 1000,
|
|
440
|
+
},
|
|
441
|
+
];
|
|
442
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(mockRecentProjects));
|
|
443
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
444
|
+
// Re-create to load recent projects
|
|
445
|
+
projectManager = new ProjectManager();
|
|
446
|
+
const effect = projectManager.loadRecentProjectsEffect();
|
|
447
|
+
const projects = await Effect.runPromise(effect);
|
|
448
|
+
expect(projects).toHaveLength(2);
|
|
449
|
+
expect(projects[0]?.name).toBe('project1');
|
|
450
|
+
});
|
|
451
|
+
it('should return Effect with FileSystemError when file read fails', async () => {
|
|
452
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
453
|
+
mockFs.readFileSync.mockImplementation(() => {
|
|
454
|
+
throw new Error('Permission denied');
|
|
455
|
+
});
|
|
456
|
+
projectManager = new ProjectManager();
|
|
457
|
+
const effect = projectManager.loadRecentProjectsEffect();
|
|
458
|
+
const result = await Effect.runPromise(Effect.either(effect));
|
|
459
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
460
|
+
if (Either.isLeft(result)) {
|
|
461
|
+
const error = result.left;
|
|
462
|
+
expect(error._tag).toBe('FileSystemError');
|
|
463
|
+
expect(error).toBeInstanceOf(FileSystemError);
|
|
464
|
+
if (error._tag === 'FileSystemError') {
|
|
465
|
+
expect(error.operation).toBe('read');
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
it('should return Effect with ConfigError when JSON parse fails', async () => {
|
|
470
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
471
|
+
mockFs.readFileSync.mockReturnValue('invalid json{');
|
|
472
|
+
projectManager = new ProjectManager();
|
|
473
|
+
const effect = projectManager.loadRecentProjectsEffect();
|
|
474
|
+
const result = await Effect.runPromise(Effect.either(effect));
|
|
475
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
476
|
+
if (Either.isLeft(result)) {
|
|
477
|
+
const error = result.left;
|
|
478
|
+
expect(error._tag).toBe('ConfigError');
|
|
479
|
+
expect(error).toBeInstanceOf(ConfigError);
|
|
480
|
+
if (error._tag === 'ConfigError') {
|
|
481
|
+
expect(error.reason).toBe('parse');
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
it('should use Effect.catchAll for fallback to empty cache on error', async () => {
|
|
486
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
487
|
+
projectManager = new ProjectManager();
|
|
488
|
+
const effect = projectManager.loadRecentProjectsEffect();
|
|
489
|
+
const projects = await Effect.runPromise(Effect.catchAll(effect, () => Effect.succeed([])));
|
|
490
|
+
expect(projects).toEqual([]);
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
describe('saveRecentProjectsEffect', () => {
|
|
494
|
+
it('should return Effect with void on success', async () => {
|
|
495
|
+
mockFs.writeFileSync.mockImplementation(() => { });
|
|
496
|
+
projectManager = new ProjectManager();
|
|
497
|
+
const projects = [
|
|
498
|
+
{
|
|
499
|
+
path: '/path/to/project1',
|
|
500
|
+
name: 'project1',
|
|
501
|
+
lastAccessed: Date.now(),
|
|
502
|
+
},
|
|
503
|
+
];
|
|
504
|
+
const effect = projectManager.saveRecentProjectsEffect(projects);
|
|
505
|
+
await Effect.runPromise(effect);
|
|
506
|
+
expect(mockFs.writeFileSync).toHaveBeenCalledWith(mockRecentProjectsPath, expect.any(String));
|
|
507
|
+
});
|
|
508
|
+
it('should return Effect with FileSystemError when write fails', async () => {
|
|
509
|
+
mockFs.writeFileSync.mockImplementation(() => {
|
|
510
|
+
throw new Error('Disk full');
|
|
511
|
+
});
|
|
512
|
+
projectManager = new ProjectManager();
|
|
513
|
+
const projects = [
|
|
514
|
+
{
|
|
515
|
+
path: '/path/to/project1',
|
|
516
|
+
name: 'project1',
|
|
517
|
+
lastAccessed: Date.now(),
|
|
518
|
+
},
|
|
519
|
+
];
|
|
520
|
+
const effect = projectManager.saveRecentProjectsEffect(projects);
|
|
521
|
+
const result = await Effect.runPromise(Effect.either(effect));
|
|
522
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
523
|
+
if (Either.isLeft(result)) {
|
|
524
|
+
const error = result.left;
|
|
525
|
+
expect(error._tag).toBe('FileSystemError');
|
|
526
|
+
expect(error).toBeInstanceOf(FileSystemError);
|
|
527
|
+
expect(error.operation).toBe('write');
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
describe('refreshProjectsEffect', () => {
|
|
532
|
+
it('should return Effect with void on success', async () => {
|
|
533
|
+
mockFs.promises = {
|
|
534
|
+
access: vi.fn().mockResolvedValue(undefined),
|
|
535
|
+
readdir: vi.fn().mockImplementation(() => {
|
|
536
|
+
return Promise.resolve([
|
|
537
|
+
{ name: 'project1', isDirectory: () => true },
|
|
538
|
+
]);
|
|
539
|
+
}),
|
|
540
|
+
stat: vi.fn().mockImplementation((path) => {
|
|
541
|
+
if (path.endsWith('.git')) {
|
|
542
|
+
return Promise.resolve({
|
|
543
|
+
isDirectory: () => true,
|
|
544
|
+
isFile: () => false,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
throw new Error('Not found');
|
|
548
|
+
}),
|
|
549
|
+
};
|
|
550
|
+
const effect = projectManager.refreshProjectsEffect();
|
|
551
|
+
await Effect.runPromise(effect);
|
|
552
|
+
expect(projectManager.projects).toHaveLength(1);
|
|
553
|
+
});
|
|
554
|
+
it('should return Effect with FileSystemError when projects directory not configured', async () => {
|
|
555
|
+
// Create without multi-project root
|
|
556
|
+
delete process.env[ENV_VARS.MULTI_PROJECT_ROOT];
|
|
557
|
+
projectManager = new ProjectManager();
|
|
558
|
+
const effect = projectManager.refreshProjectsEffect();
|
|
559
|
+
const result = await Effect.runPromise(Effect.either(effect));
|
|
560
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
561
|
+
if (Either.isLeft(result)) {
|
|
562
|
+
const error = result.left;
|
|
563
|
+
expect(error._tag).toBe('FileSystemError');
|
|
564
|
+
expect(error).toBeInstanceOf(FileSystemError);
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
it('should return Effect with FileSystemError or GitError on discovery failure', async () => {
|
|
568
|
+
mockFs.promises = {
|
|
569
|
+
access: vi.fn().mockRejectedValue({ code: 'ENOENT' }),
|
|
570
|
+
};
|
|
571
|
+
const effect = projectManager.refreshProjectsEffect();
|
|
572
|
+
const result = await Effect.runPromise(Effect.either(effect));
|
|
573
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
574
|
+
if (Either.isLeft(result)) {
|
|
575
|
+
const error = result.left;
|
|
576
|
+
expect(['FileSystemError', 'GitError']).toContain(error._tag);
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
});
|
|
341
581
|
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Session, SessionManager as ISessionManager, SessionState, DevcontainerConfig } from '../types/index.js';
|
|
2
2
|
import { EventEmitter } from 'events';
|
|
3
|
+
import { Effect } from 'effect';
|
|
4
|
+
import { ProcessError, ConfigError } from '../types/errors.js';
|
|
3
5
|
export interface SessionCounts {
|
|
4
6
|
idle: number;
|
|
5
7
|
busy: number;
|
|
@@ -16,7 +18,25 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
16
18
|
private createSessionId;
|
|
17
19
|
private createTerminal;
|
|
18
20
|
private createSessionInternal;
|
|
19
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Create session with command preset using Effect-based error handling
|
|
23
|
+
*
|
|
24
|
+
* @param {string} worktreePath - Path to the worktree
|
|
25
|
+
* @param {string} [presetId] - Optional preset ID, uses default if not provided
|
|
26
|
+
* @returns {Effect.Effect<Session, ProcessError | ConfigError, never>} Effect that may fail with ProcessError (spawn failure) or ConfigError (invalid preset)
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```typescript
|
|
30
|
+
* // Use Effect.match for type-safe error handling
|
|
31
|
+
* const result = await Effect.runPromise(
|
|
32
|
+
* Effect.match(effect, {
|
|
33
|
+
* onFailure: (error) => ({ type: 'error', message: error.message }),
|
|
34
|
+
* onSuccess: (session) => ({ type: 'success', data: session })
|
|
35
|
+
* })
|
|
36
|
+
* );
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
createSessionWithPresetEffect(worktreePath: string, presetId?: string): Effect.Effect<Session, ProcessError | ConfigError, never>;
|
|
20
40
|
private setupDataHandler;
|
|
21
41
|
/**
|
|
22
42
|
* Sets up exit handler for the session process.
|
|
@@ -31,8 +51,30 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
31
51
|
getSession(worktreePath: string): Session | undefined;
|
|
32
52
|
setSessionActive(worktreePath: string, active: boolean): void;
|
|
33
53
|
destroySession(worktreePath: string): void;
|
|
54
|
+
/**
|
|
55
|
+
* Terminate session and cleanup resources using Effect-based error handling
|
|
56
|
+
*
|
|
57
|
+
* @param {string} worktreePath - Path to the worktree
|
|
58
|
+
* @returns {Effect.Effect<void, ProcessError, never>} Effect that may fail with ProcessError if session does not exist or cleanup fails
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```typescript
|
|
62
|
+
* // Terminate session with error handling
|
|
63
|
+
* const result = await Effect.runPromise(
|
|
64
|
+
* Effect.match(effect, {
|
|
65
|
+
* onFailure: (error) => ({ type: 'error', message: error.message }),
|
|
66
|
+
* onSuccess: () => ({ type: 'success' })
|
|
67
|
+
* })
|
|
68
|
+
* );
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
terminateSessionEffect(worktreePath: string): Effect.Effect<void, ProcessError, never>;
|
|
34
72
|
getAllSessions(): Session[];
|
|
35
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Create session with devcontainer integration using Effect-based error handling
|
|
75
|
+
* @returns Effect that may fail with ProcessError (container/spawn failure) or ConfigError (invalid preset)
|
|
76
|
+
*/
|
|
77
|
+
createSessionWithDevcontainerEffect(worktreePath: string, devcontainerConfig: DevcontainerConfig, presetId?: string): Effect.Effect<Session, ProcessError | ConfigError, never>;
|
|
36
78
|
destroy(): void;
|
|
37
79
|
static getSessionCounts(sessions: Session[]): SessionCounts;
|
|
38
80
|
static formatSessionCounts(counts: SessionCounts): string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { Effect, Either } from 'effect';
|
|
3
|
+
import { spawn } from 'node-pty';
|
|
4
|
+
import { EventEmitter } from 'events';
|
|
5
|
+
// Mock node-pty
|
|
6
|
+
vi.mock('node-pty', () => ({
|
|
7
|
+
spawn: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
// Mock child_process
|
|
10
|
+
vi.mock('child_process', () => ({
|
|
11
|
+
exec: vi.fn(),
|
|
12
|
+
execFile: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
// Mock configuration manager
|
|
15
|
+
vi.mock('./configurationManager.js', () => ({
|
|
16
|
+
configurationManager: {
|
|
17
|
+
getDefaultPreset: vi.fn(),
|
|
18
|
+
getPresetById: vi.fn(),
|
|
19
|
+
},
|
|
20
|
+
}));
|
|
21
|
+
// Mock Terminal
|
|
22
|
+
vi.mock('@xterm/headless', () => ({
|
|
23
|
+
default: {
|
|
24
|
+
Terminal: vi.fn().mockImplementation(() => ({
|
|
25
|
+
buffer: {
|
|
26
|
+
active: {
|
|
27
|
+
length: 0,
|
|
28
|
+
getLine: vi.fn(),
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
write: vi.fn(),
|
|
32
|
+
})),
|
|
33
|
+
},
|
|
34
|
+
}));
|
|
35
|
+
// Create a mock IPty class
|
|
36
|
+
class MockPty extends EventEmitter {
|
|
37
|
+
constructor() {
|
|
38
|
+
super(...arguments);
|
|
39
|
+
Object.defineProperty(this, "kill", {
|
|
40
|
+
enumerable: true,
|
|
41
|
+
configurable: true,
|
|
42
|
+
writable: true,
|
|
43
|
+
value: vi.fn()
|
|
44
|
+
});
|
|
45
|
+
Object.defineProperty(this, "resize", {
|
|
46
|
+
enumerable: true,
|
|
47
|
+
configurable: true,
|
|
48
|
+
writable: true,
|
|
49
|
+
value: vi.fn()
|
|
50
|
+
});
|
|
51
|
+
Object.defineProperty(this, "write", {
|
|
52
|
+
enumerable: true,
|
|
53
|
+
configurable: true,
|
|
54
|
+
writable: true,
|
|
55
|
+
value: vi.fn()
|
|
56
|
+
});
|
|
57
|
+
Object.defineProperty(this, "onData", {
|
|
58
|
+
enumerable: true,
|
|
59
|
+
configurable: true,
|
|
60
|
+
writable: true,
|
|
61
|
+
value: vi.fn((callback) => {
|
|
62
|
+
this.on('data', callback);
|
|
63
|
+
})
|
|
64
|
+
});
|
|
65
|
+
Object.defineProperty(this, "onExit", {
|
|
66
|
+
enumerable: true,
|
|
67
|
+
configurable: true,
|
|
68
|
+
writable: true,
|
|
69
|
+
value: vi.fn((callback) => {
|
|
70
|
+
this.on('exit', callback);
|
|
71
|
+
})
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
describe('SessionManager Effect-based Operations', () => {
|
|
76
|
+
let sessionManager;
|
|
77
|
+
let mockPty;
|
|
78
|
+
let SessionManager;
|
|
79
|
+
let configurationManager;
|
|
80
|
+
beforeEach(async () => {
|
|
81
|
+
vi.clearAllMocks();
|
|
82
|
+
// Dynamically import after mocks are set up
|
|
83
|
+
const sessionManagerModule = await import('./sessionManager.js');
|
|
84
|
+
const configManagerModule = await import('./configurationManager.js');
|
|
85
|
+
SessionManager = sessionManagerModule.SessionManager;
|
|
86
|
+
configurationManager = configManagerModule.configurationManager;
|
|
87
|
+
sessionManager = new SessionManager();
|
|
88
|
+
mockPty = new MockPty();
|
|
89
|
+
});
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
sessionManager.destroy();
|
|
92
|
+
});
|
|
93
|
+
describe('createSessionWithPreset returning Effect', () => {
|
|
94
|
+
it('should return Effect that succeeds with Session', async () => {
|
|
95
|
+
// Setup mock preset
|
|
96
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
97
|
+
id: '1',
|
|
98
|
+
name: 'Main',
|
|
99
|
+
command: 'claude',
|
|
100
|
+
args: ['--preset-arg'],
|
|
101
|
+
});
|
|
102
|
+
// Setup spawn mock
|
|
103
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
104
|
+
// Create session with preset - should return Effect
|
|
105
|
+
const effect = sessionManager.createSessionWithPresetEffect('/test/worktree');
|
|
106
|
+
// Execute the Effect and verify it succeeds with a Session
|
|
107
|
+
const session = await Effect.runPromise(effect);
|
|
108
|
+
expect(session).toBeDefined();
|
|
109
|
+
expect(session.worktreePath).toBe('/test/worktree');
|
|
110
|
+
expect(session.state).toBe('busy');
|
|
111
|
+
});
|
|
112
|
+
it('should return Effect that fails with ConfigError when preset not found', async () => {
|
|
113
|
+
// Setup mocks - both return null/undefined
|
|
114
|
+
vi.mocked(configurationManager.getPresetById).mockReturnValue(undefined);
|
|
115
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue(undefined);
|
|
116
|
+
// Create session with non-existent preset - should return Effect
|
|
117
|
+
const effect = sessionManager.createSessionWithPresetEffect('/test/worktree', 'invalid-preset');
|
|
118
|
+
// Execute the Effect and expect it to fail with ConfigError
|
|
119
|
+
const result = await Effect.runPromise(Effect.either(effect));
|
|
120
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
121
|
+
if (Either.isLeft(result)) {
|
|
122
|
+
expect(result.left._tag).toBe('ConfigError');
|
|
123
|
+
if (result.left._tag === 'ConfigError') {
|
|
124
|
+
expect(result.left.reason).toBe('validation');
|
|
125
|
+
expect(result.left.details).toContain('preset');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
it('should return Effect that fails with ProcessError when spawn fails', async () => {
|
|
130
|
+
// Setup mock preset
|
|
131
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
132
|
+
id: '1',
|
|
133
|
+
name: 'Main',
|
|
134
|
+
command: 'invalid-command',
|
|
135
|
+
args: ['--arg'],
|
|
136
|
+
});
|
|
137
|
+
// Mock spawn to throw error
|
|
138
|
+
vi.mocked(spawn).mockImplementation(() => {
|
|
139
|
+
throw new Error('spawn ENOENT: command not found');
|
|
140
|
+
});
|
|
141
|
+
// Create session - should return Effect
|
|
142
|
+
const effect = sessionManager.createSessionWithPresetEffect('/test/worktree');
|
|
143
|
+
// Execute the Effect and expect it to fail with ProcessError
|
|
144
|
+
const result = await Effect.runPromise(Effect.either(effect));
|
|
145
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
146
|
+
if (Either.isLeft(result)) {
|
|
147
|
+
expect(result.left._tag).toBe('ProcessError');
|
|
148
|
+
if (result.left._tag === 'ProcessError') {
|
|
149
|
+
expect(result.left.command).toContain('createSessionWithPreset');
|
|
150
|
+
expect(result.left.message).toContain('spawn');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
it('should return existing session without creating new Effect', async () => {
|
|
155
|
+
// Setup mock preset
|
|
156
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
157
|
+
id: '1',
|
|
158
|
+
name: 'Main',
|
|
159
|
+
command: 'claude',
|
|
160
|
+
});
|
|
161
|
+
// Setup spawn mock
|
|
162
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
163
|
+
// Create session twice
|
|
164
|
+
const effect1 = sessionManager.createSessionWithPresetEffect('/test/worktree');
|
|
165
|
+
const session1 = await Effect.runPromise(effect1);
|
|
166
|
+
const effect2 = sessionManager.createSessionWithPresetEffect('/test/worktree');
|
|
167
|
+
const session2 = await Effect.runPromise(effect2);
|
|
168
|
+
// Should return the same session
|
|
169
|
+
expect(session1).toBe(session2);
|
|
170
|
+
// Spawn should only be called once
|
|
171
|
+
expect(spawn).toHaveBeenCalledTimes(1);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
describe('createSessionWithDevcontainer returning Effect', () => {
|
|
175
|
+
it('should return Effect that succeeds with Session', async () => {
|
|
176
|
+
// Setup mock preset
|
|
177
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
178
|
+
id: '1',
|
|
179
|
+
name: 'Main',
|
|
180
|
+
command: 'claude',
|
|
181
|
+
args: ['--resume'],
|
|
182
|
+
});
|
|
183
|
+
// Setup spawn mock
|
|
184
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
185
|
+
// Mock exec to succeed
|
|
186
|
+
const { exec } = await import('child_process');
|
|
187
|
+
const mockExec = vi.mocked(exec);
|
|
188
|
+
mockExec.mockImplementation((cmd, options, callback) => {
|
|
189
|
+
if (typeof options === 'function') {
|
|
190
|
+
callback = options;
|
|
191
|
+
}
|
|
192
|
+
if (callback && typeof callback === 'function') {
|
|
193
|
+
callback(null, 'Container started', '');
|
|
194
|
+
}
|
|
195
|
+
return {};
|
|
196
|
+
});
|
|
197
|
+
const devcontainerConfig = {
|
|
198
|
+
upCommand: 'devcontainer up --workspace-folder .',
|
|
199
|
+
execCommand: 'devcontainer exec --workspace-folder .',
|
|
200
|
+
};
|
|
201
|
+
// Create session with devcontainer - should return Effect
|
|
202
|
+
const effect = sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig);
|
|
203
|
+
// Execute the Effect and verify it succeeds with a Session
|
|
204
|
+
const session = await Effect.runPromise(effect);
|
|
205
|
+
expect(session).toBeDefined();
|
|
206
|
+
expect(session.worktreePath).toBe('/test/worktree');
|
|
207
|
+
expect(session.devcontainerConfig).toEqual(devcontainerConfig);
|
|
208
|
+
});
|
|
209
|
+
it('should return Effect that fails with ProcessError when devcontainer up fails', async () => {
|
|
210
|
+
// Mock exec to fail
|
|
211
|
+
const { exec } = await import('child_process');
|
|
212
|
+
const mockExec = vi.mocked(exec);
|
|
213
|
+
mockExec.mockImplementation((cmd, options, callback) => {
|
|
214
|
+
if (typeof options === 'function') {
|
|
215
|
+
callback = options;
|
|
216
|
+
}
|
|
217
|
+
if (callback && typeof callback === 'function') {
|
|
218
|
+
callback(new Error('Container failed to start'), '', '');
|
|
219
|
+
}
|
|
220
|
+
return {};
|
|
221
|
+
});
|
|
222
|
+
const devcontainerConfig = {
|
|
223
|
+
upCommand: 'devcontainer up --workspace-folder .',
|
|
224
|
+
execCommand: 'devcontainer exec --workspace-folder .',
|
|
225
|
+
};
|
|
226
|
+
// Create session with devcontainer - should return Effect
|
|
227
|
+
const effect = sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig);
|
|
228
|
+
// Execute the Effect and expect it to fail with ProcessError
|
|
229
|
+
const result = await Effect.runPromise(Effect.either(effect));
|
|
230
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
231
|
+
if (Either.isLeft(result)) {
|
|
232
|
+
expect(result.left._tag).toBe('ProcessError');
|
|
233
|
+
if (result.left._tag === 'ProcessError') {
|
|
234
|
+
expect(result.left.command).toContain('devcontainer up');
|
|
235
|
+
expect(result.left.message).toContain('Container failed');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
it('should return Effect that fails with ConfigError when preset not found', async () => {
|
|
240
|
+
// Setup mocks - both return null/undefined
|
|
241
|
+
vi.mocked(configurationManager.getPresetById).mockReturnValue(undefined);
|
|
242
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue(undefined);
|
|
243
|
+
// Mock exec to succeed (devcontainer up)
|
|
244
|
+
const { exec } = await import('child_process');
|
|
245
|
+
const mockExec = vi.mocked(exec);
|
|
246
|
+
mockExec.mockImplementation((cmd, options, callback) => {
|
|
247
|
+
if (typeof options === 'function') {
|
|
248
|
+
callback = options;
|
|
249
|
+
}
|
|
250
|
+
if (callback && typeof callback === 'function') {
|
|
251
|
+
callback(null, 'Container started', '');
|
|
252
|
+
}
|
|
253
|
+
return {};
|
|
254
|
+
});
|
|
255
|
+
const devcontainerConfig = {
|
|
256
|
+
upCommand: 'devcontainer up',
|
|
257
|
+
execCommand: 'devcontainer exec',
|
|
258
|
+
};
|
|
259
|
+
// Create session with invalid preset
|
|
260
|
+
const effect = sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig, 'invalid-preset');
|
|
261
|
+
// Execute the Effect and expect it to fail with ConfigError
|
|
262
|
+
const result = await Effect.runPromise(Effect.either(effect));
|
|
263
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
264
|
+
if (Either.isLeft(result)) {
|
|
265
|
+
expect(result.left._tag).toBe('ConfigError');
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
describe('terminateSession returning Effect', () => {
|
|
270
|
+
it('should return Effect that succeeds when session exists', async () => {
|
|
271
|
+
// Setup mock preset and create a session first
|
|
272
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
273
|
+
id: '1',
|
|
274
|
+
name: 'Main',
|
|
275
|
+
command: 'claude',
|
|
276
|
+
});
|
|
277
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
278
|
+
// Create session
|
|
279
|
+
await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
280
|
+
// Terminate session - should return Effect
|
|
281
|
+
const effect = sessionManager.terminateSessionEffect('/test/worktree');
|
|
282
|
+
// Execute the Effect and verify it succeeds
|
|
283
|
+
await Effect.runPromise(effect);
|
|
284
|
+
// Verify session was destroyed
|
|
285
|
+
expect(sessionManager.getSession('/test/worktree')).toBeUndefined();
|
|
286
|
+
expect(mockPty.kill).toHaveBeenCalled();
|
|
287
|
+
});
|
|
288
|
+
it('should return Effect that fails with ProcessError when session does not exist', async () => {
|
|
289
|
+
// Terminate non-existent session - should return Effect
|
|
290
|
+
const effect = sessionManager.terminateSessionEffect('/nonexistent/worktree');
|
|
291
|
+
// Execute the Effect and expect it to fail with ProcessError
|
|
292
|
+
const result = await Effect.runPromise(Effect.either(effect));
|
|
293
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
294
|
+
if (Either.isLeft(result)) {
|
|
295
|
+
expect(result.left._tag).toBe('ProcessError');
|
|
296
|
+
expect(result.left.message).toContain('Session not found');
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
it('should return Effect that succeeds even when process kill fails', async () => {
|
|
300
|
+
// Setup mock preset and create a session
|
|
301
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
302
|
+
id: '1',
|
|
303
|
+
name: 'Main',
|
|
304
|
+
command: 'claude',
|
|
305
|
+
});
|
|
306
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
307
|
+
// Create session
|
|
308
|
+
await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
309
|
+
// Mock kill to throw error
|
|
310
|
+
mockPty.kill.mockImplementation(() => {
|
|
311
|
+
throw new Error('Process already terminated');
|
|
312
|
+
});
|
|
313
|
+
// Terminate session - should still succeed
|
|
314
|
+
const effect = sessionManager.terminateSessionEffect('/test/worktree');
|
|
315
|
+
// Should not throw, gracefully handle kill failure
|
|
316
|
+
await Effect.runPromise(effect);
|
|
317
|
+
// Session should still be removed from map
|
|
318
|
+
expect(sessionManager.getSession('/test/worktree')).toBeUndefined();
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
});
|