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,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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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,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
|
+
});
|