ccmanager 2.8.0 → 2.9.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.
Files changed (82) 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/components/Session.js +11 -6
  26. package/dist/hooks/useGitStatus.d.ts +11 -0
  27. package/dist/hooks/useGitStatus.js +70 -12
  28. package/dist/hooks/useGitStatus.test.js +30 -23
  29. package/dist/services/configurationManager.d.ts +75 -0
  30. package/dist/services/configurationManager.effect.test.d.ts +1 -0
  31. package/dist/services/configurationManager.effect.test.js +407 -0
  32. package/dist/services/configurationManager.js +246 -0
  33. package/dist/services/globalSessionOrchestrator.test.js +0 -8
  34. package/dist/services/projectManager.d.ts +98 -2
  35. package/dist/services/projectManager.js +228 -59
  36. package/dist/services/projectManager.test.js +242 -2
  37. package/dist/services/sessionManager.d.ts +44 -2
  38. package/dist/services/sessionManager.effect.test.d.ts +1 -0
  39. package/dist/services/sessionManager.effect.test.js +321 -0
  40. package/dist/services/sessionManager.js +216 -65
  41. package/dist/services/sessionManager.statePersistence.test.js +18 -9
  42. package/dist/services/sessionManager.test.js +40 -36
  43. package/dist/services/shortcutManager.d.ts +2 -0
  44. package/dist/services/shortcutManager.js +53 -0
  45. package/dist/services/shortcutManager.test.d.ts +1 -0
  46. package/dist/services/shortcutManager.test.js +30 -0
  47. package/dist/services/worktreeService.d.ts +356 -26
  48. package/dist/services/worktreeService.js +793 -353
  49. package/dist/services/worktreeService.test.js +294 -313
  50. package/dist/types/errors.d.ts +74 -0
  51. package/dist/types/errors.js +31 -0
  52. package/dist/types/errors.test.d.ts +1 -0
  53. package/dist/types/errors.test.js +201 -0
  54. package/dist/types/index.d.ts +5 -17
  55. package/dist/utils/claudeDir.d.ts +58 -6
  56. package/dist/utils/claudeDir.js +103 -8
  57. package/dist/utils/claudeDir.test.d.ts +1 -0
  58. package/dist/utils/claudeDir.test.js +108 -0
  59. package/dist/utils/concurrencyLimit.d.ts +5 -0
  60. package/dist/utils/concurrencyLimit.js +11 -0
  61. package/dist/utils/concurrencyLimit.test.js +40 -1
  62. package/dist/utils/gitStatus.d.ts +36 -8
  63. package/dist/utils/gitStatus.js +170 -88
  64. package/dist/utils/gitStatus.test.js +12 -9
  65. package/dist/utils/hookExecutor.d.ts +41 -6
  66. package/dist/utils/hookExecutor.js +75 -32
  67. package/dist/utils/hookExecutor.test.js +73 -20
  68. package/dist/utils/terminalCapabilities.d.ts +18 -0
  69. package/dist/utils/terminalCapabilities.js +81 -0
  70. package/dist/utils/terminalCapabilities.test.d.ts +1 -0
  71. package/dist/utils/terminalCapabilities.test.js +104 -0
  72. package/dist/utils/testHelpers.d.ts +106 -0
  73. package/dist/utils/testHelpers.js +153 -0
  74. package/dist/utils/testHelpers.test.d.ts +1 -0
  75. package/dist/utils/testHelpers.test.js +114 -0
  76. package/dist/utils/worktreeConfig.d.ts +77 -2
  77. package/dist/utils/worktreeConfig.js +156 -16
  78. package/dist/utils/worktreeConfig.test.d.ts +1 -0
  79. package/dist/utils/worktreeConfig.test.js +39 -0
  80. package/package.json +4 -4
  81. package/dist/integration-tests/devcontainer.integration.test.js +0 -101
  82. /package/dist/{integration-tests/devcontainer.integration.test.d.ts → components/App.test.d.ts} +0 -0
@@ -1,12 +1,66 @@
1
- import React, { useState } from 'react';
1
+ import React, { useState, useEffect } from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
3
  import SelectInput from 'ink-select-input';
4
+ import { Effect } from 'effect';
4
5
  import { shortcutManager } from '../services/shortcutManager.js';
6
+ import { configurationManager } from '../services/configurationManager.js';
7
+ /**
8
+ * Format error using TaggedError discrimination
9
+ * Pattern matches on _tag for type-safe error display
10
+ */
11
+ const formatError = (error) => {
12
+ switch (error._tag) {
13
+ case 'FileSystemError':
14
+ return `File ${error.operation} failed for ${error.path}: ${error.cause}`;
15
+ case 'ConfigError':
16
+ return `Configuration error (${error.reason}): ${error.details}`;
17
+ case 'ValidationError':
18
+ return `Validation failed for ${error.field}: ${error.constraint}`;
19
+ case 'GitError':
20
+ return `Git command failed: ${error.command} (exit ${error.exitCode})\n${error.stderr}`;
21
+ case 'ProcessError':
22
+ return `Process error: ${error.message}`;
23
+ }
24
+ };
5
25
  const ConfigureShortcuts = ({ onComplete, }) => {
6
26
  const [step, setStep] = useState('menu');
7
27
  const [shortcuts, setShortcuts] = useState(shortcutManager.getShortcuts());
8
28
  const [editingShortcut, setEditingShortcut] = useState(null);
9
29
  const [error, setError] = useState(null);
30
+ const [isLoading, setIsLoading] = useState(true);
31
+ // Load configuration using Effect on component mount
32
+ useEffect(() => {
33
+ let cancelled = false;
34
+ const loadConfig = async () => {
35
+ const result = await Effect.runPromise(Effect.match(configurationManager.loadConfigEffect(), {
36
+ onFailure: (err) => ({
37
+ type: 'error',
38
+ error: err,
39
+ }),
40
+ onSuccess: config => ({ type: 'success', data: config }),
41
+ }));
42
+ if (!cancelled) {
43
+ if (result.type === 'error') {
44
+ // Display error using TaggedError discrimination
45
+ const errorMsg = formatError(result.error);
46
+ setError(errorMsg);
47
+ }
48
+ else if (result.data.shortcuts) {
49
+ setShortcuts(result.data.shortcuts);
50
+ }
51
+ setIsLoading(false);
52
+ }
53
+ };
54
+ loadConfig().catch(err => {
55
+ if (!cancelled) {
56
+ setError(`Unexpected error loading config: ${String(err)}`);
57
+ setIsLoading(false);
58
+ }
59
+ });
60
+ return () => {
61
+ cancelled = true;
62
+ };
63
+ }, []);
10
64
  const getShortcutDisplayFromState = (key) => {
11
65
  const shortcut = shortcuts[key];
12
66
  if (!shortcut)
@@ -93,13 +147,28 @@ const ConfigureShortcuts = ({ onComplete, }) => {
93
147
  return;
94
148
  }
95
149
  if (item.value === 'save') {
96
- const success = shortcutManager.saveShortcuts(shortcuts);
97
- if (success) {
98
- onComplete();
99
- }
100
- else {
101
- setError('Failed to save shortcuts');
102
- }
150
+ // Save shortcuts using Effect-based method
151
+ const saveConfig = async () => {
152
+ const result = await Effect.runPromise(Effect.match(configurationManager.setShortcutsEffect(shortcuts), {
153
+ onFailure: (err) => ({
154
+ type: 'error',
155
+ error: err,
156
+ }),
157
+ onSuccess: () => ({ type: 'success' }),
158
+ }));
159
+ if (result.type === 'error') {
160
+ // Display error using TaggedError discrimination
161
+ const errorMsg = formatError(result.error);
162
+ setError(errorMsg);
163
+ }
164
+ else {
165
+ // Success - call onComplete
166
+ onComplete();
167
+ }
168
+ };
169
+ saveConfig().catch(err => {
170
+ setError(`Unexpected error saving shortcuts: ${String(err)}`);
171
+ });
103
172
  return;
104
173
  }
105
174
  if (item.value === 'exit') {
@@ -111,6 +180,11 @@ const ConfigureShortcuts = ({ onComplete, }) => {
111
180
  setStep('capturing');
112
181
  setError(null);
113
182
  };
183
+ // Show loading indicator while loading config
184
+ if (isLoading) {
185
+ return (React.createElement(Box, { flexDirection: "column" },
186
+ React.createElement(Text, null, "Loading configuration...")));
187
+ }
114
188
  if (step === 'capturing') {
115
189
  return (React.createElement(Box, { flexDirection: "column" },
116
190
  React.createElement(Text, { bold: true, color: "green" },
@@ -1,6 +1,7 @@
1
1
  import React, { useState, useEffect } from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
3
  import SelectInput from 'ink-select-input';
4
+ import { Effect } from 'effect';
4
5
  import { WorktreeService } from '../services/worktreeService.js';
5
6
  import DeleteConfirmation from './DeleteConfirmation.js';
6
7
  import { shortcutManager } from '../services/shortcutManager.js';
@@ -9,12 +10,32 @@ const DeleteWorktree = ({ onComplete, onCancel, }) => {
9
10
  const [selectedIndices, setSelectedIndices] = useState(new Set());
10
11
  const [confirmMode, setConfirmMode] = useState(false);
11
12
  const [focusedIndex, setFocusedIndex] = useState(0);
13
+ const [error, setError] = useState(null);
14
+ const [isLoading, setIsLoading] = useState(true);
12
15
  useEffect(() => {
13
- const worktreeService = new WorktreeService();
14
- const allWorktrees = worktreeService.getWorktrees();
15
- // Filter out main worktree - we shouldn't delete it
16
- const deletableWorktrees = allWorktrees.filter(wt => !wt.isMainWorktree);
17
- setWorktrees(deletableWorktrees);
16
+ let cancelled = false;
17
+ const loadWorktrees = async () => {
18
+ const worktreeService = new WorktreeService();
19
+ try {
20
+ const allWorktrees = await Effect.runPromise(worktreeService.getWorktreesEffect());
21
+ if (!cancelled) {
22
+ // Filter out main worktree - we shouldn't delete it
23
+ const deletableWorktrees = allWorktrees.filter(wt => !wt.isMainWorktree);
24
+ setWorktrees(deletableWorktrees);
25
+ setIsLoading(false);
26
+ }
27
+ }
28
+ catch (err) {
29
+ if (!cancelled) {
30
+ setError(err instanceof Error ? err.message : String(err));
31
+ setIsLoading(false);
32
+ }
33
+ }
34
+ };
35
+ loadWorktrees();
36
+ return () => {
37
+ cancelled = true;
38
+ };
18
39
  }, []);
19
40
  // Create menu items from worktrees
20
41
  const menuItems = worktrees.map((worktree, index) => {
@@ -58,6 +79,19 @@ const DeleteWorktree = ({ onComplete, onCancel, }) => {
58
79
  onCancel();
59
80
  }
60
81
  });
82
+ if (isLoading) {
83
+ return (React.createElement(Box, { flexDirection: "column" },
84
+ React.createElement(Text, { color: "cyan" }, "Loading worktrees...")));
85
+ }
86
+ if (error) {
87
+ return (React.createElement(Box, { flexDirection: "column" },
88
+ React.createElement(Text, { color: "red" }, "Error loading worktrees:"),
89
+ React.createElement(Text, { color: "red" }, error),
90
+ React.createElement(Text, { dimColor: true },
91
+ "Press ",
92
+ shortcutManager.getShortcutDisplay('cancel'),
93
+ " to return to menu")));
94
+ }
61
95
  if (worktrees.length === 0) {
62
96
  return (React.createElement(Box, { flexDirection: "column" },
63
97
  React.createElement(Text, { color: "yellow" }, "No worktrees available to delete."),
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,128 @@
1
+ import React from 'react';
2
+ import { render } from 'ink-testing-library';
3
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
4
+ import { Effect } from 'effect';
5
+ import DeleteWorktree from './DeleteWorktree.js';
6
+ import { WorktreeService } from '../services/worktreeService.js';
7
+ import { GitError } from '../types/errors.js';
8
+ vi.mock('../services/worktreeService.js');
9
+ vi.mock('../services/shortcutManager.js', () => ({
10
+ shortcutManager: {
11
+ matchesShortcut: vi.fn(),
12
+ getShortcutDisplay: vi.fn(() => 'Esc'),
13
+ },
14
+ }));
15
+ // Mock stdin to avoid useInput errors
16
+ vi.mock('ink', async () => {
17
+ const actual = await vi.importActual('ink');
18
+ return {
19
+ ...actual,
20
+ useInput: vi.fn(),
21
+ };
22
+ });
23
+ // Mock SelectInput to render items as simple text
24
+ vi.mock('ink-select-input', async () => {
25
+ const React = await vi.importActual('react');
26
+ const { Text, Box } = await vi.importActual('ink');
27
+ return {
28
+ default: ({ items }) => {
29
+ return React.createElement(Box, { flexDirection: 'column' }, items.map((item, index) => React.createElement(Text, { key: index }, item.label)));
30
+ },
31
+ };
32
+ });
33
+ describe('DeleteWorktree - Effect Integration', () => {
34
+ beforeEach(() => {
35
+ vi.clearAllMocks();
36
+ });
37
+ it('should load worktrees using Effect-based method', async () => {
38
+ // GIVEN: Mock worktrees returned by Effect
39
+ const mockWorktrees = [
40
+ {
41
+ path: '/test/worktree1',
42
+ branch: 'feature-1',
43
+ isMainWorktree: false,
44
+ hasSession: false,
45
+ },
46
+ {
47
+ path: '/test/worktree2',
48
+ branch: 'feature-2',
49
+ isMainWorktree: false,
50
+ hasSession: false,
51
+ },
52
+ ];
53
+ const mockEffect = Effect.succeed(mockWorktrees);
54
+ const mockGetWorktreesEffect = vi.fn(() => mockEffect);
55
+ vi.mocked(WorktreeService).mockImplementation(() => ({
56
+ getWorktreesEffect: mockGetWorktreesEffect,
57
+ }));
58
+ const onComplete = vi.fn();
59
+ const onCancel = vi.fn();
60
+ // WHEN: Component is rendered
61
+ const { lastFrame } = render(React.createElement(DeleteWorktree, { onComplete: onComplete, onCancel: onCancel }));
62
+ // Wait for Effect to execute
63
+ await new Promise(resolve => setTimeout(resolve, 50));
64
+ // THEN: Effect method should be called
65
+ expect(mockGetWorktreesEffect).toHaveBeenCalled();
66
+ // AND: Worktrees should be displayed
67
+ const output = lastFrame();
68
+ expect(output).toContain('feature-1');
69
+ expect(output).toContain('feature-2');
70
+ });
71
+ it('should handle GitError from getWorktreesEffect gracefully', async () => {
72
+ // GIVEN: Effect that fails with GitError
73
+ const mockError = new GitError({
74
+ command: 'git worktree list --porcelain',
75
+ exitCode: 128,
76
+ stderr: 'not a git repository',
77
+ });
78
+ const mockEffect = Effect.fail(mockError);
79
+ const mockGetWorktreesEffect = vi.fn(() => mockEffect);
80
+ vi.mocked(WorktreeService).mockImplementation(() => ({
81
+ getWorktreesEffect: mockGetWorktreesEffect,
82
+ }));
83
+ const onComplete = vi.fn();
84
+ const onCancel = vi.fn();
85
+ // WHEN: Component is rendered
86
+ const { lastFrame } = render(React.createElement(DeleteWorktree, { onComplete: onComplete, onCancel: onCancel }));
87
+ // Wait for Effect to execute
88
+ await new Promise(resolve => setTimeout(resolve, 50));
89
+ // THEN: Error should be displayed
90
+ const output = lastFrame();
91
+ const hasError = output?.includes('error') ||
92
+ output?.includes('Error') ||
93
+ output?.includes('not a git repository');
94
+ expect(hasError).toBe(true);
95
+ });
96
+ it('should filter out main worktree from deletable list', async () => {
97
+ // GIVEN: Mock worktrees including main worktree
98
+ const mockWorktrees = [
99
+ {
100
+ path: '/test/main',
101
+ branch: 'main',
102
+ isMainWorktree: true,
103
+ hasSession: false,
104
+ },
105
+ {
106
+ path: '/test/feature',
107
+ branch: 'feature-1',
108
+ isMainWorktree: false,
109
+ hasSession: false,
110
+ },
111
+ ];
112
+ const mockEffect = Effect.succeed(mockWorktrees);
113
+ const mockGetWorktreesEffect = vi.fn(() => mockEffect);
114
+ vi.mocked(WorktreeService).mockImplementation(() => ({
115
+ getWorktreesEffect: mockGetWorktreesEffect,
116
+ }));
117
+ const onComplete = vi.fn();
118
+ const onCancel = vi.fn();
119
+ // WHEN: Component is rendered
120
+ const { lastFrame } = render(React.createElement(DeleteWorktree, { onComplete: onComplete, onCancel: onCancel }));
121
+ // Wait for Effect to execute
122
+ await new Promise(resolve => setTimeout(resolve, 50));
123
+ // THEN: Only non-main worktree should be shown
124
+ const output = lastFrame();
125
+ expect(output).toContain('feature-1');
126
+ expect(output).not.toContain('main');
127
+ });
128
+ });
@@ -0,0 +1,8 @@
1
+ import React from 'react';
2
+ interface LoadingSpinnerProps {
3
+ message: string;
4
+ spinnerType?: 'dots' | 'line';
5
+ color?: 'cyan' | 'yellow' | 'green';
6
+ }
7
+ declare const LoadingSpinner: React.FC<LoadingSpinnerProps>;
8
+ export default LoadingSpinner;
@@ -0,0 +1,37 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { supportsUnicode } from '../utils/terminalCapabilities.js';
4
+ const LoadingSpinner = ({ message, spinnerType, color = 'cyan', }) => {
5
+ const [frameIndex, setFrameIndex] = useState(0);
6
+ // Unicode frames for "dots" spinner type
7
+ const unicodeFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
8
+ // ASCII frames for "line" spinner type (fallback for limited terminal support)
9
+ const asciiFrames = ['-', '\\', '|', '/'];
10
+ // Determine effective spinner type:
11
+ // 1. If explicit spinnerType is provided, use it
12
+ // 2. Otherwise, detect terminal capabilities automatically
13
+ const effectiveSpinnerType = spinnerType !== undefined
14
+ ? spinnerType
15
+ : supportsUnicode()
16
+ ? 'dots'
17
+ : 'line';
18
+ // Select frames based on effective spinner type
19
+ const frames = effectiveSpinnerType === 'line' ? asciiFrames : unicodeFrames;
20
+ useEffect(() => {
21
+ // Set up animation interval - update frame every 120ms
22
+ const interval = setInterval(() => {
23
+ setFrameIndex(prevIndex => (prevIndex + 1) % frames.length);
24
+ }, 120);
25
+ // Cleanup interval on component unmount to prevent memory leaks
26
+ return () => {
27
+ clearInterval(interval);
28
+ };
29
+ }, [frames.length]);
30
+ const currentFrame = frames[frameIndex];
31
+ return (React.createElement(Box, { flexDirection: "row" },
32
+ React.createElement(Text, { color: color },
33
+ currentFrame,
34
+ " "),
35
+ React.createElement(Text, null, message)));
36
+ };
37
+ export default LoadingSpinner;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,187 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import React from 'react';
3
+ import { render } from 'ink-testing-library';
4
+ import LoadingSpinner from './LoadingSpinner.js';
5
+ describe('LoadingSpinner', () => {
6
+ // Store original environment variables for restoration
7
+ const originalEnv = { ...process.env };
8
+ const originalPlatform = process.platform;
9
+ afterEach(() => {
10
+ // Restore original environment
11
+ process.env = { ...originalEnv };
12
+ // Restore platform if it was modified
13
+ Object.defineProperty(process, 'platform', {
14
+ value: originalPlatform,
15
+ writable: true,
16
+ configurable: true,
17
+ });
18
+ vi.restoreAllMocks();
19
+ });
20
+ describe('Core LoadingSpinner component with Unicode animation', () => {
21
+ it('should render with default props and display message with cyan spinner', () => {
22
+ const { lastFrame } = render(React.createElement(LoadingSpinner, { message: "Loading..." }));
23
+ const output = lastFrame();
24
+ expect(output).toContain('Loading...');
25
+ // Should contain one of the Unicode spinner frames
26
+ expect(output).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/);
27
+ });
28
+ it('should render with custom color prop (yellow for devcontainer operations)', () => {
29
+ const { lastFrame } = render(React.createElement(LoadingSpinner, { message: "Starting devcontainer...", color: "yellow" }));
30
+ const output = lastFrame();
31
+ expect(output).toContain('Starting devcontainer...');
32
+ // Verify spinner is present
33
+ expect(output).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/);
34
+ });
35
+ it('should use ASCII fallback frames when spinnerType is "line"', () => {
36
+ const { lastFrame } = render(React.createElement(LoadingSpinner, { message: "Processing...", spinnerType: "line" }));
37
+ const output = lastFrame();
38
+ expect(output).toContain('Processing...');
39
+ // Should contain one of the ASCII spinner frames
40
+ expect(output).toMatch(/[-\\|/]/);
41
+ });
42
+ it('should set up animation interval with 120ms timing', () => {
43
+ const setIntervalSpy = vi.spyOn(global, 'setInterval');
44
+ render(React.createElement(LoadingSpinner, { message: "Loading..." }));
45
+ // Verify setInterval was called with 120ms timing
46
+ expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 120);
47
+ });
48
+ it('should cleanup interval on component unmount to prevent memory leaks', () => {
49
+ const { unmount } = render(React.createElement(LoadingSpinner, { message: "Loading..." }));
50
+ // Verify unmount completes without errors (cleanup function runs properly)
51
+ expect(() => unmount()).not.toThrow();
52
+ });
53
+ it('should preserve message text', () => {
54
+ const message = 'Creating session...';
55
+ const { lastFrame } = render(React.createElement(LoadingSpinner, { message: message }));
56
+ // Check message is rendered
57
+ expect(lastFrame()).toContain(message);
58
+ });
59
+ it('should render in flexDirection="row" layout with spinner and message', () => {
60
+ const { lastFrame } = render(React.createElement(LoadingSpinner, { message: "Test message" }));
61
+ const output = lastFrame();
62
+ // Verify both spinner and message are present
63
+ // ANSI color codes may be present between spinner and message
64
+ expect(output).toContain('Test message');
65
+ expect(output).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/);
66
+ });
67
+ });
68
+ describe('Component variations and edge cases', () => {
69
+ it('should accept "green" color option', () => {
70
+ const { lastFrame } = render(React.createElement(LoadingSpinner, { message: "Success loading...", color: "green" }));
71
+ const output = lastFrame();
72
+ expect(output).toContain('Success loading...');
73
+ expect(output).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/);
74
+ });
75
+ it('should render Unicode frames correctly', () => {
76
+ const { lastFrame } = render(React.createElement(LoadingSpinner, { message: "Test" }));
77
+ const output = lastFrame();
78
+ // Should contain one of the Unicode frames
79
+ expect(output).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/);
80
+ });
81
+ it('should render ASCII frames when using "line" spinner type', () => {
82
+ const { lastFrame } = render(React.createElement(LoadingSpinner, { message: "Test", spinnerType: "line" }));
83
+ const output = lastFrame();
84
+ // Should contain one of the ASCII frames
85
+ expect(output).toMatch(/[-\\|/]/);
86
+ });
87
+ it('should handle empty message string', () => {
88
+ const { lastFrame } = render(React.createElement(LoadingSpinner, { message: "" }));
89
+ const output = lastFrame();
90
+ // Should still render spinner even with empty message
91
+ expect(output).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/);
92
+ });
93
+ it('should handle long message text without breaking layout', () => {
94
+ const longMessage = 'This is a very long loading message that might wrap on narrow terminals';
95
+ const { lastFrame } = render(React.createElement(LoadingSpinner, { message: longMessage }));
96
+ const output = lastFrame();
97
+ expect(output).toContain(longMessage);
98
+ expect(output).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/);
99
+ });
100
+ it('should use cyan as default color', () => {
101
+ const { lastFrame } = render(React.createElement(LoadingSpinner, { message: "Test" }));
102
+ const output = lastFrame();
103
+ // Just verify it renders successfully with default color
104
+ expect(output).toContain('Test');
105
+ expect(output).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/);
106
+ });
107
+ it('should use dots as default spinner type', () => {
108
+ const { lastFrame } = render(React.createElement(LoadingSpinner, { message: "Test" }));
109
+ const output = lastFrame();
110
+ // Default should be Unicode dots, not ASCII line
111
+ expect(output).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/);
112
+ });
113
+ });
114
+ describe('Terminal compatibility detection', () => {
115
+ it('should automatically detect Unicode support and use Unicode frames', () => {
116
+ // Set up environment for Unicode support
117
+ process.env['TERM'] = 'xterm-256color';
118
+ const { lastFrame } = render(React.createElement(LoadingSpinner, { message: "Loading..." }));
119
+ const output = lastFrame();
120
+ // Should use Unicode frames when terminal supports it
121
+ expect(output).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/);
122
+ expect(output).toContain('Loading...');
123
+ });
124
+ it('should automatically fallback to ASCII when terminal does not support Unicode', () => {
125
+ // Set up environment without Unicode support
126
+ process.env['TERM'] = 'dumb';
127
+ delete process.env['LANG'];
128
+ delete process.env['LC_ALL'];
129
+ const { lastFrame } = render(React.createElement(LoadingSpinner, { message: "Loading..." }));
130
+ const output = lastFrame();
131
+ // Should use ASCII frames when terminal doesn't support Unicode
132
+ expect(output).toMatch(/[-\\|/]/);
133
+ expect(output).toContain('Loading...');
134
+ });
135
+ it('should respect explicit spinnerType prop over automatic detection', () => {
136
+ // Even with Unicode support, explicit "line" should use ASCII
137
+ process.env['TERM'] = 'xterm-256color';
138
+ const { lastFrame } = render(React.createElement(LoadingSpinner, { message: "Loading...", spinnerType: "line" }));
139
+ const output = lastFrame();
140
+ // Explicit spinnerType should override detection
141
+ expect(output).toMatch(/[-\\|/]/);
142
+ });
143
+ it('should detect Unicode support on Windows with Windows Terminal', () => {
144
+ // Mock Windows platform
145
+ Object.defineProperty(process, 'platform', {
146
+ value: 'win32',
147
+ writable: true,
148
+ configurable: true,
149
+ });
150
+ process.env['WT_SESSION'] = 'some-session-id';
151
+ const { lastFrame } = render(React.createElement(LoadingSpinner, { message: "Loading..." }));
152
+ const output = lastFrame();
153
+ // Should use Unicode on Windows Terminal
154
+ expect(output).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/);
155
+ });
156
+ it('should fallback to ASCII on Windows without Windows Terminal', () => {
157
+ // Mock Windows platform without Windows Terminal
158
+ Object.defineProperty(process, 'platform', {
159
+ value: 'win32',
160
+ writable: true,
161
+ configurable: true,
162
+ });
163
+ delete process.env['WT_SESSION'];
164
+ delete process.env['TERM'];
165
+ delete process.env['LANG'];
166
+ const { lastFrame } = render(React.createElement(LoadingSpinner, { message: "Loading..." }));
167
+ const output = lastFrame();
168
+ // Should use ASCII on Windows without WT
169
+ expect(output).toMatch(/[-\\|/]/);
170
+ });
171
+ it('should detect Unicode support from LANG environment variable', () => {
172
+ delete process.env['TERM'];
173
+ process.env['LANG'] = 'en_US.UTF-8';
174
+ const { lastFrame } = render(React.createElement(LoadingSpinner, { message: "Loading..." }));
175
+ const output = lastFrame();
176
+ // Should use Unicode when LANG indicates UTF-8
177
+ expect(output).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/);
178
+ });
179
+ it('should detect Unicode support with appropriate frame rate', () => {
180
+ process.env['TERM'] = 'xterm-256color';
181
+ const setIntervalSpy = vi.spyOn(global, 'setInterval');
182
+ render(React.createElement(LoadingSpinner, { message: "Loading..." }));
183
+ // Verify frame rate is 120ms regardless of detection
184
+ expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 120);
185
+ });
186
+ });
187
+ });