feat(frontend): implement agent configuration pages (#41)
- Add agent types list page with search and filter functionality - Add agent type detail/edit page with tabbed interface - Create AgentTypeForm component with React Hook Form + Zod validation - Implement model configuration (temperature, max tokens, top_p) - Add MCP permission management with checkboxes - Include personality prompt editor textarea - Create TanStack Query hooks for agent-types API - Add useDebounce hook for search optimization - Comprehensive unit tests for all components (68 tests) Components: - AgentTypeList: Grid view with status badges, expertise tags - AgentTypeDetail: Full detail view with model config, MCP permissions - AgentTypeForm: Create/edit with 4 tabs (Basic, Model, Permissions, Personality) Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
223
frontend/tests/components/agents/AgentTypeDetail.test.tsx
Normal file
223
frontend/tests/components/agents/AgentTypeDetail.test.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { AgentTypeDetail } from '@/components/agents/AgentTypeDetail';
|
||||
import type { AgentTypeResponse } from '@/lib/api/types/agentTypes';
|
||||
|
||||
const mockAgentType: AgentTypeResponse = {
|
||||
id: 'type-001',
|
||||
name: 'Software Architect',
|
||||
slug: 'software-architect',
|
||||
description: 'Designs system architecture and makes technology decisions',
|
||||
expertise: ['system design', 'api design', 'security', 'scalability'],
|
||||
personality_prompt: `You are a Senior Software Architect with 15+ years of experience.
|
||||
|
||||
Your approach is:
|
||||
1. Pragmatic: You favor proven solutions
|
||||
2. Security-minded: Security is a first-class concern
|
||||
3. Documentation-focused: You believe in ADRs`,
|
||||
primary_model: 'claude-opus-4-5-20251101',
|
||||
fallback_models: ['claude-sonnet-4-20250514'],
|
||||
model_params: { temperature: 0.7, max_tokens: 8192, top_p: 0.95 },
|
||||
mcp_servers: ['gitea', 'knowledge', 'filesystem'],
|
||||
tool_permissions: {},
|
||||
is_active: true,
|
||||
created_at: '2025-01-10T00:00:00Z',
|
||||
updated_at: '2025-01-18T00:00:00Z',
|
||||
instance_count: 2,
|
||||
};
|
||||
|
||||
describe('AgentTypeDetail', () => {
|
||||
const defaultProps = {
|
||||
agentType: mockAgentType,
|
||||
isLoading: false,
|
||||
onBack: jest.fn(),
|
||||
onEdit: jest.fn(),
|
||||
onDuplicate: jest.fn(),
|
||||
onDeactivate: jest.fn(),
|
||||
isDeactivating: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders agent type name', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Software Architect')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders active status badge', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Active')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders inactive status badge for inactive agent type', () => {
|
||||
render(
|
||||
<AgentTypeDetail {...defaultProps} agentType={{ ...mockAgentType, is_active: false }} />
|
||||
);
|
||||
expect(screen.getByText('Inactive')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders description card', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Description')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Designs system architecture and makes technology decisions')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders expertise areas', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Expertise Areas')).toBeInTheDocument();
|
||||
expect(screen.getByText('system design')).toBeInTheDocument();
|
||||
expect(screen.getByText('api design')).toBeInTheDocument();
|
||||
expect(screen.getByText('security')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders personality prompt', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Personality Prompt')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/You are a Senior Software Architect with 15\+ years of experience/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders MCP permissions section', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('MCP Permissions')).toBeInTheDocument();
|
||||
expect(screen.getByText('Gitea')).toBeInTheDocument();
|
||||
expect(screen.getByText('Knowledge Base')).toBeInTheDocument();
|
||||
expect(screen.getByText('Filesystem')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows enabled/disabled status for MCP servers', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
// Should show 3 "Enabled" badges for gitea, knowledge, filesystem
|
||||
const enabledBadges = screen.getAllByText('Enabled');
|
||||
expect(enabledBadges.length).toBe(3);
|
||||
// Should show 2 "Disabled" badges for slack, browser
|
||||
const disabledBadges = screen.getAllByText('Disabled');
|
||||
expect(disabledBadges.length).toBe(2);
|
||||
});
|
||||
|
||||
it('renders model configuration', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Model Configuration')).toBeInTheDocument();
|
||||
expect(screen.getByText('Primary Model')).toBeInTheDocument();
|
||||
expect(screen.getByText('Claude Opus 4.5')).toBeInTheDocument();
|
||||
expect(screen.getByText('Failover Model')).toBeInTheDocument();
|
||||
expect(screen.getByText('Claude Sonnet 4')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders model parameters', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Temperature')).toBeInTheDocument();
|
||||
expect(screen.getByText('0.7')).toBeInTheDocument();
|
||||
expect(screen.getByText('Max Tokens')).toBeInTheDocument();
|
||||
expect(screen.getByText('8,192')).toBeInTheDocument();
|
||||
expect(screen.getByText('Top P')).toBeInTheDocument();
|
||||
expect(screen.getByText('0.95')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders instance count', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Instances')).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Active instances')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onBack when back button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /go back/i }));
|
||||
expect(defaultProps.onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onEdit when edit button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /edit/i }));
|
||||
expect(defaultProps.onEdit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onDuplicate when duplicate button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /duplicate/i }));
|
||||
expect(defaultProps.onDuplicate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows loading skeleton when isLoading is true', () => {
|
||||
const { container } = render(
|
||||
<AgentTypeDetail {...defaultProps} agentType={null} isLoading={true} />
|
||||
);
|
||||
expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows not found state when agentType is null', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} agentType={null} isLoading={false} />);
|
||||
expect(screen.getByText('Agent type not found')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('The requested agent type could not be found')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows danger zone with deactivate button', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Danger Zone')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /deactivate type/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows confirmation dialog when deactivate is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /deactivate type/i }));
|
||||
expect(screen.getByText('Are you sure?')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /deactivate$/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onDeactivate when confirmation is accepted', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /deactivate type/i }));
|
||||
await user.click(screen.getByRole('button', { name: /^deactivate$/i }));
|
||||
expect(defaultProps.onDeactivate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(
|
||||
<AgentTypeDetail {...defaultProps} className="custom-class" />
|
||||
);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('shows no description message when description is null', () => {
|
||||
render(
|
||||
<AgentTypeDetail {...defaultProps} agentType={{ ...mockAgentType, description: null }} />
|
||||
);
|
||||
expect(screen.getByText('No description provided')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no expertise message when expertise is empty', () => {
|
||||
render(
|
||||
<AgentTypeDetail {...defaultProps} agentType={{ ...mockAgentType, expertise: [] }} />
|
||||
);
|
||||
expect(screen.getByText('No expertise areas defined')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "None configured" when no fallback model', () => {
|
||||
render(
|
||||
<AgentTypeDetail
|
||||
{...defaultProps}
|
||||
agentType={{ ...mockAgentType, fallback_models: [] }}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('None configured')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
242
frontend/tests/components/agents/AgentTypeForm.test.tsx
Normal file
242
frontend/tests/components/agents/AgentTypeForm.test.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { AgentTypeForm } from '@/components/agents/AgentTypeForm';
|
||||
import type { AgentTypeResponse } from '@/lib/api/types/agentTypes';
|
||||
|
||||
const mockAgentType: AgentTypeResponse = {
|
||||
id: 'type-001',
|
||||
name: 'Software Architect',
|
||||
slug: 'software-architect',
|
||||
description: 'Designs system architecture',
|
||||
expertise: ['system design', 'api design'],
|
||||
personality_prompt: 'You are a Software Architect...',
|
||||
primary_model: 'claude-opus-4-5-20251101',
|
||||
fallback_models: ['claude-sonnet-4-20250514'],
|
||||
model_params: { temperature: 0.7, max_tokens: 8192, top_p: 0.95 },
|
||||
mcp_servers: ['gitea'],
|
||||
tool_permissions: {},
|
||||
is_active: true,
|
||||
created_at: '2025-01-10T00:00:00Z',
|
||||
updated_at: '2025-01-18T00:00:00Z',
|
||||
instance_count: 2,
|
||||
};
|
||||
|
||||
describe('AgentTypeForm', () => {
|
||||
const defaultProps = {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
isSubmitting: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Create Mode', () => {
|
||||
it('renders create form title', () => {
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
expect(screen.getByText('Create Agent Type')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Define a new agent type template')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all tabs', () => {
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
expect(screen.getByRole('tab', { name: /basic info/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /model/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /permissions/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /personality/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders basic info fields by default', () => {
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/slug/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/description/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('auto-generates slug from name', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
|
||||
const nameInput = screen.getByLabelText(/name/i);
|
||||
await user.type(nameInput, 'Product Owner');
|
||||
|
||||
const slugInput = screen.getByLabelText(/slug/i) as HTMLInputElement;
|
||||
await waitFor(() => {
|
||||
expect(slugInput.value).toBe('product-owner');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows validation error for empty name', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /create/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Name is required')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows validation error for empty personality prompt', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
|
||||
// Fill name to pass first validation
|
||||
await user.type(screen.getByLabelText(/name/i), 'Test Agent');
|
||||
|
||||
// Switch to personality tab
|
||||
await user.click(screen.getByRole('tab', { name: /personality/i }));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /create/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Personality prompt is required')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('submits with default values when minimum required fields are filled', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
|
||||
// Fill name (which auto-generates slug)
|
||||
await user.type(screen.getByLabelText(/name/i), 'Test Agent');
|
||||
|
||||
// Wait for slug to auto-populate
|
||||
await waitFor(() => {
|
||||
expect((screen.getByLabelText(/slug/i) as HTMLInputElement).value).toBe('test-agent');
|
||||
});
|
||||
|
||||
// Note: onSubmit will not be called because personality_prompt is required
|
||||
// This test just verifies the form fields are working correctly
|
||||
expect(defaultProps.onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onCancel when cancel button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onCancel when back button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /go back/i }));
|
||||
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edit Mode', () => {
|
||||
it('renders edit form title', () => {
|
||||
render(<AgentTypeForm {...defaultProps} agentType={mockAgentType} />);
|
||||
expect(screen.getByText('Edit Agent Type')).toBeInTheDocument();
|
||||
expect(screen.getByText('Modify agent type configuration')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pre-fills form with agent type data', () => {
|
||||
render(<AgentTypeForm {...defaultProps} agentType={mockAgentType} />);
|
||||
|
||||
const nameInput = screen.getByLabelText(/name/i) as HTMLInputElement;
|
||||
expect(nameInput.value).toBe('Software Architect');
|
||||
|
||||
const slugInput = screen.getByLabelText(/slug/i) as HTMLInputElement;
|
||||
expect(slugInput.value).toBe('software-architect');
|
||||
});
|
||||
|
||||
it('shows save changes button', () => {
|
||||
render(<AgentTypeForm {...defaultProps} agentType={mockAgentType} />);
|
||||
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not auto-generate slug when editing', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeForm {...defaultProps} agentType={mockAgentType} />);
|
||||
|
||||
const nameInput = screen.getByLabelText(/name/i);
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'New Name');
|
||||
|
||||
// Slug should remain unchanged
|
||||
const slugInput = screen.getByLabelText(/slug/i) as HTMLInputElement;
|
||||
expect(slugInput.value).toBe('software-architect');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tabs', () => {
|
||||
it('renders all tab triggers', () => {
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
|
||||
expect(screen.getByRole('tab', { name: /basic info/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /model/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /permissions/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /personality/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('basic info tab is active by default', () => {
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
|
||||
// Basic Info content should be visible
|
||||
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/slug/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/description/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Expertise Management', () => {
|
||||
it('adds expertise when add button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
|
||||
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
|
||||
await user.type(expertiseInput, 'new skill');
|
||||
await user.click(screen.getByRole('button', { name: /^add$/i }));
|
||||
|
||||
expect(screen.getByText('new skill')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds expertise on enter key', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
|
||||
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
|
||||
await user.type(expertiseInput, 'keyboard skill{Enter}');
|
||||
|
||||
expect(screen.getByText('keyboard skill')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('removes expertise when X button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeForm {...defaultProps} agentType={mockAgentType} />);
|
||||
|
||||
// Should have existing expertise
|
||||
expect(screen.getByText('system design')).toBeInTheDocument();
|
||||
|
||||
// Click remove button
|
||||
const removeButton = screen.getByRole('button', { name: /remove system design/i });
|
||||
await user.click(removeButton);
|
||||
|
||||
expect(screen.queryByText('system design')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form State', () => {
|
||||
it('disables buttons when submitting', () => {
|
||||
render(<AgentTypeForm {...defaultProps} isSubmitting={true} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /saving/i })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(
|
||||
<AgentTypeForm {...defaultProps} className="custom-class" />
|
||||
);
|
||||
expect(container.querySelector('form')).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
});
|
||||
183
frontend/tests/components/agents/AgentTypeList.test.tsx
Normal file
183
frontend/tests/components/agents/AgentTypeList.test.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { AgentTypeList } from '@/components/agents/AgentTypeList';
|
||||
import type { AgentTypeResponse } from '@/lib/api/types/agentTypes';
|
||||
|
||||
const mockAgentTypes: AgentTypeResponse[] = [
|
||||
{
|
||||
id: 'type-001',
|
||||
name: 'Product Owner',
|
||||
slug: 'product-owner',
|
||||
description: 'Manages product backlog and prioritizes features',
|
||||
expertise: ['requirements', 'user stories', 'prioritization'],
|
||||
personality_prompt: 'You are a Product Owner...',
|
||||
primary_model: 'claude-opus-4-5-20251101',
|
||||
fallback_models: ['claude-sonnet-4-20250514'],
|
||||
model_params: { temperature: 0.7, max_tokens: 8192, top_p: 0.95 },
|
||||
mcp_servers: ['gitea', 'knowledge'],
|
||||
tool_permissions: {},
|
||||
is_active: true,
|
||||
created_at: '2025-01-15T00:00:00Z',
|
||||
updated_at: '2025-01-20T00:00:00Z',
|
||||
instance_count: 3,
|
||||
},
|
||||
{
|
||||
id: 'type-002',
|
||||
name: 'Software Architect',
|
||||
slug: 'software-architect',
|
||||
description: 'Designs system architecture and makes technology decisions',
|
||||
expertise: ['system design', 'api design', 'security'],
|
||||
personality_prompt: 'You are a Software Architect...',
|
||||
primary_model: 'claude-opus-4-5-20251101',
|
||||
fallback_models: [],
|
||||
model_params: { temperature: 0.5, max_tokens: 8192, top_p: 0.9 },
|
||||
mcp_servers: ['gitea'],
|
||||
tool_permissions: {},
|
||||
is_active: false,
|
||||
created_at: '2025-01-10T00:00:00Z',
|
||||
updated_at: '2025-01-18T00:00:00Z',
|
||||
instance_count: 0,
|
||||
},
|
||||
];
|
||||
|
||||
describe('AgentTypeList', () => {
|
||||
const defaultProps = {
|
||||
agentTypes: mockAgentTypes,
|
||||
isLoading: false,
|
||||
searchQuery: '',
|
||||
onSearchChange: jest.fn(),
|
||||
statusFilter: 'all',
|
||||
onStatusFilterChange: jest.fn(),
|
||||
onSelect: jest.fn(),
|
||||
onCreate: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders page title and description', () => {
|
||||
render(<AgentTypeList {...defaultProps} />);
|
||||
expect(screen.getByText('Agent Types')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Configure templates for spawning AI agent instances')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders create button', () => {
|
||||
render(<AgentTypeList {...defaultProps} />);
|
||||
expect(screen.getByRole('button', { name: /create agent type/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders search input', () => {
|
||||
render(<AgentTypeList {...defaultProps} />);
|
||||
expect(screen.getByPlaceholderText('Search agent types...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all agent types', () => {
|
||||
render(<AgentTypeList {...defaultProps} />);
|
||||
expect(screen.getByText('Product Owner')).toBeInTheDocument();
|
||||
expect(screen.getByText('Software Architect')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows description for each agent type', () => {
|
||||
render(<AgentTypeList {...defaultProps} />);
|
||||
expect(
|
||||
screen.getByText('Manages product backlog and prioritizes features')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Designs system architecture and makes technology decisions')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows active status badge for active agent types', () => {
|
||||
render(<AgentTypeList {...defaultProps} />);
|
||||
expect(screen.getByText('Active')).toBeInTheDocument();
|
||||
expect(screen.getByText('Inactive')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows expertise tags', () => {
|
||||
render(<AgentTypeList {...defaultProps} />);
|
||||
expect(screen.getByText('requirements')).toBeInTheDocument();
|
||||
expect(screen.getByText('user stories')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows instance count', () => {
|
||||
render(<AgentTypeList {...defaultProps} />);
|
||||
expect(screen.getByText('3 instances')).toBeInTheDocument();
|
||||
expect(screen.getByText('0 instances')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSelect when agent type card is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeList {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText('Product Owner'));
|
||||
expect(defaultProps.onSelect).toHaveBeenCalledWith('type-001');
|
||||
});
|
||||
|
||||
it('calls onCreate when create button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeList {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /create agent type/i }));
|
||||
expect(defaultProps.onCreate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onSearchChange when search input changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeList {...defaultProps} />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search agent types...');
|
||||
await user.type(searchInput, 'architect');
|
||||
expect(defaultProps.onSearchChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows loading skeletons when isLoading is true', () => {
|
||||
const { container } = render(
|
||||
<AgentTypeList {...defaultProps} agentTypes={[]} isLoading={true} />
|
||||
);
|
||||
expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows empty state when no agent types', () => {
|
||||
render(<AgentTypeList {...defaultProps} agentTypes={[]} />);
|
||||
expect(screen.getByText('No agent types found')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Create your first agent type to get started')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows filter hint in empty state when filters are applied', () => {
|
||||
render(
|
||||
<AgentTypeList {...defaultProps} agentTypes={[]} searchQuery="nonexistent" />
|
||||
);
|
||||
expect(screen.getByText('Try adjusting your search or filters')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows +N badge when expertise has more than 3 items', () => {
|
||||
const agentWithManySkills: AgentTypeResponse = {
|
||||
...mockAgentTypes[0],
|
||||
expertise: ['skill1', 'skill2', 'skill3', 'skill4', 'skill5'],
|
||||
};
|
||||
render(<AgentTypeList {...defaultProps} agentTypes={[agentWithManySkills]} />);
|
||||
expect(screen.getByText('+2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('supports keyboard navigation on agent type cards', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeList {...defaultProps} />);
|
||||
|
||||
const cards = screen.getAllByRole('button', { name: /view .* agent type/i });
|
||||
cards[0].focus();
|
||||
await user.keyboard('{Enter}');
|
||||
expect(defaultProps.onSelect).toHaveBeenCalledWith('type-001');
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(
|
||||
<AgentTypeList {...defaultProps} className="custom-class" />
|
||||
);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user