@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.
- package/README.ja.md +323 -0
- package/README.md +347 -0
- package/bin/gwt.js +5 -0
- package/package.json +125 -0
- package/src/claude-history.ts +717 -0
- package/src/claude.ts +292 -0
- package/src/cli/ui/__tests__/SKIPPED_TESTS.md +119 -0
- package/src/cli/ui/__tests__/acceptance/branchList.acceptance.test.tsx.skip +239 -0
- package/src/cli/ui/__tests__/acceptance/navigation.acceptance.test.tsx +214 -0
- package/src/cli/ui/__tests__/acceptance/realtimeUpdate.acceptance.test.tsx.skip +219 -0
- package/src/cli/ui/__tests__/components/App.protected-branch.test.tsx +183 -0
- package/src/cli/ui/__tests__/components/App.shortcuts.test.tsx +313 -0
- package/src/cli/ui/__tests__/components/App.test.tsx +270 -0
- package/src/cli/ui/__tests__/components/common/Confirm.test.tsx +66 -0
- package/src/cli/ui/__tests__/components/common/ErrorBoundary.test.tsx +103 -0
- package/src/cli/ui/__tests__/components/common/Input.test.tsx +92 -0
- package/src/cli/ui/__tests__/components/common/LoadingIndicator.test.tsx +127 -0
- package/src/cli/ui/__tests__/components/common/Select.memo.test.tsx +264 -0
- package/src/cli/ui/__tests__/components/common/Select.test.tsx +246 -0
- package/src/cli/ui/__tests__/components/parts/Footer.test.tsx +62 -0
- package/src/cli/ui/__tests__/components/parts/Header.test.tsx +54 -0
- package/src/cli/ui/__tests__/components/parts/ScrollableList.test.tsx +68 -0
- package/src/cli/ui/__tests__/components/parts/Stats.test.tsx +135 -0
- package/src/cli/ui/__tests__/components/screens/AIToolSelectorScreen.test.tsx +153 -0
- package/src/cli/ui/__tests__/components/screens/BranchCreatorScreen.test.tsx +215 -0
- package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +293 -0
- package/src/cli/ui/__tests__/components/screens/ExecutionModeSelectorScreen.test.tsx +161 -0
- package/src/cli/ui/__tests__/components/screens/PRCleanupScreen.test.tsx +215 -0
- package/src/cli/ui/__tests__/components/screens/SessionSelectorScreen.test.tsx +99 -0
- package/src/cli/ui/__tests__/components/screens/WorktreeManagerScreen.test.tsx +127 -0
- package/src/cli/ui/__tests__/hooks/useGitData.test.ts.skip +228 -0
- package/src/cli/ui/__tests__/hooks/useScreenState.test.ts +146 -0
- package/src/cli/ui/__tests__/hooks/useTerminalSize.test.ts +98 -0
- package/src/cli/ui/__tests__/integration/branchList.test.tsx.skip +253 -0
- package/src/cli/ui/__tests__/integration/edgeCases.test.tsx +306 -0
- package/src/cli/ui/__tests__/integration/navigation.test.tsx +405 -0
- package/src/cli/ui/__tests__/integration/realtimeUpdate.test.tsx +505 -0
- package/src/cli/ui/__tests__/integration/realtimeUpdate.test.tsx.skip +216 -0
- package/src/cli/ui/__tests__/performance/branchList.performance.test.tsx +180 -0
- package/src/cli/ui/__tests__/performance/useMemoOptimization.test.tsx +237 -0
- package/src/cli/ui/__tests__/utils/branchFormatter.test.ts +775 -0
- package/src/cli/ui/__tests__/utils/statisticsCalculator.test.ts +243 -0
- package/src/cli/ui/components/App.tsx +793 -0
- package/src/cli/ui/components/common/Confirm.tsx +40 -0
- package/src/cli/ui/components/common/ErrorBoundary.tsx +57 -0
- package/src/cli/ui/components/common/Input.tsx +36 -0
- package/src/cli/ui/components/common/LoadingIndicator.tsx +95 -0
- package/src/cli/ui/components/common/Select.tsx +216 -0
- package/src/cli/ui/components/parts/Footer.tsx +41 -0
- package/src/cli/ui/components/parts/Header.test.tsx +85 -0
- package/src/cli/ui/components/parts/Header.tsx +63 -0
- package/src/cli/ui/components/parts/MergeStatusList.tsx +75 -0
- package/src/cli/ui/components/parts/ProgressBar.tsx +73 -0
- package/src/cli/ui/components/parts/ScrollableList.tsx +24 -0
- package/src/cli/ui/components/parts/Stats.tsx +67 -0
- package/src/cli/ui/components/screens/AIToolSelectorScreen.tsx +116 -0
- package/src/cli/ui/components/screens/BatchMergeProgressScreen.tsx +70 -0
- package/src/cli/ui/components/screens/BatchMergeResultScreen.tsx +104 -0
- package/src/cli/ui/components/screens/BranchCreatorScreen.tsx +213 -0
- package/src/cli/ui/components/screens/BranchListScreen.tsx +299 -0
- package/src/cli/ui/components/screens/ExecutionModeSelectorScreen.tsx +149 -0
- package/src/cli/ui/components/screens/PRCleanupScreen.tsx +167 -0
- package/src/cli/ui/components/screens/SessionSelectorScreen.tsx +100 -0
- package/src/cli/ui/components/screens/WorktreeManagerScreen.tsx +117 -0
- package/src/cli/ui/hooks/useBatchMerge.ts +96 -0
- package/src/cli/ui/hooks/useGitData.ts +157 -0
- package/src/cli/ui/hooks/useScreenState.ts +44 -0
- package/src/cli/ui/hooks/useTerminalSize.ts +33 -0
- package/src/cli/ui/screens/BranchActionSelectorScreen.tsx +102 -0
- package/src/cli/ui/screens/__tests__/BranchActionSelectorScreen.test.tsx +151 -0
- package/src/cli/ui/types.ts +295 -0
- package/src/cli/ui/utils/baseBranch.ts +34 -0
- package/src/cli/ui/utils/branchFormatter.ts +222 -0
- package/src/cli/ui/utils/statisticsCalculator.ts +44 -0
- package/src/codex.ts +139 -0
- package/src/config/builtin-tools.ts +44 -0
- package/src/config/constants.ts +100 -0
- package/src/config/env-history.ts +45 -0
- package/src/config/index.ts +204 -0
- package/src/config/tools.ts +293 -0
- package/src/git.ts +1102 -0
- package/src/github.ts +158 -0
- package/src/index.test.ts +87 -0
- package/src/index.ts +684 -0
- package/src/index.ts.backup +1543 -0
- package/src/launcher.ts +142 -0
- package/src/repositories/git.repository.ts +129 -0
- package/src/repositories/github.repository.ts +83 -0
- package/src/repositories/worktree.repository.ts +69 -0
- package/src/services/BatchMergeService.ts +251 -0
- package/src/services/WorktreeOrchestrator.ts +115 -0
- package/src/services/__tests__/BatchMergeService.test.ts +518 -0
- package/src/services/__tests__/WorktreeOrchestrator.test.ts +258 -0
- package/src/services/dependency-installer.ts +199 -0
- package/src/services/git.service.ts +113 -0
- package/src/services/github.service.ts +61 -0
- package/src/services/worktree.service.ts +66 -0
- package/src/types/api.ts +241 -0
- package/src/types/tools.ts +235 -0
- package/src/utils/spinner.ts +54 -0
- package/src/utils/terminal.ts +272 -0
- package/src/utils.test.ts +43 -0
- package/src/utils.ts +60 -0
- package/src/web/client/index.html +12 -0
- package/src/web/client/src/components/BranchGraph.tsx +231 -0
- package/src/web/client/src/components/EnvEditor.tsx +145 -0
- package/src/web/client/src/components/Terminal.tsx +137 -0
- package/src/web/client/src/hooks/useBranches.ts +41 -0
- package/src/web/client/src/hooks/useConfig.ts +31 -0
- package/src/web/client/src/hooks/useSessions.ts +59 -0
- package/src/web/client/src/hooks/useWorktrees.ts +47 -0
- package/src/web/client/src/index.css +834 -0
- package/src/web/client/src/lib/api.ts +184 -0
- package/src/web/client/src/lib/websocket.ts +174 -0
- package/src/web/client/src/main.tsx +29 -0
- package/src/web/client/src/pages/BranchDetailPage.tsx +847 -0
- package/src/web/client/src/pages/BranchListPage.tsx +264 -0
- package/src/web/client/src/pages/ConfigManagementPage.tsx +203 -0
- package/src/web/client/src/router.tsx +27 -0
- package/src/web/client/vite.config.ts +21 -0
- package/src/web/server/env/importer.ts +54 -0
- package/src/web/server/index.ts +74 -0
- package/src/web/server/pty/manager.ts +189 -0
- package/src/web/server/routes/branches.ts +126 -0
- package/src/web/server/routes/config.ts +220 -0
- package/src/web/server/routes/index.ts +37 -0
- package/src/web/server/routes/sessions.ts +130 -0
- package/src/web/server/routes/worktrees.ts +108 -0
- package/src/web/server/services/branches.ts +368 -0
- package/src/web/server/services/worktrees.ts +85 -0
- package/src/web/server/websocket/handler.ts +180 -0
- 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
|
+
});
|