@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,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment happy-dom
|
|
3
|
+
* Acceptance tests for User Story 2: Sub-screen Navigation
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest';
|
|
6
|
+
import type { Mock } from 'vitest';
|
|
7
|
+
import { render, waitFor } from '@testing-library/react';
|
|
8
|
+
import React from 'react';
|
|
9
|
+
import { App } from '../../components/App.js';
|
|
10
|
+
import { Window } from 'happy-dom';
|
|
11
|
+
import type { BranchInfo } from '../../types.js';
|
|
12
|
+
|
|
13
|
+
// Mock git.ts and worktree.ts
|
|
14
|
+
vi.mock('../../../../git.ts', () => ({
|
|
15
|
+
__esModule: true,
|
|
16
|
+
getAllBranches: vi.fn(),
|
|
17
|
+
getRepositoryRoot: vi.fn(async () => '/repo'),
|
|
18
|
+
deleteBranch: vi.fn(async () => undefined),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
const { acceptanceIsProtectedBranchName, acceptanceSwitchToProtectedBranch } = vi.hoisted(() => ({
|
|
22
|
+
acceptanceIsProtectedBranchName: vi.fn(() => false),
|
|
23
|
+
acceptanceSwitchToProtectedBranch: vi.fn(async () => 'none' as const),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock('../../../../worktree.ts', () => ({
|
|
27
|
+
__esModule: true,
|
|
28
|
+
listAdditionalWorktrees: vi.fn(),
|
|
29
|
+
createWorktree: vi.fn(async () => undefined),
|
|
30
|
+
generateWorktreePath: vi.fn(async () => '/repo/.git/worktree/test'),
|
|
31
|
+
getMergedPRWorktrees: vi.fn(async () => []),
|
|
32
|
+
removeWorktree: vi.fn(async () => undefined),
|
|
33
|
+
isProtectedBranchName: acceptanceIsProtectedBranchName,
|
|
34
|
+
switchToProtectedBranch: acceptanceSwitchToProtectedBranch,
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
import { getAllBranches, getRepositoryRoot, deleteBranch } from '../../../../git.ts';
|
|
38
|
+
import {
|
|
39
|
+
listAdditionalWorktrees,
|
|
40
|
+
createWorktree,
|
|
41
|
+
generateWorktreePath,
|
|
42
|
+
getMergedPRWorktrees,
|
|
43
|
+
removeWorktree,
|
|
44
|
+
} from '../../../../worktree.ts';
|
|
45
|
+
|
|
46
|
+
const mockedGetAllBranches = getAllBranches as Mock;
|
|
47
|
+
const mockedGetRepositoryRoot = getRepositoryRoot as Mock;
|
|
48
|
+
const mockedDeleteBranch = deleteBranch as Mock;
|
|
49
|
+
const mockedListAdditionalWorktrees = listAdditionalWorktrees as Mock;
|
|
50
|
+
const mockedCreateWorktree = createWorktree as Mock;
|
|
51
|
+
const mockedGenerateWorktreePath = generateWorktreePath as Mock;
|
|
52
|
+
const mockedGetMergedPRWorktrees = getMergedPRWorktrees as Mock;
|
|
53
|
+
const mockedRemoveWorktree = removeWorktree as Mock;
|
|
54
|
+
const mockedIsProtectedBranchName = acceptanceIsProtectedBranchName as Mock;
|
|
55
|
+
const mockedSwitchToProtectedBranch = acceptanceSwitchToProtectedBranch as Mock;
|
|
56
|
+
|
|
57
|
+
describe('Acceptance: Navigation (User Story 2)', () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
// Setup happy-dom
|
|
60
|
+
const window = new Window();
|
|
61
|
+
globalThis.window = window as any;
|
|
62
|
+
globalThis.document = window.document as any;
|
|
63
|
+
|
|
64
|
+
// Reset mocks
|
|
65
|
+
mockedGetAllBranches.mockReset();
|
|
66
|
+
mockedListAdditionalWorktrees.mockReset();
|
|
67
|
+
mockedGetRepositoryRoot.mockReset();
|
|
68
|
+
mockedDeleteBranch.mockReset();
|
|
69
|
+
mockedCreateWorktree.mockReset();
|
|
70
|
+
mockedGenerateWorktreePath.mockReset();
|
|
71
|
+
mockedGetMergedPRWorktrees.mockReset();
|
|
72
|
+
mockedRemoveWorktree.mockReset();
|
|
73
|
+
mockedIsProtectedBranchName.mockReset();
|
|
74
|
+
mockedSwitchToProtectedBranch.mockReset();
|
|
75
|
+
mockedGetRepositoryRoot.mockResolvedValue('/repo');
|
|
76
|
+
mockedSwitchToProtectedBranch.mockResolvedValue('none');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const mockBranches: BranchInfo[] = [
|
|
80
|
+
{
|
|
81
|
+
name: 'main',
|
|
82
|
+
type: 'local',
|
|
83
|
+
branchType: 'main',
|
|
84
|
+
isCurrent: true,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'feature/test',
|
|
88
|
+
type: 'local',
|
|
89
|
+
branchType: 'feature',
|
|
90
|
+
isCurrent: false,
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* T074: Acceptance Scenario 1
|
|
96
|
+
* nキーで新規ブランチ作成画面に遷移
|
|
97
|
+
*/
|
|
98
|
+
it('[AC1] should navigate to branch creator on n key', async () => {
|
|
99
|
+
(getAllBranches as ReturnType<typeof vi.fn>).mockResolvedValue(mockBranches);
|
|
100
|
+
(listAdditionalWorktrees as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
|
101
|
+
|
|
102
|
+
const onExit = vi.fn();
|
|
103
|
+
const { getByText, container } = render(<App onExit={onExit} />);
|
|
104
|
+
|
|
105
|
+
await waitFor(() => {
|
|
106
|
+
expect(getByText(/gwt - Branch Selection/i)).toBeDefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Verify n key action is available in footer
|
|
110
|
+
const nKeyElements = container.querySelectorAll('*');
|
|
111
|
+
let hasNKey = false;
|
|
112
|
+
nKeyElements.forEach((el) => {
|
|
113
|
+
if (el.textContent?.toLowerCase().includes('new branch')) {
|
|
114
|
+
hasNKey = true;
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(hasNKey || container.textContent?.toLowerCase().includes('n')).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* T075: Acceptance Scenario 2
|
|
123
|
+
* メイン画面にはqキーが存在しない(終了はCtrl+Cのみ)
|
|
124
|
+
*/
|
|
125
|
+
it('[AC2] should not have q key on main screen', async () => {
|
|
126
|
+
(getAllBranches as ReturnType<typeof vi.fn>).mockResolvedValue(mockBranches);
|
|
127
|
+
(listAdditionalWorktrees as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
|
128
|
+
|
|
129
|
+
const onExit = vi.fn();
|
|
130
|
+
const { container } = render(<App onExit={onExit} />);
|
|
131
|
+
|
|
132
|
+
await waitFor(() => {
|
|
133
|
+
expect(container).toBeDefined();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Verify q key is NOT in the footer (main screen uses Ctrl+C for exit)
|
|
137
|
+
const footerText = container.textContent || '';
|
|
138
|
+
// Main screen should not have 'q' for quit, but should have other keys
|
|
139
|
+
expect(footerText.toLowerCase()).not.toMatch(/\[q\]/);
|
|
140
|
+
expect(footerText.toLowerCase()).toContain('enter');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* T076: Acceptance Scenario 3
|
|
145
|
+
* Worktree管理でアクション実行後に適切に遷移
|
|
146
|
+
*/
|
|
147
|
+
it('[AC3] should handle worktree management navigation', async () => {
|
|
148
|
+
(getAllBranches as ReturnType<typeof vi.fn>).mockResolvedValue(mockBranches);
|
|
149
|
+
(listAdditionalWorktrees as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
150
|
+
{
|
|
151
|
+
branch: 'feature/test',
|
|
152
|
+
path: '/path/to/worktree',
|
|
153
|
+
head: 'abc123',
|
|
154
|
+
isAccessible: true,
|
|
155
|
+
},
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
const onExit = vi.fn();
|
|
159
|
+
const { getByText, container } = render(<App onExit={onExit} />);
|
|
160
|
+
|
|
161
|
+
await waitFor(() => {
|
|
162
|
+
expect(getByText(/gwt - Branch Selection/i)).toBeDefined();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Verify m key action is available for worktree management
|
|
166
|
+
const mKeyElements = container.querySelectorAll('*');
|
|
167
|
+
let hasMKey = false;
|
|
168
|
+
mKeyElements.forEach((el) => {
|
|
169
|
+
if (el.textContent?.toLowerCase().includes('manage worktrees')) {
|
|
170
|
+
hasMKey = true;
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(hasMKey || container.textContent?.toLowerCase().includes('m')).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('[Integration] should support all navigation keys', async () => {
|
|
178
|
+
(getAllBranches as ReturnType<typeof vi.fn>).mockResolvedValue(mockBranches);
|
|
179
|
+
(listAdditionalWorktrees as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
|
180
|
+
|
|
181
|
+
const onExit = vi.fn();
|
|
182
|
+
const { getByText, getAllByText } = render(<App onExit={onExit} />);
|
|
183
|
+
|
|
184
|
+
await waitFor(() => {
|
|
185
|
+
expect(getByText(/gwt - Branch Selection/i)).toBeDefined();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Verify navigation keys are available (main screen doesn't have q key)
|
|
189
|
+
const enterKeys = getAllByText(/enter/i);
|
|
190
|
+
|
|
191
|
+
expect(enterKeys.length).toBeGreaterThan(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('[Integration] should display correct footer actions', async () => {
|
|
195
|
+
(getAllBranches as ReturnType<typeof vi.fn>).mockResolvedValue(mockBranches);
|
|
196
|
+
(listAdditionalWorktrees as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
|
197
|
+
|
|
198
|
+
const onExit = vi.fn();
|
|
199
|
+
const { container } = render(<App onExit={onExit} />);
|
|
200
|
+
|
|
201
|
+
await waitFor(() => {
|
|
202
|
+
expect(container).toBeDefined();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Verify footer has multiple action keys (main screen doesn't have q key)
|
|
206
|
+
const footerText = container.textContent || '';
|
|
207
|
+
expect(footerText.toLowerCase()).toContain('enter');
|
|
208
|
+
expect(footerText.toLowerCase()).toContain('m'); // Manage worktrees
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
afterAll(() => {
|
|
213
|
+
vi.restoreAllMocks();
|
|
214
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment happy-dom
|
|
3
|
+
* Acceptance tests for User Story 3: Realtime Statistics Update
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
6
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
7
|
+
import { useGitData } from '../../hooks/useGitData.js';
|
|
8
|
+
import { Window } from 'happy-dom';
|
|
9
|
+
import type { BranchInfo } from '../../types.js';
|
|
10
|
+
|
|
11
|
+
// Mock git.js and worktree.js
|
|
12
|
+
vi.mock('../../../git.js', () => ({
|
|
13
|
+
getAllBranches: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock('../../../worktree.js', () => ({
|
|
17
|
+
listAdditionalWorktrees: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
import { getAllBranches } from '../../../git.js';
|
|
21
|
+
import { listAdditionalWorktrees } from '../../../worktree.js';
|
|
22
|
+
|
|
23
|
+
describe('Acceptance: Realtime Update (User Story 3)', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
// Setup happy-dom
|
|
26
|
+
const window = new Window();
|
|
27
|
+
globalThis.window = window as any;
|
|
28
|
+
globalThis.document = window.document as any;
|
|
29
|
+
|
|
30
|
+
// Reset mocks
|
|
31
|
+
(getAllBranches as ReturnType<typeof vi.fn>).mockReset();
|
|
32
|
+
(listAdditionalWorktrees as ReturnType<typeof vi.fn>).mockReset();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* T085: Acceptance Scenario 1
|
|
37
|
+
* 別ターミナルでGit操作後、数秒以内に統計情報が更新される
|
|
38
|
+
*/
|
|
39
|
+
it('[AC1] should update statistics within seconds after Git operations', async () => {
|
|
40
|
+
// Simulate initial state
|
|
41
|
+
const initialBranches: BranchInfo[] = [
|
|
42
|
+
{ name: 'main', type: 'local', branchType: 'main', isCurrent: true },
|
|
43
|
+
{ name: 'feature/a', type: 'local', branchType: 'feature', isCurrent: false },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
let callCount = 0;
|
|
47
|
+
(getAllBranches as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
|
48
|
+
callCount++;
|
|
49
|
+
if (callCount === 1) {
|
|
50
|
+
return initialBranches;
|
|
51
|
+
}
|
|
52
|
+
// Simulate Git operation: new branch created
|
|
53
|
+
return [
|
|
54
|
+
...initialBranches,
|
|
55
|
+
{ name: 'feature/b', type: 'local', branchType: 'feature', isCurrent: false },
|
|
56
|
+
];
|
|
57
|
+
});
|
|
58
|
+
(listAdditionalWorktrees as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
|
59
|
+
|
|
60
|
+
// Enable auto-refresh with 1 second interval
|
|
61
|
+
const { result } = renderHook(() =>
|
|
62
|
+
useGitData({ enableAutoRefresh: true, refreshInterval: 1000 })
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// Wait for initial load
|
|
66
|
+
await waitFor(() => {
|
|
67
|
+
expect(result.current.loading).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(result.current.branches).toHaveLength(2);
|
|
71
|
+
const initialLastUpdated = result.current.lastUpdated;
|
|
72
|
+
|
|
73
|
+
// Simulate Git operation happening in another terminal
|
|
74
|
+
// Wait for auto-refresh (slightly more than 1 second)
|
|
75
|
+
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
76
|
+
|
|
77
|
+
// Statistics should be updated
|
|
78
|
+
await waitFor(
|
|
79
|
+
() => {
|
|
80
|
+
expect(result.current.branches).toHaveLength(3);
|
|
81
|
+
},
|
|
82
|
+
{ timeout: 2000 }
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
expect(result.current.branches[2].name).toBe('feature/b');
|
|
86
|
+
expect(result.current.lastUpdated!.getTime()).toBeGreaterThan(
|
|
87
|
+
initialLastUpdated!.getTime()
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* T086: Acceptance Scenario 2
|
|
93
|
+
* Worktree作成/削除後、統計情報が即座に更新される
|
|
94
|
+
*/
|
|
95
|
+
it('[AC2] should update statistics immediately after worktree operations', async () => {
|
|
96
|
+
const mockBranches: BranchInfo[] = [
|
|
97
|
+
{ name: 'main', type: 'local', branchType: 'main', isCurrent: true },
|
|
98
|
+
{ name: 'feature/test', type: 'local', branchType: 'feature', isCurrent: false },
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
let worktreeCallCount = 0;
|
|
102
|
+
(getAllBranches as ReturnType<typeof vi.fn>).mockResolvedValue(mockBranches);
|
|
103
|
+
(listAdditionalWorktrees as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
|
104
|
+
worktreeCallCount++;
|
|
105
|
+
if (worktreeCallCount === 1) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
// Simulate worktree creation
|
|
109
|
+
return [
|
|
110
|
+
{
|
|
111
|
+
branch: 'feature/test',
|
|
112
|
+
path: '/path/to/worktree',
|
|
113
|
+
head: 'abc123',
|
|
114
|
+
isAccessible: true,
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Enable auto-refresh with 500ms interval
|
|
120
|
+
const { result } = renderHook(() =>
|
|
121
|
+
useGitData({ enableAutoRefresh: true, refreshInterval: 500 })
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Wait for initial load
|
|
125
|
+
await waitFor(() => {
|
|
126
|
+
expect(result.current.loading).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(result.current.worktrees).toHaveLength(0);
|
|
130
|
+
|
|
131
|
+
// Simulate worktree creation in another terminal
|
|
132
|
+
// Wait for auto-refresh (slightly more than 500ms)
|
|
133
|
+
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
134
|
+
|
|
135
|
+
// Worktree statistics should be updated
|
|
136
|
+
await waitFor(
|
|
137
|
+
() => {
|
|
138
|
+
expect(result.current.worktrees).toHaveLength(1);
|
|
139
|
+
},
|
|
140
|
+
{ timeout: 1000 }
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
expect(result.current.worktrees[0].branch).toBe('feature/test');
|
|
144
|
+
expect(result.current.worktrees[0].path).toBe('/path/to/worktree');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Additional: Verify lastUpdated display behavior
|
|
149
|
+
*/
|
|
150
|
+
it('[AC3] should display lastUpdated timestamp after each refresh', async () => {
|
|
151
|
+
const mockBranches: BranchInfo[] = [
|
|
152
|
+
{ name: 'main', type: 'local', branchType: 'main', isCurrent: true },
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
(getAllBranches as ReturnType<typeof vi.fn>).mockResolvedValue(mockBranches);
|
|
156
|
+
(listAdditionalWorktrees as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
|
157
|
+
|
|
158
|
+
const { result } = renderHook(() =>
|
|
159
|
+
useGitData({ enableAutoRefresh: true, refreshInterval: 200 })
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// Wait for initial load
|
|
163
|
+
await waitFor(() => {
|
|
164
|
+
expect(result.current.loading).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const firstTimestamp = result.current.lastUpdated;
|
|
168
|
+
expect(firstTimestamp).toBeInstanceOf(Date);
|
|
169
|
+
|
|
170
|
+
// Wait for auto-refresh
|
|
171
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
172
|
+
|
|
173
|
+
await waitFor(() => {
|
|
174
|
+
expect(result.current.lastUpdated!.getTime()).toBeGreaterThan(firstTimestamp!.getTime());
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const secondTimestamp = result.current.lastUpdated;
|
|
178
|
+
expect(secondTimestamp).toBeInstanceOf(Date);
|
|
179
|
+
|
|
180
|
+
// Verify timestamps are different
|
|
181
|
+
expect(secondTimestamp!.getTime()).toBeGreaterThan(firstTimestamp!.getTime());
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Additional: Verify manual refresh updates lastUpdated
|
|
186
|
+
*/
|
|
187
|
+
it('[AC4] should update lastUpdated on manual refresh', async () => {
|
|
188
|
+
const mockBranches: BranchInfo[] = [
|
|
189
|
+
{ name: 'main', type: 'local', branchType: 'main', isCurrent: true },
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
(getAllBranches as ReturnType<typeof vi.fn>).mockResolvedValue(mockBranches);
|
|
193
|
+
(listAdditionalWorktrees as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
|
194
|
+
|
|
195
|
+
const { result } = renderHook(() => useGitData({ enableAutoRefresh: false }));
|
|
196
|
+
|
|
197
|
+
// Wait for initial load
|
|
198
|
+
await waitFor(() => {
|
|
199
|
+
expect(result.current.loading).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const firstTimestamp = result.current.lastUpdated;
|
|
203
|
+
expect(firstTimestamp).toBeInstanceOf(Date);
|
|
204
|
+
|
|
205
|
+
// Wait to ensure timestamp difference
|
|
206
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
207
|
+
|
|
208
|
+
// Manual refresh
|
|
209
|
+
result.current.refresh();
|
|
210
|
+
|
|
211
|
+
await waitFor(() => {
|
|
212
|
+
expect(result.current.loading).toBe(false);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const secondTimestamp = result.current.lastUpdated;
|
|
216
|
+
expect(secondTimestamp).toBeInstanceOf(Date);
|
|
217
|
+
expect(secondTimestamp!.getTime()).toBeGreaterThan(firstTimestamp!.getTime());
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment happy-dom
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from 'vitest';
|
|
5
|
+
import { act, render } from '@testing-library/react';
|
|
6
|
+
import React from 'react';
|
|
7
|
+
import { Window } from 'happy-dom';
|
|
8
|
+
import { App } from '../../components/App.js';
|
|
9
|
+
import type { BranchInfo, BranchItem } from '../../types.js';
|
|
10
|
+
import * as useGitDataModule from '../../hooks/useGitData.js';
|
|
11
|
+
import * as useScreenStateModule from '../../hooks/useScreenState.js';
|
|
12
|
+
import * as BranchListScreenModule from '../../components/screens/BranchListScreen.js';
|
|
13
|
+
import * as BranchActionSelectorScreenModule from '../../screens/BranchActionSelectorScreen.js';
|
|
14
|
+
import * as worktreeModule from '../../../../worktree.ts';
|
|
15
|
+
import * as gitModule from '../../../../git.ts';
|
|
16
|
+
import type { ScreenType } from '../../types.js';
|
|
17
|
+
|
|
18
|
+
const navigateToMock = vi.fn();
|
|
19
|
+
const goBackMock = vi.fn();
|
|
20
|
+
const resetMock = vi.fn();
|
|
21
|
+
|
|
22
|
+
const originalUseGitData = useGitDataModule.useGitData;
|
|
23
|
+
const originalUseScreenState = useScreenStateModule.useScreenState;
|
|
24
|
+
const originalBranchListScreen = BranchListScreenModule.BranchListScreen;
|
|
25
|
+
const originalBranchActionSelector = BranchActionSelectorScreenModule.BranchActionSelectorScreen;
|
|
26
|
+
const originalGetRepositoryRoot = gitModule.getRepositoryRoot;
|
|
27
|
+
|
|
28
|
+
const useGitDataSpy = vi.spyOn(useGitDataModule, 'useGitData');
|
|
29
|
+
const useScreenStateSpy = vi.spyOn(useScreenStateModule, 'useScreenState');
|
|
30
|
+
const branchListScreenSpy = vi.spyOn(BranchListScreenModule, 'BranchListScreen');
|
|
31
|
+
const branchActionSelectorSpy = vi.spyOn(BranchActionSelectorScreenModule, 'BranchActionSelectorScreen');
|
|
32
|
+
const switchToProtectedBranchSpy = vi.spyOn(worktreeModule, 'switchToProtectedBranch');
|
|
33
|
+
const getRepositoryRootSpy = vi.spyOn(gitModule, 'getRepositoryRoot');
|
|
34
|
+
|
|
35
|
+
const branchListProps: any[] = [];
|
|
36
|
+
const branchActionProps: any[] = [];
|
|
37
|
+
const aiToolProps: any[] = [];
|
|
38
|
+
let currentScreenState: ScreenType;
|
|
39
|
+
|
|
40
|
+
vi.mock('../../components/screens/AIToolSelectorScreen.js', () => {
|
|
41
|
+
return {
|
|
42
|
+
AIToolSelectorScreen: (props: unknown) => {
|
|
43
|
+
aiToolProps.push(props);
|
|
44
|
+
return React.createElement('div');
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('App protected branch handling', () => {
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
const window = new Window();
|
|
52
|
+
globalThis.window = window as any;
|
|
53
|
+
globalThis.document = window.document as any;
|
|
54
|
+
|
|
55
|
+
currentScreenState = 'branch-list';
|
|
56
|
+
navigateToMock.mockReset();
|
|
57
|
+
goBackMock.mockReset();
|
|
58
|
+
resetMock.mockReset();
|
|
59
|
+
branchListProps.length = 0;
|
|
60
|
+
branchActionProps.length = 0;
|
|
61
|
+
aiToolProps.length = 0;
|
|
62
|
+
|
|
63
|
+
useGitDataSpy.mockReset();
|
|
64
|
+
switchToProtectedBranchSpy.mockReset();
|
|
65
|
+
getRepositoryRootSpy.mockReset();
|
|
66
|
+
|
|
67
|
+
useScreenStateSpy.mockImplementation(() => ({
|
|
68
|
+
currentScreen: currentScreenState,
|
|
69
|
+
navigateTo: (screen: ScreenType) => {
|
|
70
|
+
navigateToMock(screen);
|
|
71
|
+
currentScreenState = screen;
|
|
72
|
+
},
|
|
73
|
+
goBack: goBackMock,
|
|
74
|
+
reset: () => {
|
|
75
|
+
resetMock();
|
|
76
|
+
currentScreenState = 'branch-list';
|
|
77
|
+
},
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
branchListScreenSpy.mockImplementation((props: any) => {
|
|
81
|
+
branchListProps.push(props);
|
|
82
|
+
return React.createElement(originalBranchListScreen, props);
|
|
83
|
+
});
|
|
84
|
+
branchActionSelectorSpy.mockImplementation((props: any) => {
|
|
85
|
+
branchActionProps.push(props);
|
|
86
|
+
return React.createElement(originalBranchActionSelector, props);
|
|
87
|
+
});
|
|
88
|
+
switchToProtectedBranchSpy.mockResolvedValue('local');
|
|
89
|
+
getRepositoryRootSpy.mockResolvedValue('/repo');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
useGitDataSpy.mockReset();
|
|
94
|
+
useGitDataSpy.mockImplementation(originalUseGitData);
|
|
95
|
+
useScreenStateSpy.mockReset();
|
|
96
|
+
useScreenStateSpy.mockImplementation(originalUseScreenState);
|
|
97
|
+
branchListScreenSpy.mockImplementation(originalBranchListScreen as any);
|
|
98
|
+
branchActionSelectorSpy.mockImplementation(originalBranchActionSelector as any);
|
|
99
|
+
switchToProtectedBranchSpy.mockReset();
|
|
100
|
+
getRepositoryRootSpy.mockReset();
|
|
101
|
+
branchActionProps.length = 0;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
afterAll(() => {
|
|
105
|
+
useGitDataSpy.mockRestore();
|
|
106
|
+
useScreenStateSpy.mockRestore();
|
|
107
|
+
branchListScreenSpy.mockRestore();
|
|
108
|
+
branchActionSelectorSpy.mockRestore();
|
|
109
|
+
switchToProtectedBranchSpy.mockRestore();
|
|
110
|
+
getRepositoryRootSpy.mockRestore();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('shows protected branch warning and switches root without launching AI tool', async () => {
|
|
114
|
+
const branches: BranchInfo[] = [
|
|
115
|
+
{
|
|
116
|
+
name: 'main',
|
|
117
|
+
type: 'local',
|
|
118
|
+
branchType: 'main',
|
|
119
|
+
isCurrent: false,
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: 'feature/example',
|
|
123
|
+
type: 'local',
|
|
124
|
+
branchType: 'feature',
|
|
125
|
+
isCurrent: true,
|
|
126
|
+
},
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
useGitDataSpy.mockImplementation(() => ({
|
|
130
|
+
branches,
|
|
131
|
+
worktrees: [],
|
|
132
|
+
loading: false,
|
|
133
|
+
error: null,
|
|
134
|
+
refresh: vi.fn(),
|
|
135
|
+
lastUpdated: null,
|
|
136
|
+
}));
|
|
137
|
+
|
|
138
|
+
render(<App onExit={vi.fn()} />);
|
|
139
|
+
|
|
140
|
+
expect(branchListProps).not.toHaveLength(0);
|
|
141
|
+
const latestProps = branchListProps.at(-1);
|
|
142
|
+
expect(latestProps).toBeDefined();
|
|
143
|
+
if (!latestProps) {
|
|
144
|
+
throw new Error('BranchListScreen props missing');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const protectedBranch = (latestProps.branches as BranchItem[]).find(
|
|
148
|
+
(item) => item.name === 'main'
|
|
149
|
+
);
|
|
150
|
+
expect(protectedBranch).toBeDefined();
|
|
151
|
+
if (!protectedBranch) {
|
|
152
|
+
throw new Error('Protected branch item not found');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await act(async () => {
|
|
156
|
+
latestProps.onSelect(protectedBranch);
|
|
157
|
+
await Promise.resolve();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(navigateToMock).toHaveBeenCalledWith('branch-action-selector');
|
|
161
|
+
expect(branchActionProps).not.toHaveLength(0);
|
|
162
|
+
const actionProps = branchActionProps.at(-1);
|
|
163
|
+
expect(actionProps?.mode).toBe('protected');
|
|
164
|
+
expect(actionProps?.infoMessage).toContain('is a root branch');
|
|
165
|
+
expect(actionProps?.primaryLabel).toBe('Use root branch (no worktree)');
|
|
166
|
+
expect(actionProps?.secondaryLabel).toBe('Create new branch from this branch');
|
|
167
|
+
|
|
168
|
+
await act(async () => {
|
|
169
|
+
actionProps?.onUseExisting();
|
|
170
|
+
await Promise.resolve();
|
|
171
|
+
await Promise.resolve();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(switchToProtectedBranchSpy).toHaveBeenCalledWith({
|
|
175
|
+
branchName: 'main',
|
|
176
|
+
repoRoot: expect.any(String),
|
|
177
|
+
remoteRef: null,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(navigateToMock).toHaveBeenCalledWith('ai-tool-selector');
|
|
181
|
+
expect(aiToolProps).not.toHaveLength(0);
|
|
182
|
+
});
|
|
183
|
+
});
|