Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 57 additions & 28 deletions web-ui/__tests__/components/Dashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,34 +11,63 @@ import * as api from '@/lib/api';
import * as websocket from '@/lib/websocket';
import * as agentAssignment from '@/api/agentAssignment';

// Mock Hugeicons (used by PhaseProgress, Dashboard, and SessionStatus components)
jest.mock('@hugeicons/react', () => ({
// PhaseProgress icons
Search01Icon: ({ className }: { className?: string }) => <svg className={className} data-testid="search-icon" />,
TaskEdit01Icon: ({ className }: { className?: string }) => <svg className={className} data-testid="task-edit-icon" />,
Wrench01Icon: ({ className }: { className?: string }) => <svg className={className} data-testid="wrench-icon" />,
CheckmarkCircle01Icon: ({ className }: { className?: string }) => <svg className={className} data-testid="checkmark-circle-icon" />,
Award01Icon: ({ className }: { className?: string }) => <svg className={className} data-testid="award-icon" />,
RocketIcon: ({ className }: { className?: string }) => <svg className={className} data-testid="rocket-icon" />,
HelpCircleIcon: ({ className }: { className?: string }) => <svg className={className} data-testid="help-icon" />,
Idea01Icon: ({ className }: { className?: string }) => <svg className={className} data-testid="idea-icon" />,
// Dashboard section header icons
UserGroupIcon: ({ className }: { className?: string }) => <svg className={className} data-testid="user-group-icon" />,
Loading03Icon: ({ className }: { className?: string }) => <svg className={className} data-testid="loading-icon" />,
WorkHistoryIcon: ({ className }: { className?: string }) => <svg className={className} data-testid="work-history-icon" />,
AnalyticsUpIcon: ({ className }: { className?: string }) => <svg className={className} data-testid="analytics-icon" />,
ClipboardIcon: ({ className }: { className?: string }) => <svg className={className} data-testid="clipboard-icon" />,
CheckListIcon: ({ className }: { className?: string }) => <svg className={className} data-testid="checklist-icon" />,
Target02Icon: ({ className }: { className?: string }) => <svg className={className} data-testid="target-icon" />,
FloppyDiskIcon: ({ className }: { className?: string }) => <svg className={className} data-testid="floppy-icon" />,
// Activity item icons
TestTube01Icon: ({ className }: { className?: string }) => <svg className={className} data-testid="test-tube-icon" />,
Alert02Icon: ({ className }: { className?: string }) => <svg className={className} data-testid="alert-icon" />,
CheckmarkSquare01Icon: ({ className }: { className?: string }) => <svg className={className} data-testid="checkmark-square-icon" />,
BotIcon: ({ className }: { className?: string }) => <svg className={className} data-testid="bot-icon" />,
Logout02Icon: ({ className }: { className?: string }) => <svg className={className} data-testid="logout-icon" />,
GitCommitIcon: ({ className }: { className?: string }) => <svg className={className} data-testid="git-commit-icon" />,
}));
// Mock Hugeicons (used by PhaseProgress, Dashboard, SessionStatus, and UI components)
// This mock must include ALL icons used by Dashboard and its children
jest.mock('@hugeicons/react', () => {
const createMockIcon = (name: string) => {
const Icon = ({ className }: { className?: string }) => (
<svg className={className} data-testid={name} />
);
Icon.displayName = name;
return Icon;
};

return {
// PhaseProgress icons
Search01Icon: createMockIcon('search-icon'),
TaskEdit01Icon: createMockIcon('task-edit-icon'),
Wrench01Icon: createMockIcon('wrench-icon'),
Award01Icon: createMockIcon('award-icon'),
RocketIcon: createMockIcon('rocket-icon'),
HelpCircleIcon: createMockIcon('help-icon'),
Idea01Icon: createMockIcon('idea-icon'),
// Dashboard section header icons
UserGroupIcon: createMockIcon('user-group-icon'),
Loading03Icon: createMockIcon('loading-icon'),
WorkHistoryIcon: createMockIcon('work-history-icon'),
AnalyticsUpIcon: createMockIcon('analytics-icon'),
ClipboardIcon: createMockIcon('clipboard-icon'),
CheckListIcon: createMockIcon('checklist-icon'),
Target02Icon: createMockIcon('target-icon'),
FloppyDiskIcon: createMockIcon('floppy-icon'),
GitPullRequestIcon: createMockIcon('git-pull-request-icon'),
// Activity item icons
TestTube01Icon: createMockIcon('test-tube-icon'),
Alert02Icon: createMockIcon('alert-icon'),
CheckmarkSquare01Icon: createMockIcon('checkmark-square-icon'),
CheckmarkCircle01Icon: createMockIcon('checkmark-circle-icon'),
BotIcon: createMockIcon('bot-icon'),
Logout02Icon: createMockIcon('logout-icon'),
GitCommitIcon: createMockIcon('git-commit-icon'),
// UI component icons (select, checkbox, dialog, radio-group)
Tick01Icon: createMockIcon('tick-icon'),
ArrowDown01Icon: createMockIcon('arrow-down-icon'),
ArrowUp01Icon: createMockIcon('arrow-up-icon'),
Cancel01Icon: createMockIcon('cancel-icon'),
CircleIcon: createMockIcon('circle-icon'),
// PR component icons
AlertCircleIcon: createMockIcon('alert-circle-icon'),
GitBranchIcon: createMockIcon('git-branch-icon'),
ArrowRight01Icon: createMockIcon('arrow-right-icon'),
Link01Icon: createMockIcon('link-icon'),
Add01Icon: createMockIcon('add-icon'),
Cancel02Icon: createMockIcon('cancel02-icon'),
Time01Icon: createMockIcon('time-icon'),
// Other icons
Download01Icon: createMockIcon('download-icon'),
AlertDiamondIcon: createMockIcon('alert-diamond-icon'),
};
});

// Create a shared mock WebSocket client that will be used across all tests
const sharedMockWsClient = {
Expand Down
247 changes: 247 additions & 0 deletions web-ui/__tests__/components/PRCreationDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
/**
* Tests for PRCreationDialog Component
* TDD: Tests written first to define expected behavior
*/

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import PRCreationDialog from '@/components/pr/PRCreationDialog';

// Mock the API
jest.mock('@/lib/api', () => ({
pullRequestsApi: {
create: jest.fn().mockResolvedValue({
data: {
pr_id: 1,
pr_number: 42,
pr_url: 'https://github.com/org/repo/pull/42',
status: 'open',
},
}),
},
}));

// Mock git API for branch list
jest.mock('@/api/git', () => ({
gitApi: {
getBranches: jest.fn().mockResolvedValue({
data: {
branches: [
{ id: 1, branch_name: 'feature/auth', status: 'active' },
{ id: 2, branch_name: 'feature/dashboard', status: 'active' },
{ id: 3, branch_name: 'main', status: 'active' },
],
},
}),
},
}));

describe('PRCreationDialog', () => {
const defaultProps = {
projectId: 1,
isOpen: true,
onClose: jest.fn(),
onSuccess: jest.fn(),
defaultBranch: 'feature/auth',
defaultTitle: 'Add authentication feature',
defaultDescription: 'Implements OAuth 2.0 login flow',
};

beforeEach(() => {
jest.clearAllMocks();
});

describe('Rendering', () => {
it('should render the dialog when open', () => {
render(<PRCreationDialog {...defaultProps} />);

expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText(/create pull request/i)).toBeInTheDocument();
});

it('should not render when closed', () => {
render(<PRCreationDialog {...defaultProps} isOpen={false} />);

expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});

it('should pre-fill form with default values', () => {
render(<PRCreationDialog {...defaultProps} />);

expect(screen.getByDisplayValue('Add authentication feature')).toBeInTheDocument();
expect(screen.getByDisplayValue('Implements OAuth 2.0 login flow')).toBeInTheDocument();
});
});

describe('Form Fields', () => {
it('should have title input field', () => {
render(<PRCreationDialog {...defaultProps} />);

const titleInput = screen.getByLabelText(/title/i);
expect(titleInput).toBeInTheDocument();
expect(titleInput).toHaveAttribute('type', 'text');
});

it('should have description textarea', () => {
render(<PRCreationDialog {...defaultProps} />);

const descriptionInput = screen.getByLabelText(/description/i);
expect(descriptionInput).toBeInTheDocument();
expect(descriptionInput.tagName.toLowerCase()).toBe('textarea');
});

it('should have branch selector', () => {
render(<PRCreationDialog {...defaultProps} />);

expect(screen.getByLabelText(/source branch/i)).toBeInTheDocument();
});

it('should have base branch selector with main as default', () => {
render(<PRCreationDialog {...defaultProps} />);

const baseBranchSelect = screen.getByLabelText(/target branch/i);
expect(baseBranchSelect).toBeInTheDocument();
});
});

describe('Form Validation', () => {
it('should require title', async () => {
const user = userEvent.setup();
render(<PRCreationDialog {...defaultProps} defaultTitle="" />);

const submitButton = screen.getByRole('button', { name: /create/i });
await user.click(submitButton);

expect(screen.getByText(/title is required/i)).toBeInTheDocument();
});

it('should require branch selection', async () => {
const user = userEvent.setup();
render(<PRCreationDialog {...defaultProps} defaultBranch="" />);

const submitButton = screen.getByRole('button', { name: /create/i });
await user.click(submitButton);

expect(screen.getByText(/branch is required/i)).toBeInTheDocument();
});
});

describe('Form Submission', () => {
it('should call API with form data on submit', async () => {
const user = userEvent.setup();
const { pullRequestsApi } = jest.requireMock('@/lib/api');

render(<PRCreationDialog {...defaultProps} />);

const submitButton = screen.getByRole('button', { name: /create/i });
await user.click(submitButton);

await waitFor(() => {
expect(pullRequestsApi.create).toHaveBeenCalledWith(1, {
branch: 'feature/auth',
title: 'Add authentication feature',
body: 'Implements OAuth 2.0 login flow',
base: 'main',
});
});
});

it('should call onSuccess after successful creation', async () => {
const user = userEvent.setup();
render(<PRCreationDialog {...defaultProps} />);

const submitButton = screen.getByRole('button', { name: /create/i });
await user.click(submitButton);

await waitFor(() => {
expect(defaultProps.onSuccess).toHaveBeenCalled();
});
});

it('should close dialog after successful creation', async () => {
const user = userEvent.setup();
render(<PRCreationDialog {...defaultProps} />);

const submitButton = screen.getByRole('button', { name: /create/i });
await user.click(submitButton);

await waitFor(() => {
expect(defaultProps.onClose).toHaveBeenCalled();
});
});

it('should show loading state during submission', async () => {
// Make the API call take time to show loading state
const { pullRequestsApi } = jest.requireMock('@/lib/api');
let resolvePromise: (value: unknown) => void;
pullRequestsApi.create.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolvePromise = resolve;
})
);

const user = userEvent.setup();
render(<PRCreationDialog {...defaultProps} />);

const submitButton = screen.getByRole('button', { name: /create/i });
await user.click(submitButton);

// Button should show loading state
await waitFor(() => {
expect(screen.getByRole('button', { name: /creating/i })).toBeInTheDocument();
});

// Cleanup: resolve the promise
resolvePromise!({
data: { pr_id: 1, pr_number: 42, pr_url: 'https://github.com/org/repo/pull/42', status: 'open' },
});
});
});

describe('Error Handling', () => {
it('should display error message on API failure', async () => {
const user = userEvent.setup();
const { pullRequestsApi } = jest.requireMock('@/lib/api');
pullRequestsApi.create.mockRejectedValueOnce(new Error('Branch already has an open PR'));

render(<PRCreationDialog {...defaultProps} />);

const submitButton = screen.getByRole('button', { name: /create/i });
await user.click(submitButton);

await waitFor(() => {
expect(screen.getByText(/branch already has an open pr/i)).toBeInTheDocument();
});
});
});

describe('Cancel Action', () => {
it('should call onClose when cancel button is clicked', async () => {
const user = userEvent.setup();
render(<PRCreationDialog {...defaultProps} />);

const cancelButton = screen.getByRole('button', { name: /cancel/i });
await user.click(cancelButton);

expect(defaultProps.onClose).toHaveBeenCalled();
});

it('should reset form when reopened', async () => {
const user = userEvent.setup();
const { rerender } = render(<PRCreationDialog {...defaultProps} />);

// Modify title
const titleInput = screen.getByLabelText(/title/i);
await user.clear(titleInput);
await user.type(titleInput, 'Modified title');

// Close and reopen
rerender(<PRCreationDialog {...defaultProps} isOpen={false} />);
rerender(<PRCreationDialog {...defaultProps} isOpen={true} />);

// Should have original default value
expect(screen.getByDisplayValue('Add authentication feature')).toBeInTheDocument();
});
});
});
Loading
Loading