@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,153 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
5
+ import { render, waitFor } from '@testing-library/react';
6
+ import React from 'react';
7
+ import { AIToolSelectorScreen } from '../../../components/screens/AIToolSelectorScreen.js';
8
+ import { Window } from 'happy-dom';
9
+
10
+ // Mock getAllTools
11
+ vi.mock('../../../config/tools.js', () => ({
12
+ getAllTools: vi.fn().mockResolvedValue([
13
+ {
14
+ id: 'claude-code',
15
+ displayName: 'Claude Code',
16
+ isBuiltin: true,
17
+ },
18
+ {
19
+ id: 'codex-cli',
20
+ displayName: 'Codex CLI',
21
+ isBuiltin: true,
22
+ },
23
+ ]),
24
+ }));
25
+
26
+ describe('AIToolSelectorScreen', () => {
27
+ beforeEach(() => {
28
+ // Setup happy-dom
29
+ const window = new Window();
30
+ globalThis.window = window as any;
31
+ globalThis.document = window.document as any;
32
+ });
33
+
34
+ it('should render header with title', () => {
35
+ const onBack = vi.fn();
36
+ const onSelect = vi.fn();
37
+ const { getByText } = render(
38
+ <AIToolSelectorScreen onBack={onBack} onSelect={onSelect} />
39
+ );
40
+
41
+ expect(getByText(/AI Tool Selection/i)).toBeDefined();
42
+ });
43
+
44
+ it('should render AI tool options', async () => {
45
+ const onBack = vi.fn();
46
+ const onSelect = vi.fn();
47
+ const { getByText } = render(
48
+ <AIToolSelectorScreen onBack={onBack} onSelect={onSelect} />
49
+ );
50
+
51
+ // Wait for tools to load
52
+ await waitFor(() => {
53
+ expect(getByText(/Claude Code/i)).toBeDefined();
54
+ expect(getByText(/Codex CLI/i)).toBeDefined();
55
+ });
56
+ });
57
+
58
+ it('should render footer with actions', () => {
59
+ const onBack = vi.fn();
60
+ const onSelect = vi.fn();
61
+ const { getAllByText } = render(
62
+ <AIToolSelectorScreen onBack={onBack} onSelect={onSelect} />
63
+ );
64
+
65
+ expect(getAllByText(/enter/i).length).toBeGreaterThan(0);
66
+ expect(getAllByText(/esc/i).length).toBeGreaterThan(0);
67
+ });
68
+
69
+ it('should use terminal height for layout calculation', () => {
70
+ const originalRows = process.stdout.rows;
71
+ process.stdout.rows = 30;
72
+
73
+ const onBack = vi.fn();
74
+ const onSelect = vi.fn();
75
+ const { container } = render(
76
+ <AIToolSelectorScreen onBack={onBack} onSelect={onSelect} />
77
+ );
78
+
79
+ expect(container).toBeDefined();
80
+
81
+ process.stdout.rows = originalRows;
82
+ });
83
+
84
+ it('should handle back navigation with ESC key', () => {
85
+ const onBack = vi.fn();
86
+ const onSelect = vi.fn();
87
+ const { container } = render(
88
+ <AIToolSelectorScreen onBack={onBack} onSelect={onSelect} />
89
+ );
90
+
91
+ // Test will verify onBack is called when ESC is pressed
92
+ expect(container).toBeDefined();
93
+ });
94
+
95
+ it('should handle tool selection', () => {
96
+ const onBack = vi.fn();
97
+ const onSelect = vi.fn();
98
+ const { container } = render(
99
+ <AIToolSelectorScreen onBack={onBack} onSelect={onSelect} />
100
+ );
101
+
102
+ // Test will verify onSelect is called with correct tool
103
+ expect(container).toBeDefined();
104
+ });
105
+
106
+ /**
107
+ * T210: カスタムツール表示のテスト
108
+ */
109
+ describe('Custom tool display', () => {
110
+ it('should load tools from getAllTools() dynamically', async () => {
111
+ // TODO: 実装後にテストを記述
112
+ // getAllTools()がモックされ、呼び出されることを確認
113
+ // モックの戻り値がツールアイテムとして表示されることを確認
114
+ expect(true).toBe(true);
115
+ });
116
+
117
+ it('should display both builtin and custom tools', async () => {
118
+ // TODO: 実装後にテストを記述
119
+ // getAllTools()がビルトインツール(claude-code, codex-cli)と
120
+ // カスタムツール(例: aider)を返す場合、
121
+ // すべてのツールが表示されることを確認
122
+ expect(true).toBe(true);
123
+ });
124
+
125
+ it('should display custom tool with icon if defined', async () => {
126
+ // TODO: 実装後にテストを記述
127
+ // カスタムツールにiconフィールドがある場合、
128
+ // それが表示されることを確認
129
+ expect(true).toBe(true);
130
+ });
131
+
132
+ it('should display custom tool without icon if not defined', async () => {
133
+ // TODO: 実装後にテストを記述
134
+ // カスタムツールにiconフィールドがない場合、
135
+ // ツール名のみが表示されることを確認
136
+ expect(true).toBe(true);
137
+ });
138
+
139
+ it('should handle custom tool selection', async () => {
140
+ // TODO: 実装後にテストを記述
141
+ // カスタムツールを選択した場合、
142
+ // onSelect()がカスタムツールのIDで呼び出されることを確認
143
+ expect(true).toBe(true);
144
+ });
145
+
146
+ it('should display only builtin tools if no custom tools exist', async () => {
147
+ // TODO: 実装後にテストを記述
148
+ // getAllTools()がビルトインツールのみを返す場合、
149
+ // ビルトインツールのみが表示されることを確認
150
+ expect(true).toBe(true);
151
+ });
152
+ });
153
+ });
@@ -0,0 +1,215 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
5
+ import { render as rtlRender, act } from '@testing-library/react';
6
+ import React from 'react';
7
+ import { BranchCreatorScreen } from '../../../components/screens/BranchCreatorScreen.js';
8
+ import { Window } from 'happy-dom';
9
+ import { render as inkRender } from 'ink-testing-library';
10
+
11
+ describe('BranchCreatorScreen', () => {
12
+ beforeEach(() => {
13
+ vi.useFakeTimers();
14
+ // Setup happy-dom
15
+ const window = new Window();
16
+ globalThis.window = window as any;
17
+ globalThis.document = window.document as any;
18
+ });
19
+
20
+ afterEach(() => {
21
+ vi.useRealTimers();
22
+ });
23
+
24
+ it('should render header with title', () => {
25
+ const onBack = vi.fn();
26
+ const onCreate = vi.fn().mockResolvedValue(undefined);
27
+ const { getByText } = rtlRender(
28
+ <BranchCreatorScreen onBack={onBack} onCreate={onCreate} disableAnimation />
29
+ );
30
+
31
+ expect(getByText(/New Branch/i)).toBeDefined();
32
+ });
33
+
34
+ it('should render branch type selection initially', () => {
35
+ const onBack = vi.fn();
36
+ const onCreate = vi.fn().mockResolvedValue(undefined);
37
+ const { getByText } = rtlRender(
38
+ <BranchCreatorScreen onBack={onBack} onCreate={onCreate} disableAnimation />
39
+ );
40
+
41
+ expect(getByText(/Select branch type/i)).toBeDefined();
42
+ expect(getByText(/feature/i)).toBeDefined();
43
+ expect(getByText(/hotfix/i)).toBeDefined();
44
+ expect(getByText(/release/i)).toBeDefined();
45
+ });
46
+
47
+ it('should render footer with actions', () => {
48
+ const onBack = vi.fn();
49
+ const onCreate = vi.fn().mockResolvedValue(undefined);
50
+ const { getAllByText } = rtlRender(
51
+ <BranchCreatorScreen onBack={onBack} onCreate={onCreate} disableAnimation />
52
+ );
53
+
54
+ expect(getAllByText(/enter/i).length).toBeGreaterThan(0);
55
+ expect(getAllByText(/esc/i).length).toBeGreaterThan(0);
56
+ });
57
+
58
+ it('should show branch name input after type selection', () => {
59
+ const onBack = vi.fn();
60
+ const onCreate = vi.fn().mockResolvedValue(undefined);
61
+ const { container } = rtlRender(
62
+ <BranchCreatorScreen onBack={onBack} onCreate={onCreate} disableAnimation />
63
+ );
64
+
65
+ // Test will verify the screen transitions from type selection to name input
66
+ expect(container).toBeDefined();
67
+ });
68
+
69
+ it('should handle branch creation', () => {
70
+ const onBack = vi.fn();
71
+ const onCreate = vi.fn().mockResolvedValue(undefined);
72
+ const { container } = rtlRender(
73
+ <BranchCreatorScreen onBack={onBack} onCreate={onCreate} disableAnimation />
74
+ );
75
+
76
+ // Test will verify onCreate is called with correct branch name
77
+ expect(container).toBeDefined();
78
+ });
79
+
80
+ it('should use terminal height for layout calculation', () => {
81
+ const originalRows = process.stdout.rows;
82
+ process.stdout.rows = 30;
83
+
84
+ const onBack = vi.fn();
85
+ const onCreate = vi.fn().mockResolvedValue(undefined);
86
+ const { container } = rtlRender(
87
+ <BranchCreatorScreen onBack={onBack} onCreate={onCreate} disableAnimation />
88
+ );
89
+
90
+ expect(container).toBeDefined();
91
+
92
+ process.stdout.rows = originalRows;
93
+ });
94
+
95
+ it('should handle back navigation with ESC key', () => {
96
+ const onBack = vi.fn();
97
+ const onCreate = vi.fn().mockResolvedValue(undefined);
98
+ const { container } = rtlRender(
99
+ <BranchCreatorScreen onBack={onBack} onCreate={onCreate} disableAnimation />
100
+ );
101
+
102
+ // Test will verify onBack is called when ESC is pressed
103
+ expect(container).toBeDefined();
104
+ });
105
+
106
+ it('should display creating state while waiting for branch creation', async () => {
107
+ expect.assertions(3);
108
+ const onBack = vi.fn();
109
+ let resolveCreate: (() => void) | null = null;
110
+ const onCreate = vi.fn(
111
+ () =>
112
+ new Promise<void>((resolve) => {
113
+ resolveCreate = resolve;
114
+ })
115
+ );
116
+ const { stdin, lastFrame } = inkRender(
117
+ <BranchCreatorScreen onBack={onBack} onCreate={onCreate} disableAnimation />
118
+ );
119
+
120
+ // Select default branch type (feature)
121
+ await act(async () => {
122
+ stdin.write('\r');
123
+ });
124
+ await act(async () => {
125
+ await Promise.resolve();
126
+ });
127
+
128
+ const branchName = 'new-branch';
129
+ for (const char of branchName) {
130
+ await act(async () => {
131
+ stdin.write(char);
132
+ });
133
+ }
134
+ await act(async () => {
135
+ await Promise.resolve();
136
+ });
137
+
138
+ // Submit branch name
139
+ await act(async () => {
140
+ stdin.write('\r');
141
+ });
142
+ await act(async () => {
143
+ await Promise.resolve();
144
+ });
145
+
146
+ expect(onCreate).toHaveBeenCalledWith(`feature/${branchName}`);
147
+ expect(lastFrame()).toContain('Creating branch');
148
+ expect(lastFrame()).toContain(`feature/${branchName}`);
149
+
150
+ await act(async () => {
151
+ resolveCreate?.();
152
+ });
153
+ await act(async () => {
154
+ await Promise.resolve();
155
+ });
156
+ });
157
+
158
+ it('should ignore ESC input while branch creation is in progress', async () => {
159
+ expect.assertions(2);
160
+ const onBack = vi.fn();
161
+ let resolveCreate: (() => void) | null = null;
162
+ const onCreate = vi.fn(
163
+ () =>
164
+ new Promise<void>((resolve) => {
165
+ resolveCreate = resolve;
166
+ })
167
+ );
168
+ const { stdin, lastFrame } = inkRender(
169
+ <BranchCreatorScreen onBack={onBack} onCreate={onCreate} disableAnimation />
170
+ );
171
+
172
+ // Move to name input
173
+ await act(async () => {
174
+ stdin.write('\r');
175
+ });
176
+ await act(async () => {
177
+ await Promise.resolve();
178
+ });
179
+
180
+ const branchName = 'blocking-branch';
181
+ for (const char of branchName) {
182
+ await act(async () => {
183
+ stdin.write(char);
184
+ });
185
+ }
186
+ await act(async () => {
187
+ await Promise.resolve();
188
+ });
189
+
190
+ await act(async () => {
191
+ stdin.write('\r');
192
+ });
193
+ await act(async () => {
194
+ await Promise.resolve();
195
+ });
196
+
197
+ // Attempt to cancel with ESC during creation
198
+ await act(async () => {
199
+ stdin.write('\u001B');
200
+ });
201
+ await act(async () => {
202
+ await Promise.resolve();
203
+ });
204
+
205
+ expect(onBack).not.toHaveBeenCalled();
206
+ expect(lastFrame()).toContain('Creating branch');
207
+
208
+ await act(async () => {
209
+ resolveCreate?.();
210
+ });
211
+ await act(async () => {
212
+ await Promise.resolve();
213
+ });
214
+ });
215
+ });
@@ -0,0 +1,293 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
5
+ import { act, render } from '@testing-library/react';
6
+ import { render as inkRender } from 'ink-testing-library';
7
+ import React from 'react';
8
+ import { BranchListScreen } from '../../../components/screens/BranchListScreen.js';
9
+ import type { BranchInfo, BranchItem, Statistics } from '../../../types.js';
10
+ import { formatBranchItem } from '../../../utils/branchFormatter.js';
11
+ import stringWidth from 'string-width';
12
+ import { Window } from 'happy-dom';
13
+
14
+ const stripAnsi = (value: string): string => value.replace(/\u001b\[[0-9;]*m/g, '');
15
+ const stripControlSequences = (value: string): string =>
16
+ value.replace(/\u001b\[([0-9;?]*)([A-Za-z])/g, (_, params, command) => {
17
+ if (command === 'C') {
18
+ const count = Number(params || '1');
19
+ return ' '.repeat(Number.isNaN(count) ? 0 : count);
20
+ }
21
+ return '';
22
+ });
23
+
24
+ describe('BranchListScreen', () => {
25
+ beforeEach(() => {
26
+ vi.useFakeTimers();
27
+ // Setup happy-dom
28
+ const window = new Window();
29
+ globalThis.window = window as any;
30
+ globalThis.document = window.document as any;
31
+ });
32
+
33
+ afterEach(() => {
34
+ vi.useRealTimers();
35
+ });
36
+
37
+ const mockBranches: BranchItem[] = [
38
+ {
39
+ name: 'main',
40
+ type: 'local',
41
+ branchType: 'main',
42
+ isCurrent: true,
43
+ icons: ['⚡', '⭐'],
44
+ hasChanges: false,
45
+ label: '⚡ ⭐ main',
46
+ value: 'main',
47
+ latestCommitTimestamp: 1_700_000_000,
48
+ },
49
+ {
50
+ name: 'feature/test',
51
+ type: 'local',
52
+ branchType: 'feature',
53
+ isCurrent: false,
54
+ icons: ['✨'],
55
+ hasChanges: false,
56
+ label: '✨ feature/test',
57
+ value: 'feature/test',
58
+ latestCommitTimestamp: 1_699_000_000,
59
+ },
60
+ ];
61
+
62
+ const mockStats: Statistics = {
63
+ localCount: 2,
64
+ remoteCount: 1,
65
+ worktreeCount: 0,
66
+ changesCount: 0,
67
+ lastUpdated: new Date(),
68
+ };
69
+
70
+ it('should render header with title', () => {
71
+ const onSelect = vi.fn();
72
+ const { getByText } = render(
73
+ <BranchListScreen branches={mockBranches} stats={mockStats} onSelect={onSelect} />
74
+ );
75
+
76
+ expect(getByText(/gwt - Branch Selection/i)).toBeDefined();
77
+ });
78
+
79
+ it('should render statistics', () => {
80
+ const onSelect = vi.fn();
81
+ const { container, getByText } = render(
82
+ <BranchListScreen branches={mockBranches} stats={mockStats} onSelect={onSelect} />
83
+ );
84
+
85
+ expect(container.textContent).toContain('Local: 2');
86
+ expect(getByText(/Remote:/)).toBeDefined();
87
+ });
88
+
89
+ it('should render branch list', () => {
90
+ const onSelect = vi.fn();
91
+ const { getByText } = render(
92
+ <BranchListScreen branches={mockBranches} stats={mockStats} onSelect={onSelect} />
93
+ );
94
+
95
+ expect(getByText(/main/)).toBeDefined();
96
+ expect(getByText(/feature\/test/)).toBeDefined();
97
+ });
98
+
99
+ it('should render footer with actions', () => {
100
+ const onSelect = vi.fn();
101
+ const { getAllByText } = render(
102
+ <BranchListScreen branches={mockBranches} stats={mockStats} onSelect={onSelect} />
103
+ );
104
+
105
+ // Check for enter key (main screen doesn't have q key, exit is Ctrl+C only)
106
+ expect(getAllByText(/enter/i).length).toBeGreaterThan(0);
107
+ });
108
+
109
+ it('should handle empty branch list', () => {
110
+ const onSelect = vi.fn();
111
+ const emptyStats: Statistics = {
112
+ localCount: 0,
113
+ remoteCount: 0,
114
+ worktreeCount: 0,
115
+ changesCount: 0,
116
+ lastUpdated: new Date(),
117
+ };
118
+
119
+ const { container } = render(
120
+ <BranchListScreen branches={[]} stats={emptyStats} onSelect={onSelect} />
121
+ );
122
+
123
+ expect(container).toBeDefined();
124
+ });
125
+
126
+ it('should display loading indicator after the configured delay', async () => {
127
+ const onSelect = vi.fn();
128
+ const { queryByText, getByText } = render(
129
+ <BranchListScreen
130
+ branches={mockBranches}
131
+ stats={mockStats}
132
+ onSelect={onSelect}
133
+ loading={true}
134
+ loadingIndicatorDelay={10}
135
+ />
136
+ );
137
+
138
+ await act(async () => {
139
+ if (typeof (vi as any).advanceTimersByTime === 'function') {
140
+ (vi as any).advanceTimersByTime(10);
141
+ } else {
142
+ await new Promise((resolve) => setTimeout(resolve, 10));
143
+ }
144
+ });
145
+
146
+ expect(getByText(/Loading Git information/i)).toBeDefined();
147
+ });
148
+
149
+ it('should display error state', () => {
150
+ const onSelect = vi.fn();
151
+ const error = new Error('Failed to load branches');
152
+ const { getByText } = render(
153
+ <BranchListScreen branches={[]} stats={mockStats} onSelect={onSelect} error={error} />
154
+ );
155
+
156
+ expect(getByText(/Error:/i)).toBeDefined();
157
+ expect(getByText(/Failed to load branches/i)).toBeDefined();
158
+ });
159
+
160
+ it('should use terminal height for layout calculation', () => {
161
+ const onSelect = vi.fn();
162
+
163
+ // Mock process.stdout
164
+ const originalRows = process.stdout.rows;
165
+ process.stdout.rows = 30;
166
+
167
+ const { container } = render(
168
+ <BranchListScreen branches={mockBranches} stats={mockStats} onSelect={onSelect} />
169
+ );
170
+
171
+ expect(container).toBeDefined();
172
+
173
+ // Restore
174
+ process.stdout.rows = originalRows;
175
+ });
176
+
177
+ it('should display branch icons', () => {
178
+ const onSelect = vi.fn();
179
+ const { getByText } = render(
180
+ <BranchListScreen branches={mockBranches} stats={mockStats} onSelect={onSelect} />
181
+ );
182
+
183
+ // Check for icons in labels
184
+ expect(getByText(/⚡/)).toBeDefined(); // main icon
185
+ expect(getByText(/⭐/)).toBeDefined(); // current icon
186
+ expect(getByText(/✨/)).toBeDefined(); // feature icon
187
+ });
188
+
189
+ it('should render latest commit timestamp for each branch', () => {
190
+ const onSelect = vi.fn();
191
+ const { container } = render(
192
+ <BranchListScreen branches={mockBranches} stats={mockStats} onSelect={onSelect} />
193
+ );
194
+
195
+ const textContent = container.textContent ?? '';
196
+ const matches = textContent.match(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}/g) ?? [];
197
+ expect(matches.length).toBe(mockBranches.length);
198
+ });
199
+
200
+ it('should highlight the selected branch with cyan background', async () => {
201
+ process.env.FORCE_COLOR = '1';
202
+ const onSelect = vi.fn();
203
+ let renderResult: ReturnType<typeof inkRender>;
204
+ await act(async () => {
205
+ renderResult = inkRender(
206
+ <BranchListScreen branches={mockBranches} stats={mockStats} onSelect={onSelect} />,
207
+ { stripAnsi: false }
208
+ );
209
+ });
210
+
211
+ const frame = renderResult!.lastFrame() ?? '';
212
+ expect(frame).toContain('\u001b[46m'); // cyan background ANSI code
213
+ });
214
+
215
+ it('should align timestamps even when unpushed icon is displayed', async () => {
216
+ process.env.FORCE_COLOR = '1';
217
+ const onSelect = vi.fn();
218
+
219
+ const originalColumns = process.stdout.columns;
220
+ process.stdout.columns = 94;
221
+
222
+ const branchInfos: BranchInfo[] = [
223
+ {
224
+ name: 'feature/update-ui',
225
+ type: 'local',
226
+ branchType: 'feature',
227
+ isCurrent: false,
228
+ hasUnpushedCommits: true,
229
+ latestCommitTimestamp: 1_700_000_000,
230
+ },
231
+ {
232
+ name: 'origin/main',
233
+ type: 'remote',
234
+ branchType: 'main',
235
+ isCurrent: false,
236
+ hasUnpushedCommits: false,
237
+ latestCommitTimestamp: 1_699_999_000,
238
+ },
239
+ {
240
+ name: 'main',
241
+ type: 'local',
242
+ branchType: 'main',
243
+ isCurrent: true,
244
+ hasUnpushedCommits: false,
245
+ latestCommitTimestamp: 1_699_998_000,
246
+ },
247
+ ];
248
+
249
+ const branchesWithUnpushed: BranchItem[] = branchInfos.map((branch) =>
250
+ formatBranchItem(branch)
251
+ );
252
+
253
+ try {
254
+ let renderResult: ReturnType<typeof inkRender>;
255
+ await act(async () => {
256
+ renderResult = inkRender(
257
+ <BranchListScreen branches={branchesWithUnpushed} stats={mockStats} onSelect={onSelect} />,
258
+ { stripAnsi: false }
259
+ );
260
+ });
261
+
262
+ const frame = renderResult!.lastFrame() ?? '';
263
+ const timestampLines = frame
264
+ .split('\n')
265
+ .map((line) => stripControlSequences(stripAnsi(line)))
266
+ .filter((line) => /\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(line));
267
+
268
+ expect(timestampLines.length).toBeGreaterThanOrEqual(3);
269
+
270
+ const timestampWidths = timestampLines.map((line) => {
271
+ const match = line.match(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}/);
272
+ const index = match?.index ?? 0;
273
+ const beforeTimestamp = line.slice(0, index);
274
+
275
+ let width = 0;
276
+ for (const char of Array.from(beforeTimestamp)) {
277
+ if (char === '\u2B06' || char === '\u2601') {
278
+ width += 1;
279
+ continue;
280
+ }
281
+ width += stringWidth(char);
282
+ }
283
+ return width;
284
+ });
285
+
286
+ const uniquePositions = new Set(timestampWidths);
287
+
288
+ expect(uniquePositions.size).toBe(1);
289
+ } finally {
290
+ process.stdout.columns = originalColumns;
291
+ }
292
+ });
293
+ });