ccmanager 2.7.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.
Files changed (77) hide show
  1. package/dist/cli.test.js +13 -2
  2. package/dist/components/App.js +125 -50
  3. package/dist/components/App.test.js +270 -0
  4. package/dist/components/ConfigureShortcuts.js +82 -8
  5. package/dist/components/DeleteWorktree.js +39 -5
  6. package/dist/components/DeleteWorktree.test.d.ts +1 -0
  7. package/dist/components/DeleteWorktree.test.js +128 -0
  8. package/dist/components/LoadingSpinner.d.ts +8 -0
  9. package/dist/components/LoadingSpinner.js +37 -0
  10. package/dist/components/LoadingSpinner.test.d.ts +1 -0
  11. package/dist/components/LoadingSpinner.test.js +187 -0
  12. package/dist/components/Menu.js +64 -16
  13. package/dist/components/Menu.recent-projects.test.js +32 -11
  14. package/dist/components/Menu.test.js +136 -4
  15. package/dist/components/MergeWorktree.js +79 -18
  16. package/dist/components/MergeWorktree.test.d.ts +1 -0
  17. package/dist/components/MergeWorktree.test.js +227 -0
  18. package/dist/components/NewWorktree.js +88 -9
  19. package/dist/components/NewWorktree.test.d.ts +1 -0
  20. package/dist/components/NewWorktree.test.js +244 -0
  21. package/dist/components/ProjectList.js +44 -13
  22. package/dist/components/ProjectList.recent-projects.test.js +8 -3
  23. package/dist/components/ProjectList.test.js +105 -8
  24. package/dist/components/RemoteBranchSelector.test.js +3 -1
  25. package/dist/hooks/useGitStatus.d.ts +11 -0
  26. package/dist/hooks/useGitStatus.js +70 -12
  27. package/dist/hooks/useGitStatus.test.js +30 -23
  28. package/dist/services/configurationManager.d.ts +75 -0
  29. package/dist/services/configurationManager.effect.test.d.ts +1 -0
  30. package/dist/services/configurationManager.effect.test.js +407 -0
  31. package/dist/services/configurationManager.js +246 -0
  32. package/dist/services/globalSessionOrchestrator.test.js +0 -8
  33. package/dist/services/projectManager.d.ts +98 -2
  34. package/dist/services/projectManager.js +228 -59
  35. package/dist/services/projectManager.test.js +242 -2
  36. package/dist/services/sessionManager.d.ts +44 -2
  37. package/dist/services/sessionManager.effect.test.d.ts +1 -0
  38. package/dist/services/sessionManager.effect.test.js +321 -0
  39. package/dist/services/sessionManager.js +216 -65
  40. package/dist/services/sessionManager.statePersistence.test.js +18 -9
  41. package/dist/services/sessionManager.test.js +40 -36
  42. package/dist/services/worktreeService.d.ts +356 -26
  43. package/dist/services/worktreeService.js +793 -353
  44. package/dist/services/worktreeService.test.js +294 -313
  45. package/dist/types/errors.d.ts +74 -0
  46. package/dist/types/errors.js +31 -0
  47. package/dist/types/errors.test.d.ts +1 -0
  48. package/dist/types/errors.test.js +201 -0
  49. package/dist/types/index.d.ts +5 -17
  50. package/dist/utils/claudeDir.d.ts +58 -6
  51. package/dist/utils/claudeDir.js +103 -8
  52. package/dist/utils/claudeDir.test.d.ts +1 -0
  53. package/dist/utils/claudeDir.test.js +108 -0
  54. package/dist/utils/concurrencyLimit.d.ts +5 -0
  55. package/dist/utils/concurrencyLimit.js +11 -0
  56. package/dist/utils/concurrencyLimit.test.js +40 -1
  57. package/dist/utils/gitStatus.d.ts +36 -8
  58. package/dist/utils/gitStatus.js +170 -88
  59. package/dist/utils/gitStatus.test.js +12 -9
  60. package/dist/utils/hookExecutor.d.ts +41 -6
  61. package/dist/utils/hookExecutor.js +75 -32
  62. package/dist/utils/hookExecutor.test.js +73 -20
  63. package/dist/utils/terminalCapabilities.d.ts +18 -0
  64. package/dist/utils/terminalCapabilities.js +81 -0
  65. package/dist/utils/terminalCapabilities.test.d.ts +1 -0
  66. package/dist/utils/terminalCapabilities.test.js +104 -0
  67. package/dist/utils/testHelpers.d.ts +106 -0
  68. package/dist/utils/testHelpers.js +153 -0
  69. package/dist/utils/testHelpers.test.d.ts +1 -0
  70. package/dist/utils/testHelpers.test.js +114 -0
  71. package/dist/utils/worktreeConfig.d.ts +77 -2
  72. package/dist/utils/worktreeConfig.js +156 -16
  73. package/dist/utils/worktreeConfig.test.d.ts +1 -0
  74. package/dist/utils/worktreeConfig.test.js +39 -0
  75. package/package.json +4 -4
  76. package/dist/integration-tests/devcontainer.integration.test.js +0 -101
  77. /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.refreshProjects();
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 expect(projectManager.refreshProjects()).rejects.toThrow(`Projects directory does not exist: ${mockProjectsDir}`);
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
- createSessionWithPreset(worktreePath: string, presetId?: string): Promise<Session>;
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
- createSessionWithDevcontainer(worktreePath: string, devcontainerConfig: DevcontainerConfig, presetId?: string): Promise<Session>;
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
+ });