@akiojin/gwt 2.0.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 (132) hide show
  1. package/README.ja.md +323 -0
  2. package/README.md +347 -0
  3. package/bin/gwt.js +5 -0
  4. package/package.json +125 -0
  5. package/src/claude-history.ts +717 -0
  6. package/src/claude.ts +292 -0
  7. package/src/cli/ui/__tests__/SKIPPED_TESTS.md +119 -0
  8. package/src/cli/ui/__tests__/acceptance/branchList.acceptance.test.tsx.skip +239 -0
  9. package/src/cli/ui/__tests__/acceptance/navigation.acceptance.test.tsx +214 -0
  10. package/src/cli/ui/__tests__/acceptance/realtimeUpdate.acceptance.test.tsx.skip +219 -0
  11. package/src/cli/ui/__tests__/components/App.protected-branch.test.tsx +183 -0
  12. package/src/cli/ui/__tests__/components/App.shortcuts.test.tsx +313 -0
  13. package/src/cli/ui/__tests__/components/App.test.tsx +270 -0
  14. package/src/cli/ui/__tests__/components/common/Confirm.test.tsx +66 -0
  15. package/src/cli/ui/__tests__/components/common/ErrorBoundary.test.tsx +103 -0
  16. package/src/cli/ui/__tests__/components/common/Input.test.tsx +92 -0
  17. package/src/cli/ui/__tests__/components/common/LoadingIndicator.test.tsx +127 -0
  18. package/src/cli/ui/__tests__/components/common/Select.memo.test.tsx +264 -0
  19. package/src/cli/ui/__tests__/components/common/Select.test.tsx +246 -0
  20. package/src/cli/ui/__tests__/components/parts/Footer.test.tsx +62 -0
  21. package/src/cli/ui/__tests__/components/parts/Header.test.tsx +54 -0
  22. package/src/cli/ui/__tests__/components/parts/ScrollableList.test.tsx +68 -0
  23. package/src/cli/ui/__tests__/components/parts/Stats.test.tsx +135 -0
  24. package/src/cli/ui/__tests__/components/screens/AIToolSelectorScreen.test.tsx +153 -0
  25. package/src/cli/ui/__tests__/components/screens/BranchCreatorScreen.test.tsx +215 -0
  26. package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +293 -0
  27. package/src/cli/ui/__tests__/components/screens/ExecutionModeSelectorScreen.test.tsx +161 -0
  28. package/src/cli/ui/__tests__/components/screens/PRCleanupScreen.test.tsx +215 -0
  29. package/src/cli/ui/__tests__/components/screens/SessionSelectorScreen.test.tsx +99 -0
  30. package/src/cli/ui/__tests__/components/screens/WorktreeManagerScreen.test.tsx +127 -0
  31. package/src/cli/ui/__tests__/hooks/useGitData.test.ts.skip +228 -0
  32. package/src/cli/ui/__tests__/hooks/useScreenState.test.ts +146 -0
  33. package/src/cli/ui/__tests__/hooks/useTerminalSize.test.ts +98 -0
  34. package/src/cli/ui/__tests__/integration/branchList.test.tsx.skip +253 -0
  35. package/src/cli/ui/__tests__/integration/edgeCases.test.tsx +306 -0
  36. package/src/cli/ui/__tests__/integration/navigation.test.tsx +405 -0
  37. package/src/cli/ui/__tests__/integration/realtimeUpdate.test.tsx +505 -0
  38. package/src/cli/ui/__tests__/integration/realtimeUpdate.test.tsx.skip +216 -0
  39. package/src/cli/ui/__tests__/performance/branchList.performance.test.tsx +180 -0
  40. package/src/cli/ui/__tests__/performance/useMemoOptimization.test.tsx +237 -0
  41. package/src/cli/ui/__tests__/utils/branchFormatter.test.ts +775 -0
  42. package/src/cli/ui/__tests__/utils/statisticsCalculator.test.ts +243 -0
  43. package/src/cli/ui/components/App.tsx +793 -0
  44. package/src/cli/ui/components/common/Confirm.tsx +40 -0
  45. package/src/cli/ui/components/common/ErrorBoundary.tsx +57 -0
  46. package/src/cli/ui/components/common/Input.tsx +36 -0
  47. package/src/cli/ui/components/common/LoadingIndicator.tsx +95 -0
  48. package/src/cli/ui/components/common/Select.tsx +216 -0
  49. package/src/cli/ui/components/parts/Footer.tsx +41 -0
  50. package/src/cli/ui/components/parts/Header.test.tsx +85 -0
  51. package/src/cli/ui/components/parts/Header.tsx +63 -0
  52. package/src/cli/ui/components/parts/MergeStatusList.tsx +75 -0
  53. package/src/cli/ui/components/parts/ProgressBar.tsx +73 -0
  54. package/src/cli/ui/components/parts/ScrollableList.tsx +24 -0
  55. package/src/cli/ui/components/parts/Stats.tsx +67 -0
  56. package/src/cli/ui/components/screens/AIToolSelectorScreen.tsx +116 -0
  57. package/src/cli/ui/components/screens/BatchMergeProgressScreen.tsx +70 -0
  58. package/src/cli/ui/components/screens/BatchMergeResultScreen.tsx +104 -0
  59. package/src/cli/ui/components/screens/BranchCreatorScreen.tsx +213 -0
  60. package/src/cli/ui/components/screens/BranchListScreen.tsx +299 -0
  61. package/src/cli/ui/components/screens/ExecutionModeSelectorScreen.tsx +149 -0
  62. package/src/cli/ui/components/screens/PRCleanupScreen.tsx +167 -0
  63. package/src/cli/ui/components/screens/SessionSelectorScreen.tsx +100 -0
  64. package/src/cli/ui/components/screens/WorktreeManagerScreen.tsx +117 -0
  65. package/src/cli/ui/hooks/useBatchMerge.ts +96 -0
  66. package/src/cli/ui/hooks/useGitData.ts +157 -0
  67. package/src/cli/ui/hooks/useScreenState.ts +44 -0
  68. package/src/cli/ui/hooks/useTerminalSize.ts +33 -0
  69. package/src/cli/ui/screens/BranchActionSelectorScreen.tsx +102 -0
  70. package/src/cli/ui/screens/__tests__/BranchActionSelectorScreen.test.tsx +151 -0
  71. package/src/cli/ui/types.ts +295 -0
  72. package/src/cli/ui/utils/baseBranch.ts +34 -0
  73. package/src/cli/ui/utils/branchFormatter.ts +222 -0
  74. package/src/cli/ui/utils/statisticsCalculator.ts +44 -0
  75. package/src/codex.ts +139 -0
  76. package/src/config/builtin-tools.ts +44 -0
  77. package/src/config/constants.ts +100 -0
  78. package/src/config/env-history.ts +45 -0
  79. package/src/config/index.ts +204 -0
  80. package/src/config/tools.ts +293 -0
  81. package/src/git.ts +1102 -0
  82. package/src/github.ts +158 -0
  83. package/src/index.test.ts +87 -0
  84. package/src/index.ts +684 -0
  85. package/src/index.ts.backup +1543 -0
  86. package/src/launcher.ts +142 -0
  87. package/src/repositories/git.repository.ts +129 -0
  88. package/src/repositories/github.repository.ts +83 -0
  89. package/src/repositories/worktree.repository.ts +69 -0
  90. package/src/services/BatchMergeService.ts +251 -0
  91. package/src/services/WorktreeOrchestrator.ts +115 -0
  92. package/src/services/__tests__/BatchMergeService.test.ts +518 -0
  93. package/src/services/__tests__/WorktreeOrchestrator.test.ts +258 -0
  94. package/src/services/dependency-installer.ts +199 -0
  95. package/src/services/git.service.ts +113 -0
  96. package/src/services/github.service.ts +61 -0
  97. package/src/services/worktree.service.ts +66 -0
  98. package/src/types/api.ts +241 -0
  99. package/src/types/tools.ts +235 -0
  100. package/src/utils/spinner.ts +54 -0
  101. package/src/utils/terminal.ts +272 -0
  102. package/src/utils.test.ts +43 -0
  103. package/src/utils.ts +60 -0
  104. package/src/web/client/index.html +12 -0
  105. package/src/web/client/src/components/BranchGraph.tsx +231 -0
  106. package/src/web/client/src/components/EnvEditor.tsx +145 -0
  107. package/src/web/client/src/components/Terminal.tsx +137 -0
  108. package/src/web/client/src/hooks/useBranches.ts +41 -0
  109. package/src/web/client/src/hooks/useConfig.ts +31 -0
  110. package/src/web/client/src/hooks/useSessions.ts +59 -0
  111. package/src/web/client/src/hooks/useWorktrees.ts +47 -0
  112. package/src/web/client/src/index.css +834 -0
  113. package/src/web/client/src/lib/api.ts +184 -0
  114. package/src/web/client/src/lib/websocket.ts +174 -0
  115. package/src/web/client/src/main.tsx +29 -0
  116. package/src/web/client/src/pages/BranchDetailPage.tsx +847 -0
  117. package/src/web/client/src/pages/BranchListPage.tsx +264 -0
  118. package/src/web/client/src/pages/ConfigManagementPage.tsx +203 -0
  119. package/src/web/client/src/router.tsx +27 -0
  120. package/src/web/client/vite.config.ts +21 -0
  121. package/src/web/server/env/importer.ts +54 -0
  122. package/src/web/server/index.ts +74 -0
  123. package/src/web/server/pty/manager.ts +189 -0
  124. package/src/web/server/routes/branches.ts +126 -0
  125. package/src/web/server/routes/config.ts +220 -0
  126. package/src/web/server/routes/index.ts +37 -0
  127. package/src/web/server/routes/sessions.ts +130 -0
  128. package/src/web/server/routes/worktrees.ts +108 -0
  129. package/src/web/server/services/branches.ts +368 -0
  130. package/src/web/server/services/worktrees.ts +85 -0
  131. package/src/web/server/websocket/handler.ts +180 -0
  132. package/src/worktree.ts +703 -0
@@ -0,0 +1,246 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ /* eslint-disable no-control-regex */
5
+ import { describe, it, expect, vi } from 'vitest';
6
+ import { render } from 'ink-testing-library';
7
+ import React from 'react';
8
+ import { Select } from '../../../components/common/Select.js';
9
+
10
+ // Helper to wait for async updates
11
+ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
12
+
13
+ interface TestItem {
14
+ label: string;
15
+ value: string;
16
+ }
17
+
18
+ describe('Select', () => {
19
+ const mockItems: TestItem[] = [
20
+ { label: 'Option 1', value: 'opt1' },
21
+ { label: 'Option 2', value: 'opt2' },
22
+ { label: 'Option 3', value: 'opt3' },
23
+ { label: 'Option 4', value: 'opt4' },
24
+ { label: 'Option 5', value: 'opt5' },
25
+ ];
26
+
27
+ describe('Rendering', () => {
28
+ it('should render all items', () => {
29
+ const onSelect = vi.fn();
30
+ const { lastFrame } = render(<Select items={mockItems} onSelect={onSelect} />);
31
+
32
+ expect(lastFrame()).toContain('Option 1');
33
+ expect(lastFrame()).toContain('Option 2');
34
+ expect(lastFrame()).toContain('Option 3');
35
+ });
36
+
37
+ it('should highlight first item by default', () => {
38
+ const onSelect = vi.fn();
39
+ const { lastFrame } = render(<Select items={mockItems} onSelect={onSelect} />);
40
+
41
+ // Cyan color code indicates selected item
42
+ expect(lastFrame()).toContain('›');
43
+ });
44
+
45
+ it('should highlight item at initialIndex', () => {
46
+ const onSelect = vi.fn();
47
+ const { lastFrame } = render(
48
+ <Select items={mockItems} onSelect={onSelect} initialIndex={2} />
49
+ );
50
+
51
+ const output = lastFrame();
52
+ // Should have exactly one selected indicator
53
+ const selectedCount = (output.match(/›/g) || []).length;
54
+ expect(selectedCount).toBe(1);
55
+ });
56
+
57
+ it('should render with empty items array', () => {
58
+ const onSelect = vi.fn();
59
+ const { lastFrame } = render(<Select items={[]} onSelect={onSelect} />);
60
+
61
+ expect(lastFrame()).toBeDefined();
62
+ });
63
+
64
+ it('should respect limit prop for scrolling', () => {
65
+ const onSelect = vi.fn();
66
+ const { lastFrame } = render(<Select items={mockItems} onSelect={onSelect} limit={3} />);
67
+
68
+ const output = lastFrame();
69
+ // Should only show 3 items when limit is 3
70
+ expect(output).toContain('Option 1');
71
+ expect(output).toContain('Option 2');
72
+ expect(output).toContain('Option 3');
73
+ // Option 4 and 5 should not be visible initially
74
+ expect(output).not.toContain('Option 4');
75
+ expect(output).not.toContain('Option 5');
76
+ });
77
+ });
78
+
79
+ describe('Navigation - No Looping (Critical Feature)', () => {
80
+ it('should implement boundary checks to prevent looping', () => {
81
+ // Unit test: verify the logic used in implementation
82
+ // Math.max(0, current - 1) - prevents going below 0
83
+ // Math.min(items.length - 1, current + 1) - prevents going above max
84
+
85
+ const onSelect = vi.fn();
86
+ const { lastFrame } = render(<Select items={mockItems} onSelect={onSelect} />);
87
+
88
+ // Verify component renders (implementation uses Math.max/min for boundaries)
89
+ expect(lastFrame()).toBeDefined();
90
+ expect(lastFrame()).toContain('Option 1');
91
+ });
92
+
93
+ it('should start at first item by default', () => {
94
+ const onSelect = vi.fn();
95
+ const { lastFrame } = render(<Select items={mockItems} onSelect={onSelect} />);
96
+
97
+ const output = lastFrame();
98
+ // First line should have the selection indicator
99
+ const lines = output.split('\n').filter((l) => l.trim());
100
+ expect(lines[0]).toContain('›');
101
+ expect(lines[0]).toContain('Option 1');
102
+ });
103
+
104
+ it('should respect initialIndex without looping', () => {
105
+ const onSelect = vi.fn();
106
+ const { lastFrame } = render(
107
+ <Select items={mockItems} onSelect={onSelect} initialIndex={4} />
108
+ );
109
+
110
+ // Should start at last item (index 4)
111
+ const output = lastFrame();
112
+ expect(output).toContain('Option 5');
113
+ expect(output).toContain('›');
114
+ });
115
+
116
+ it('should handle initialIndex at 0', () => {
117
+ const onSelect = vi.fn();
118
+ const { lastFrame } = render(
119
+ <Select items={mockItems} onSelect={onSelect} initialIndex={0} />
120
+ );
121
+
122
+ const output = lastFrame();
123
+ const lines = output.split('\n').filter((l) => l.trim());
124
+ expect(lines[0]).toContain('Option 1');
125
+ });
126
+ });
127
+
128
+ describe('Navigation - Input Handling', () => {
129
+ it('should use useInput hook for keyboard handling', () => {
130
+ // Verify component accepts keyboard input by checking it renders properly
131
+ const onSelect = vi.fn();
132
+ const { stdin } = render(<Select items={mockItems} onSelect={onSelect} />);
133
+
134
+ // Component should handle input without errors
135
+ expect(() => stdin.write('\u001B[B')).not.toThrow();
136
+ expect(() => stdin.write('\u001B[A')).not.toThrow();
137
+ expect(() => stdin.write('j')).not.toThrow();
138
+ expect(() => stdin.write('k')).not.toThrow();
139
+ });
140
+
141
+ it('should support vim-style navigation keys (j/k)', () => {
142
+ const onSelect = vi.fn();
143
+ const { stdin } = render(<Select items={mockItems} onSelect={onSelect} />);
144
+
145
+ // Should accept j and k keys
146
+ expect(() => stdin.write('j')).not.toThrow();
147
+ expect(() => stdin.write('k')).not.toThrow();
148
+ });
149
+
150
+ it('should support arrow keys', () => {
151
+ const onSelect = vi.fn();
152
+ const { stdin } = render(<Select items={mockItems} onSelect={onSelect} />);
153
+
154
+ // Should accept arrow keys
155
+ expect(() => stdin.write('\u001B[A')).not.toThrow(); // Up
156
+ expect(() => stdin.write('\u001B[B')).not.toThrow(); // Down
157
+ });
158
+ });
159
+
160
+ describe('Selection', () => {
161
+ it('should call onSelect when Enter is pressed', () => {
162
+ const onSelect = vi.fn();
163
+ const { stdin } = render(<Select items={mockItems} onSelect={onSelect} />);
164
+
165
+ stdin.write('\r'); // Enter key
166
+
167
+ // Should be called at least once
168
+ expect(onSelect).toHaveBeenCalled();
169
+ });
170
+
171
+ it('should pass selected item to onSelect callback', () => {
172
+ const onSelect = vi.fn();
173
+ render(<Select items={mockItems} onSelect={onSelect} initialIndex={2} />);
174
+
175
+ // onSelect should be configured to receive item objects
176
+ // Actual keyboard testing is limited by ink-testing-library
177
+ expect(onSelect).toBeInstanceOf(Function);
178
+ });
179
+
180
+ it('should handle Enter key without errors', () => {
181
+ const onSelect = vi.fn();
182
+ const { stdin } = render(<Select items={mockItems} onSelect={onSelect} />);
183
+
184
+ expect(() => stdin.write('\r')).not.toThrow();
185
+ });
186
+ });
187
+
188
+ describe('Scrolling with limit', () => {
189
+ it('should implement offset-based scrolling logic', () => {
190
+ // Verify limit prop is accepted and used for slicing
191
+ const onSelect = vi.fn();
192
+ const { lastFrame } = render(<Select items={mockItems} onSelect={onSelect} limit={3} />);
193
+
194
+ const output = lastFrame();
195
+ // Should show limited items initially
196
+ expect(output).toContain('Option 1');
197
+ expect(output).toContain('Option 2');
198
+ expect(output).toContain('Option 3');
199
+ });
200
+
201
+ it('should handle limit smaller than items length', () => {
202
+ const onSelect = vi.fn();
203
+ const { lastFrame } = render(<Select items={mockItems} onSelect={onSelect} limit={2} />);
204
+
205
+ const output = lastFrame();
206
+ const lines = output.split('\n').filter((l) => l.trim());
207
+ // Should only show 2 items
208
+ expect(lines.length).toBeLessThanOrEqual(2);
209
+ });
210
+
211
+ it('should handle limit larger than items length', () => {
212
+ const onSelect = vi.fn();
213
+ const { lastFrame } = render(<Select items={mockItems} onSelect={onSelect} limit={100} />);
214
+
215
+ // Should show all items without error
216
+ const output = lastFrame();
217
+ expect(output).toContain('Option 1');
218
+ expect(output).toContain('Option 5');
219
+ });
220
+ });
221
+
222
+ describe('Key propagation (Critical Feature)', () => {
223
+ it('should not interfere with other keys like q', () => {
224
+ const onSelect = vi.fn();
225
+ const { stdin } = render(<Select items={mockItems} onSelect={onSelect} />);
226
+
227
+ // Press q key (should be ignored by Select and propagate to parent)
228
+ stdin.write('q');
229
+
230
+ // onSelect should not be called
231
+ expect(onSelect).not.toHaveBeenCalled();
232
+ });
233
+
234
+ it('should not interfere with other keys like m, n, c', () => {
235
+ const onSelect = vi.fn();
236
+ const { stdin } = render(<Select items={mockItems} onSelect={onSelect} />);
237
+
238
+ stdin.write('m');
239
+ stdin.write('n');
240
+ stdin.write('c');
241
+
242
+ // None of these should trigger selection
243
+ expect(onSelect).not.toHaveBeenCalled();
244
+ });
245
+ });
246
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ import { describe, it, expect, beforeEach } from 'vitest';
5
+ import { render } from '@testing-library/react';
6
+ import React from 'react';
7
+ import { Footer } from '../../../components/parts/Footer.js';
8
+ import { Window } from 'happy-dom';
9
+
10
+ describe('Footer', () => {
11
+ beforeEach(() => {
12
+ // Setup happy-dom
13
+ const window = new Window();
14
+ globalThis.window = window as any;
15
+ globalThis.document = window.document as any;
16
+ });
17
+
18
+ const mockActions = [
19
+ { key: 'enter', description: 'Select' },
20
+ { key: 'esc', description: 'Back' },
21
+ { key: 'h', description: 'Help' },
22
+ ];
23
+
24
+ it('should render all actions', () => {
25
+ const { getByText } = render(<Footer actions={mockActions} />);
26
+
27
+ expect(getByText(/enter/)).toBeDefined();
28
+ expect(getByText(/Select/)).toBeDefined();
29
+ expect(getByText(/esc/)).toBeDefined();
30
+ expect(getByText(/Back/)).toBeDefined();
31
+ expect(getByText(/h/)).toBeDefined();
32
+ expect(getByText(/Help/)).toBeDefined();
33
+ });
34
+
35
+ it('should render with empty actions array', () => {
36
+ const { container } = render(<Footer actions={[]} />);
37
+
38
+ expect(container).toBeDefined();
39
+ });
40
+
41
+ it('should render single action', () => {
42
+ const singleAction = [{ key: 'esc', description: 'Exit' }];
43
+ const { getByText } = render(<Footer actions={singleAction} />);
44
+
45
+ expect(getByText(/esc/)).toBeDefined();
46
+ expect(getByText(/Exit/)).toBeDefined();
47
+ });
48
+
49
+ it('should render actions in a horizontal layout', () => {
50
+ const { container } = render(<Footer actions={mockActions} />);
51
+
52
+ // Verify component renders without error
53
+ expect(container).toBeDefined();
54
+ });
55
+
56
+ it('should accept custom separator', () => {
57
+ const { getAllByText } = render(<Footer actions={mockActions} separator=" | " />);
58
+
59
+ const separators = getAllByText(/\|/);
60
+ expect(separators.length).toBeGreaterThan(0);
61
+ });
62
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ import { describe, it, expect, beforeEach } from 'vitest';
5
+ import { render } from '@testing-library/react';
6
+ import React from 'react';
7
+ import { Header } from '../../../components/parts/Header.js';
8
+ import { Window } from 'happy-dom';
9
+
10
+ describe('Header', () => {
11
+ beforeEach(() => {
12
+ // Setup happy-dom
13
+ const window = new Window();
14
+ globalThis.window = window as any;
15
+ globalThis.document = window.document as any;
16
+ });
17
+
18
+ it('should render title', () => {
19
+ const { getByText } = render(<Header title="gwt" />);
20
+
21
+ expect(getByText('gwt')).toBeDefined();
22
+ });
23
+
24
+ it('should render divider', () => {
25
+ const { getByText } = render(<Header title="Test" />);
26
+
27
+ // Check for a line of dashes (divider)
28
+ expect(getByText(/─+/)).toBeDefined();
29
+ });
30
+
31
+ it('should render title in bold and cyan by default', () => {
32
+ const { container } = render(<Header title="Test Title" />);
33
+
34
+ expect(container).toBeDefined();
35
+ });
36
+
37
+ it('should accept custom title color', () => {
38
+ const { container } = render(<Header title="Test" titleColor="green" />);
39
+
40
+ expect(container).toBeDefined();
41
+ });
42
+
43
+ it('should accept custom divider character', () => {
44
+ const { getByText } = render(<Header title="Test" dividerChar="=" />);
45
+
46
+ expect(getByText(/=+/)).toBeDefined();
47
+ });
48
+
49
+ it('should render without divider when showDivider is false', () => {
50
+ const { queryByText } = render(<Header title="Test" showDivider={false} />);
51
+
52
+ expect(queryByText(/─+/)).toBeNull();
53
+ });
54
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ import { describe, it, expect, beforeEach } from 'vitest';
5
+ import { render } from '@testing-library/react';
6
+ import React from 'react';
7
+ import { ScrollableList } from '../../../components/parts/ScrollableList.js';
8
+ import { Text } from 'ink';
9
+ import { Window } from 'happy-dom';
10
+
11
+ describe('ScrollableList', () => {
12
+ beforeEach(() => {
13
+ // Setup happy-dom
14
+ const window = new Window();
15
+ globalThis.window = window as any;
16
+ globalThis.document = window.document as any;
17
+ });
18
+
19
+ it('should render children', () => {
20
+ const { getByText } = render(
21
+ <ScrollableList>
22
+ <Text>Item 1</Text>
23
+ <Text>Item 2</Text>
24
+ <Text>Item 3</Text>
25
+ </ScrollableList>
26
+ );
27
+
28
+ expect(getByText('Item 1')).toBeDefined();
29
+ expect(getByText('Item 2')).toBeDefined();
30
+ expect(getByText('Item 3')).toBeDefined();
31
+ });
32
+
33
+ it('should render with no children', () => {
34
+ const { container } = render(<ScrollableList>{null}</ScrollableList>);
35
+
36
+ expect(container).toBeDefined();
37
+ });
38
+
39
+ it('should accept maxHeight prop', () => {
40
+ const { container } = render(
41
+ <ScrollableList maxHeight={10}>
42
+ <Text>Content</Text>
43
+ </ScrollableList>
44
+ );
45
+
46
+ expect(container).toBeDefined();
47
+ });
48
+
49
+ it('should render in a vertical layout', () => {
50
+ const { container } = render(
51
+ <ScrollableList>
52
+ <Text>Content</Text>
53
+ </ScrollableList>
54
+ );
55
+
56
+ expect(container).toBeDefined();
57
+ });
58
+
59
+ it('should handle single child', () => {
60
+ const { getByText } = render(
61
+ <ScrollableList>
62
+ <Text>Single Item</Text>
63
+ </ScrollableList>
64
+ );
65
+
66
+ expect(getByText('Single Item')).toBeDefined();
67
+ });
68
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ import { describe, it, expect, beforeEach } from 'vitest';
5
+ import { render } from '@testing-library/react';
6
+ import React from 'react';
7
+ import { Stats } from '../../../components/parts/Stats.js';
8
+ import type { Statistics } from '../../../types.js';
9
+ import { Window } from 'happy-dom';
10
+
11
+ describe('Stats', () => {
12
+ beforeEach(() => {
13
+ // Setup happy-dom
14
+ const window = new Window();
15
+ globalThis.window = window as any;
16
+ globalThis.document = window.document as any;
17
+ });
18
+
19
+ const mockStats: Statistics = {
20
+ localCount: 10,
21
+ remoteCount: 8,
22
+ worktreeCount: 3,
23
+ changesCount: 2,
24
+ lastUpdated: new Date('2025-01-25T12:00:00Z'),
25
+ };
26
+
27
+ it('should render all statistics', () => {
28
+ const { getByText } = render(<Stats stats={mockStats} />);
29
+
30
+ expect(getByText(/Local:/)).toBeDefined();
31
+ expect(getByText(/10/)).toBeDefined();
32
+ expect(getByText(/Remote:/)).toBeDefined();
33
+ expect(getByText(/8/)).toBeDefined();
34
+ expect(getByText(/Worktrees:/)).toBeDefined();
35
+ expect(getByText(/3/)).toBeDefined();
36
+ expect(getByText(/Changes:/)).toBeDefined();
37
+ expect(getByText(/2/)).toBeDefined();
38
+ });
39
+
40
+ it('should render with zero counts', () => {
41
+ const zeroStats: Statistics = {
42
+ localCount: 0,
43
+ remoteCount: 0,
44
+ worktreeCount: 0,
45
+ changesCount: 0,
46
+ lastUpdated: new Date(),
47
+ };
48
+
49
+ const { getByText, getAllByText } = render(<Stats stats={zeroStats} />);
50
+
51
+ expect(getByText(/Local:/)).toBeDefined();
52
+ const zeros = getAllByText(/0/);
53
+ expect(zeros.length).toBe(4); // All 4 counts are 0
54
+ });
55
+
56
+ it('should render in a horizontal layout', () => {
57
+ const { container } = render(<Stats stats={mockStats} />);
58
+
59
+ // Verify component renders without error
60
+ expect(container).toBeDefined();
61
+ });
62
+
63
+ it('should accept custom separator', () => {
64
+ const { getAllByText } = render(<Stats stats={mockStats} separator=" | " />);
65
+
66
+ const separators = getAllByText(/\|/);
67
+ expect(separators.length).toBeGreaterThan(0);
68
+ });
69
+
70
+ it('should handle large numbers', () => {
71
+ const largeStats: Statistics = {
72
+ localCount: 999,
73
+ remoteCount: 888,
74
+ worktreeCount: 777,
75
+ changesCount: 666,
76
+ lastUpdated: new Date(),
77
+ };
78
+
79
+ const { getByText } = render(<Stats stats={largeStats} />);
80
+
81
+ expect(getByText(/999/)).toBeDefined();
82
+ expect(getByText(/888/)).toBeDefined();
83
+ expect(getByText(/777/)).toBeDefined();
84
+ expect(getByText(/666/)).toBeDefined();
85
+ });
86
+
87
+ it('should display lastUpdated when provided', () => {
88
+ const now = new Date();
89
+ const lastUpdated = new Date(now.getTime() - 5000); // 5 seconds ago
90
+
91
+ const { getByText } = render(<Stats stats={mockStats} lastUpdated={lastUpdated} />);
92
+
93
+ expect(getByText(/Updated:/)).toBeDefined();
94
+ expect(getByText(/ago/)).toBeDefined();
95
+ });
96
+
97
+ it('should not display lastUpdated when null', () => {
98
+ const { queryByText } = render(<Stats stats={mockStats} lastUpdated={null} />);
99
+
100
+ expect(queryByText(/Updated:/)).toBeNull();
101
+ });
102
+
103
+ it('should not display lastUpdated when not provided', () => {
104
+ const { queryByText } = render(<Stats stats={mockStats} />);
105
+
106
+ expect(queryByText(/Updated:/)).toBeNull();
107
+ });
108
+
109
+ it('should format relative time correctly (seconds)', () => {
110
+ const now = new Date();
111
+ const lastUpdated = new Date(now.getTime() - 30000); // 30 seconds ago
112
+
113
+ const { getByText } = render(<Stats stats={mockStats} lastUpdated={lastUpdated} />);
114
+
115
+ expect(getByText(/30s ago/)).toBeDefined();
116
+ });
117
+
118
+ it('should format relative time correctly (minutes)', () => {
119
+ const now = new Date();
120
+ const lastUpdated = new Date(now.getTime() - 120000); // 2 minutes ago
121
+
122
+ const { getByText } = render(<Stats stats={mockStats} lastUpdated={lastUpdated} />);
123
+
124
+ expect(getByText(/2m ago/)).toBeDefined();
125
+ });
126
+
127
+ it('should format relative time correctly (hours)', () => {
128
+ const now = new Date();
129
+ const lastUpdated = new Date(now.getTime() - 7200000); // 2 hours ago
130
+
131
+ const { getByText } = render(<Stats stats={mockStats} lastUpdated={lastUpdated} />);
132
+
133
+ expect(getByText(/2h ago/)).toBeDefined();
134
+ });
135
+ });