Skip to content

Commit bc32826

Browse files
frankbriaTest User
andauthored
feat(ui): add Git visualization components (#272) (#283)
* feat(ui): add Git visualization components (#272) Add Git branch and commit visualization to the Dashboard Overview tab: - Add Git types (GitBranch, GitCommit, GitStatus, GitState) - Add Git API client for consuming endpoints from ticket #270 - Add 7 Git reducer actions for state management - Create GitBranchIndicator component (current branch with dirty indicator) - Create CommitHistory component (recent commits with relative timestamps) - Create BranchList component (branches with status badges) - Create GitSection container with SWR data fetching (30s refresh) - Integrate GitSection in Dashboard Overview tab (active/review/complete phases) - Add WebSocket handlers for commit_created and branch_created events Test coverage: 74 new tests (all passing) * fix(ui): address review feedback for Git visualization (#272) - Add null checks in WebSocket commit_created/branch_created handlers - Add defensive checks in reducer for gitState arrays - Remove invalid ARIA role from CommitHistory time element - Fix SWR cache key to include maxCommits for proper invalidation - Update JSDoc for getBranch to clarify encoding behavior - Add visibility assertion in GitBranchIndicator test - Add 10 new tests for message validation --------- Co-authored-by: Test User <[email protected]>
1 parent 1383cb8 commit bc32826

21 files changed

+2751
-4
lines changed

web-ui/__tests__/api/git.test.ts

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/**
2+
* Git API Client Tests
3+
*
4+
* Tests for the Git API client functions.
5+
* Uses mocked fetch to verify correct API calls.
6+
*/
7+
8+
import {
9+
getGitStatus,
10+
getCommits,
11+
getBranches,
12+
getBranch,
13+
} from '@/api/git';
14+
import type { GitStatus, GitCommit, GitBranch } from '@/types/git';
15+
16+
// Mock localStorage
17+
const mockLocalStorage = {
18+
getItem: jest.fn((): string | null => 'mock-auth-token'),
19+
setItem: jest.fn(),
20+
removeItem: jest.fn(),
21+
clear: jest.fn(),
22+
length: 0,
23+
key: jest.fn(),
24+
};
25+
Object.defineProperty(window, 'localStorage', { value: mockLocalStorage });
26+
27+
// Mock fetch globally
28+
const mockFetch = jest.fn();
29+
global.fetch = mockFetch;
30+
31+
describe('Git API Client', () => {
32+
beforeEach(() => {
33+
jest.clearAllMocks();
34+
mockLocalStorage.getItem.mockReturnValue('mock-auth-token');
35+
});
36+
37+
describe('getGitStatus', () => {
38+
it('should fetch git status for a project', async () => {
39+
const mockStatus: GitStatus = {
40+
current_branch: 'feature/auth',
41+
is_dirty: false,
42+
modified_files: [],
43+
untracked_files: [],
44+
staged_files: [],
45+
};
46+
47+
mockFetch.mockResolvedValueOnce({
48+
ok: true,
49+
text: () => Promise.resolve(JSON.stringify(mockStatus)),
50+
});
51+
52+
const result = await getGitStatus(123);
53+
54+
expect(mockFetch).toHaveBeenCalledWith(
55+
expect.stringContaining('/api/projects/123/git/status'),
56+
expect.objectContaining({
57+
headers: expect.objectContaining({
58+
'Authorization': 'Bearer mock-auth-token',
59+
}),
60+
})
61+
);
62+
expect(result).toEqual(mockStatus);
63+
});
64+
65+
it('should throw error when not authenticated', async () => {
66+
mockLocalStorage.getItem.mockReturnValueOnce(null);
67+
68+
await expect(getGitStatus(123)).rejects.toThrow('Not authenticated');
69+
});
70+
71+
it('should throw error on API failure', async () => {
72+
mockFetch.mockResolvedValueOnce({
73+
ok: false,
74+
status: 404,
75+
text: () => Promise.resolve('Not found'),
76+
});
77+
78+
await expect(getGitStatus(123)).rejects.toThrow();
79+
});
80+
});
81+
82+
describe('getCommits', () => {
83+
it('should fetch commits with default limit', async () => {
84+
const mockCommits: GitCommit[] = [
85+
{
86+
hash: 'abc123def456',
87+
short_hash: 'abc123d',
88+
message: 'feat: Add login',
89+
author: 'Agent',
90+
timestamp: '2025-01-01T00:00:00Z',
91+
files_changed: 3,
92+
},
93+
];
94+
95+
mockFetch.mockResolvedValueOnce({
96+
ok: true,
97+
text: () => Promise.resolve(JSON.stringify({ commits: mockCommits })),
98+
});
99+
100+
const result = await getCommits(123);
101+
102+
expect(mockFetch).toHaveBeenCalledWith(
103+
expect.stringContaining('/api/projects/123/git/commits'),
104+
expect.any(Object)
105+
);
106+
expect(result).toEqual(mockCommits);
107+
});
108+
109+
it('should fetch commits with custom limit', async () => {
110+
mockFetch.mockResolvedValueOnce({
111+
ok: true,
112+
text: () => Promise.resolve(JSON.stringify({ commits: [] })),
113+
});
114+
115+
await getCommits(123, { limit: 5 });
116+
117+
expect(mockFetch).toHaveBeenCalledWith(
118+
expect.stringContaining('limit=5'),
119+
expect.any(Object)
120+
);
121+
});
122+
123+
it('should fetch commits for specific branch', async () => {
124+
mockFetch.mockResolvedValueOnce({
125+
ok: true,
126+
text: () => Promise.resolve(JSON.stringify({ commits: [] })),
127+
});
128+
129+
await getCommits(123, { branch: 'feature/test' });
130+
131+
expect(mockFetch).toHaveBeenCalledWith(
132+
expect.stringContaining('branch=feature%2Ftest'),
133+
expect.any(Object)
134+
);
135+
});
136+
});
137+
138+
describe('getBranches', () => {
139+
it('should fetch branches with default status filter', async () => {
140+
const mockBranches: GitBranch[] = [
141+
{
142+
id: 1,
143+
branch_name: 'feature/auth',
144+
issue_id: 10,
145+
status: 'active',
146+
created_at: '2025-01-01T00:00:00Z',
147+
},
148+
];
149+
150+
mockFetch.mockResolvedValueOnce({
151+
ok: true,
152+
text: () => Promise.resolve(JSON.stringify({ branches: mockBranches })),
153+
});
154+
155+
const result = await getBranches(123);
156+
157+
expect(mockFetch).toHaveBeenCalledWith(
158+
expect.stringContaining('/api/projects/123/git/branches'),
159+
expect.any(Object)
160+
);
161+
expect(result).toEqual(mockBranches);
162+
});
163+
164+
it('should filter branches by status', async () => {
165+
mockFetch.mockResolvedValueOnce({
166+
ok: true,
167+
text: () => Promise.resolve(JSON.stringify({ branches: [] })),
168+
});
169+
170+
await getBranches(123, 'merged');
171+
172+
expect(mockFetch).toHaveBeenCalledWith(
173+
expect.stringContaining('status=merged'),
174+
expect.any(Object)
175+
);
176+
});
177+
});
178+
179+
describe('getBranch', () => {
180+
it('should fetch single branch by name', async () => {
181+
const mockBranch: GitBranch = {
182+
id: 1,
183+
branch_name: 'feature/auth',
184+
issue_id: 10,
185+
status: 'active',
186+
created_at: '2025-01-01T00:00:00Z',
187+
};
188+
189+
mockFetch.mockResolvedValueOnce({
190+
ok: true,
191+
text: () => Promise.resolve(JSON.stringify(mockBranch)),
192+
});
193+
194+
const result = await getBranch(123, 'feature/auth');
195+
196+
expect(mockFetch).toHaveBeenCalledWith(
197+
expect.stringContaining('/api/projects/123/git/branches/feature%2Fauth'),
198+
expect.any(Object)
199+
);
200+
expect(result).toEqual(mockBranch);
201+
});
202+
203+
it('should handle branch not found', async () => {
204+
mockFetch.mockResolvedValueOnce({
205+
ok: false,
206+
status: 404,
207+
text: () => Promise.resolve('Branch not found'),
208+
});
209+
210+
await expect(getBranch(123, 'nonexistent')).rejects.toThrow();
211+
});
212+
});
213+
});
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* BranchList Component Tests
3+
*
4+
* Tests for the branch list component that displays
5+
* all git branches with status indicators.
6+
*/
7+
8+
import React from 'react';
9+
import { render, screen } from '@testing-library/react';
10+
import BranchList from '@/components/git/BranchList';
11+
import type { GitBranch } from '@/types/git';
12+
13+
const mockBranches: GitBranch[] = [
14+
{
15+
id: 1,
16+
branch_name: 'feature/auth',
17+
issue_id: 10,
18+
status: 'active',
19+
created_at: '2025-01-01T00:00:00Z',
20+
},
21+
{
22+
id: 2,
23+
branch_name: 'feature/dashboard',
24+
issue_id: 20,
25+
status: 'merged',
26+
created_at: '2025-01-01T00:00:00Z',
27+
merged_at: '2025-01-02T00:00:00Z',
28+
merge_commit: 'abc123',
29+
},
30+
{
31+
id: 3,
32+
branch_name: 'feature/abandoned',
33+
issue_id: 30,
34+
status: 'abandoned',
35+
created_at: '2025-01-01T00:00:00Z',
36+
},
37+
];
38+
39+
describe('BranchList', () => {
40+
describe('rendering', () => {
41+
it('should render empty state when no branches', () => {
42+
render(<BranchList branches={[]} />);
43+
44+
expect(screen.getByText(/no branches/i)).toBeInTheDocument();
45+
});
46+
47+
it('should render list of branches', () => {
48+
render(<BranchList branches={mockBranches} />);
49+
50+
expect(screen.getByText('feature/auth')).toBeInTheDocument();
51+
expect(screen.getByText('feature/dashboard')).toBeInTheDocument();
52+
expect(screen.getByText('feature/abandoned')).toBeInTheDocument();
53+
});
54+
55+
it('should render branch count in header', () => {
56+
render(<BranchList branches={mockBranches} />);
57+
58+
expect(screen.getByText(/\(3\)/)).toBeInTheDocument();
59+
});
60+
});
61+
62+
describe('status badges', () => {
63+
it('should show active status badge', () => {
64+
render(<BranchList branches={[mockBranches[0]]} />);
65+
66+
expect(screen.getByText('active')).toBeInTheDocument();
67+
});
68+
69+
it('should show merged status badge', () => {
70+
render(<BranchList branches={[mockBranches[1]]} />);
71+
72+
expect(screen.getByText('merged')).toBeInTheDocument();
73+
});
74+
75+
it('should show abandoned status badge', () => {
76+
render(<BranchList branches={[mockBranches[2]]} />);
77+
78+
expect(screen.getByText('abandoned')).toBeInTheDocument();
79+
});
80+
81+
it('should use correct styling for active branch', () => {
82+
render(<BranchList branches={[mockBranches[0]]} />);
83+
84+
const badge = screen.getByText('active');
85+
expect(badge).toHaveClass('bg-primary/10');
86+
});
87+
88+
it('should use correct styling for merged branch', () => {
89+
render(<BranchList branches={[mockBranches[1]]} />);
90+
91+
const badge = screen.getByText('merged');
92+
expect(badge).toHaveClass('bg-secondary/10');
93+
});
94+
95+
it('should use correct styling for abandoned branch', () => {
96+
render(<BranchList branches={[mockBranches[2]]} />);
97+
98+
const badge = screen.getByText('abandoned');
99+
expect(badge).toHaveClass('bg-muted');
100+
});
101+
});
102+
103+
describe('merged branch info', () => {
104+
it('should show merge commit for merged branches', () => {
105+
render(<BranchList branches={[mockBranches[1]]} />);
106+
107+
expect(screen.getByText(/abc123/i)).toBeInTheDocument();
108+
});
109+
});
110+
111+
describe('loading state', () => {
112+
it('should show loading state', () => {
113+
render(<BranchList branches={[]} isLoading={true} />);
114+
115+
expect(screen.getByTestId('branches-loading')).toBeInTheDocument();
116+
});
117+
});
118+
119+
describe('error state', () => {
120+
it('should show error message', () => {
121+
render(<BranchList branches={[]} error="Failed to load branches" />);
122+
123+
expect(screen.getByText(/failed to load branches/i)).toBeInTheDocument();
124+
});
125+
});
126+
127+
describe('filtering', () => {
128+
it('should filter by status when provided', () => {
129+
render(<BranchList branches={mockBranches} filterStatus="active" />);
130+
131+
expect(screen.getByText('feature/auth')).toBeInTheDocument();
132+
expect(screen.queryByText('feature/dashboard')).not.toBeInTheDocument();
133+
});
134+
});
135+
});

0 commit comments

Comments
 (0)