🔐 feat: Granular Role-based Permissions + Entra ID Group Discovery (#7804)

WIP: pre-granular-permissions commit

feat: Add category and support contact fields to Agent schema and UI components

Revert "feat: Add category and support contact fields to Agent schema and UI components"

This reverts commit c43a52b4c9.

Fix: Update import for renderHook in useAgentCategories.spec.tsx

fix: Update icon rendering in AgentCategoryDisplay tests to use empty spans

refactor: Improve category synchronization logic and clean up AgentConfig component

refactor: Remove unused UI flow translations from translation.json

feat: agent marketplace features

🔐 feat: Granular Role-based Permissions + Entra ID Group Discovery (#7804)
This commit is contained in:
Danny Avila 2025-06-23 10:22:27 -04:00
parent aa42759ffd
commit 66bd419baa
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
147 changed files with 17564 additions and 645 deletions

View file

@ -0,0 +1,422 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import CategoryTabs from '../CategoryTabs';
import AgentGrid from '../AgentGrid';
import AgentCard from '../AgentCard';
import SearchBar from '../SearchBar';
import ErrorDisplay from '../ErrorDisplay';
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(() => ({
matches: false,
media: '',
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
// Mock hooks
jest.mock(
'~/hooks/useLocalize',
() => () =>
jest.fn((key: string, options?: any) => {
const translations: Record<string, string> = {
com_agents_category_tabs_label: 'Agent Categories',
com_agents_category_tab_label: `${options?.category} category, ${options?.position} of ${options?.total}`,
com_agents_search_instructions: 'Type to search agents by name or description',
com_agents_search_aria: 'Search agents',
com_agents_search_placeholder: 'Search agents...',
com_agents_clear_search: 'Clear search',
com_agents_agent_card_label: `${options?.name} agent. ${options?.description}`,
com_agents_no_description: 'No description available',
com_agents_grid_announcement: `Showing ${options?.count} agents in ${options?.category} category`,
com_agents_load_more_label: `Load more agents from ${options?.category} category`,
com_agents_error_retry: 'Try Again',
com_agents_loading: 'Loading...',
com_agents_empty_state_heading: 'No agents found',
com_agents_search_empty_heading: 'No search results',
};
return translations[key] || key;
}),
);
jest.mock('~/hooks/Agents', () => ({
useDynamicAgentQuery: jest.fn(),
}));
const { useDynamicAgentQuery } = require('~/hooks/Agents');
// Create wrapper with QueryClient
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('Accessibility Improvements', () => {
beforeEach(() => {
useDynamicAgentQuery.mockClear();
});
describe('CategoryTabs Accessibility', () => {
const categories = [
{ name: 'promoted', count: 5 },
{ name: 'all', count: 20 },
{ name: 'productivity', count: 8 },
];
it('implements proper tablist role and ARIA attributes', () => {
render(
<CategoryTabs
categories={categories}
activeTab="promoted"
isLoading={false}
onChange={jest.fn()}
/>,
);
// Check tablist role
const tablist = screen.getByRole('tablist');
expect(tablist).toBeInTheDocument();
expect(tablist).toHaveAttribute('aria-label', 'Agent Categories');
expect(tablist).toHaveAttribute('aria-orientation', 'horizontal');
// Check individual tabs
const tabs = screen.getAllByRole('tab');
expect(tabs).toHaveLength(3);
tabs.forEach((tab, index) => {
expect(tab).toHaveAttribute('aria-selected');
expect(tab).toHaveAttribute('aria-controls');
expect(tab).toHaveAttribute('id');
});
});
it('supports keyboard navigation', () => {
const onChange = jest.fn();
render(
<CategoryTabs
categories={categories}
activeTab="promoted"
isLoading={false}
onChange={onChange}
/>,
);
const promotedTab = screen.getByRole('tab', { name: /promoted category/ });
// Test arrow key navigation
fireEvent.keyDown(promotedTab, { key: 'ArrowRight' });
expect(onChange).toHaveBeenCalledWith('all');
fireEvent.keyDown(promotedTab, { key: 'ArrowLeft' });
expect(onChange).toHaveBeenCalledWith('productivity');
fireEvent.keyDown(promotedTab, { key: 'Home' });
expect(onChange).toHaveBeenCalledWith('promoted');
fireEvent.keyDown(promotedTab, { key: 'End' });
expect(onChange).toHaveBeenCalledWith('productivity');
});
it('manages focus correctly', () => {
const onChange = jest.fn();
render(
<CategoryTabs
categories={categories}
activeTab="promoted"
isLoading={false}
onChange={onChange}
/>,
);
const promotedTab = screen.getByRole('tab', { name: /promoted category/ });
const allTab = screen.getByRole('tab', { name: /all category/ });
// Active tab should be focusable
expect(promotedTab).toHaveAttribute('tabIndex', '0');
expect(allTab).toHaveAttribute('tabIndex', '-1');
});
});
describe('SearchBar Accessibility', () => {
it('provides proper search role and labels', () => {
render(<SearchBar value="" onSearch={jest.fn()} />);
// Check search landmark
const searchRegion = screen.getByRole('search');
expect(searchRegion).toBeInTheDocument();
// Check input accessibility
const searchInput = screen.getByRole('searchbox');
expect(searchInput).toHaveAttribute('id', 'agent-search');
expect(searchInput).toHaveAttribute('aria-label', 'Search agents');
expect(searchInput).toHaveAttribute(
'aria-describedby',
'search-instructions search-results-count',
);
// Check hidden label
expect(screen.getByText('Type to search agents by name or description')).toHaveClass(
'sr-only',
);
});
it('provides accessible clear button', () => {
render(<SearchBar value="test" onSearch={jest.fn()} />);
const clearButton = screen.getByRole('button', { name: 'Clear search' });
expect(clearButton).toBeInTheDocument();
expect(clearButton).toHaveAttribute('aria-label', 'Clear search');
expect(clearButton).toHaveAttribute('title', 'Clear search');
});
it('hides decorative icons from screen readers', () => {
render(<SearchBar value="" onSearch={jest.fn()} />);
// Search icon should be hidden
const iconContainer = document.querySelector('[aria-hidden="true"]');
expect(iconContainer).toBeInTheDocument();
});
});
describe('AgentCard Accessibility', () => {
const mockAgent = {
id: 'test-agent',
name: 'Test Agent',
description: 'A test agent for testing',
authorName: 'Test Author',
};
it('provides comprehensive ARIA labels', () => {
render(<AgentCard agent={mockAgent} onClick={jest.fn()} />);
const card = screen.getByRole('button');
expect(card).toHaveAttribute('aria-label', 'Test Agent agent. A test agent for testing');
expect(card).toHaveAttribute('aria-describedby', 'agent-test-agent-description');
expect(card).toHaveAttribute('role', 'button');
});
it('handles agents without descriptions', () => {
const agentWithoutDesc = { ...mockAgent, description: undefined };
render(<AgentCard agent={agentWithoutDesc} onClick={jest.fn()} />);
expect(screen.getByText('No description available')).toBeInTheDocument();
});
it('supports keyboard interaction', () => {
const onClick = jest.fn();
render(<AgentCard agent={mockAgent} onClick={onClick} />);
const card = screen.getByRole('button');
fireEvent.keyDown(card, { key: 'Enter' });
expect(onClick).toHaveBeenCalledTimes(1);
fireEvent.keyDown(card, { key: ' ' });
expect(onClick).toHaveBeenCalledTimes(2);
});
});
describe('AgentGrid Accessibility', () => {
beforeEach(() => {
useDynamicAgentQuery.mockReturnValue({
data: {
agents: [
{ id: '1', name: 'Agent 1', description: 'First agent' },
{ id: '2', name: 'Agent 2', description: 'Second agent' },
],
pagination: { hasMore: false, total: 2, current: 1 },
},
isLoading: false,
isFetching: false,
error: null,
refetch: jest.fn(),
});
});
it('implements proper tabpanel structure', () => {
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
</Wrapper>,
);
// Check tabpanel role
const tabpanel = screen.getByRole('tabpanel');
expect(tabpanel).toHaveAttribute('id', 'category-panel-all');
expect(tabpanel).toHaveAttribute('aria-labelledby', 'category-tab-all');
expect(tabpanel).toHaveAttribute('aria-live', 'polite');
});
it('provides grid structure with proper roles', () => {
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
</Wrapper>,
);
// Check grid role
const grid = screen.getByRole('grid');
expect(grid).toBeInTheDocument();
expect(grid).toHaveAttribute('aria-label', 'Showing 2 agents in all category');
// Check gridcells
const gridcells = screen.getAllByRole('gridcell');
expect(gridcells).toHaveLength(2);
});
it('announces loading states to screen readers', () => {
useDynamicAgentQuery.mockReturnValue({
data: { agents: [{ id: '1', name: 'Agent 1' }] },
isLoading: false,
isFetching: true,
error: null,
refetch: jest.fn(),
});
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
</Wrapper>,
);
// Check for loading announcement
const loadingStatus = screen.getByRole('status', { name: 'Loading...' });
expect(loadingStatus).toBeInTheDocument();
expect(loadingStatus).toHaveAttribute('aria-live', 'polite');
});
it('provides accessible empty states', () => {
useDynamicAgentQuery.mockReturnValue({
data: { agents: [], pagination: { hasMore: false, total: 0, current: 1 } },
isLoading: false,
isFetching: false,
error: null,
refetch: jest.fn(),
});
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
</Wrapper>,
);
// Check empty state accessibility
const emptyState = screen.getByRole('status');
expect(emptyState).toHaveAttribute('aria-live', 'polite');
expect(emptyState).toHaveAttribute('aria-label', 'No agents found');
});
});
describe('ErrorDisplay Accessibility', () => {
const mockError = {
response: {
data: {
userMessage: 'Unable to load agents. Please try refreshing the page.',
suggestion: 'Try refreshing the page or check your network connection',
},
},
};
it('implements proper alert role and ARIA attributes', () => {
render(<ErrorDisplay error={mockError} onRetry={jest.fn()} />);
// Check alert role
const alert = screen.getByRole('alert');
expect(alert).toHaveAttribute('aria-live', 'assertive');
expect(alert).toHaveAttribute('aria-atomic', 'true');
// Check heading structure
const heading = screen.getByRole('heading', { level: 2 });
expect(heading).toHaveAttribute('id', 'error-title');
});
it('provides accessible retry button', () => {
const onRetry = jest.fn();
render(<ErrorDisplay error={mockError} onRetry={onRetry} />);
const retryButton = screen.getByRole('button', { name: /retry action/i });
expect(retryButton).toHaveAttribute('aria-describedby', 'error-message error-suggestion');
fireEvent.click(retryButton);
expect(onRetry).toHaveBeenCalledTimes(1);
});
it('structures error content with proper semantics', () => {
render(<ErrorDisplay error={mockError} />);
// Check error message structure
expect(screen.getByText(/unable to load agents/i)).toHaveAttribute('id', 'error-message');
// Check suggestion note
const suggestion = screen.getByRole('note');
expect(suggestion).toHaveAttribute('aria-label', expect.stringContaining('Suggestion:'));
});
});
describe('Focus Management', () => {
it('maintains proper focus ring styles', () => {
const { container } = render(<SearchBar value="" onSearch={jest.fn()} />);
// Check for focus styles in CSS classes
const searchInput = container.querySelector('input');
expect(searchInput?.className).toContain('focus:');
});
it('provides visible focus indicators on interactive elements', () => {
render(
<CategoryTabs
categories={[{ name: 'test', count: 1 }]}
activeTab="test"
isLoading={false}
onChange={jest.fn()}
/>,
);
const tab = screen.getByRole('tab');
expect(tab.className).toContain('focus:outline-none');
expect(tab.className).toContain('focus:ring-2');
});
});
describe('Screen Reader Announcements', () => {
it('includes live regions for dynamic content', () => {
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
</Wrapper>,
);
// Check for live region
const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion).toBeInTheDocument();
});
it('provides screen reader only content', () => {
render(<SearchBar value="" onSearch={jest.fn()} />);
// Check for screen reader only instructions
const srOnlyElement = document.querySelector('.sr-only');
expect(srOnlyElement).toBeInTheDocument();
});
});
});
export default {};

View file

@ -0,0 +1,197 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import AgentCard from '../AgentCard';
import type t from 'librechat-data-provider';
// Mock useLocalize hook
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
const mockTranslations: Record<string, string> = {
com_agents_created_by: 'Created by',
};
return mockTranslations[key] || key;
});
describe('AgentCard', () => {
const mockAgent: t.Agent = {
id: '1',
name: 'Test Agent',
description: 'A test agent for testing purposes',
support_contact: {
name: 'Test Support',
email: 'test@example.com',
},
avatar: '/test-avatar.png',
} as t.Agent;
const mockOnClick = jest.fn();
beforeEach(() => {
mockOnClick.mockClear();
});
it('renders agent information correctly', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
expect(screen.getByText('Test Agent')).toBeInTheDocument();
expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument();
expect(screen.getByText('Created by')).toBeInTheDocument();
expect(screen.getByText('Test Support')).toBeInTheDocument();
});
it('displays avatar when provided as string', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
const avatarImg = screen.getByAltText('Test Agent avatar');
expect(avatarImg).toBeInTheDocument();
expect(avatarImg).toHaveAttribute('src', '/test-avatar.png');
});
it('displays avatar when provided as object with filepath', () => {
const agentWithObjectAvatar = {
...mockAgent,
avatar: { filepath: '/object-avatar.png' },
};
render(<AgentCard agent={agentWithObjectAvatar} onClick={mockOnClick} />);
const avatarImg = screen.getByAltText('Test Agent avatar');
expect(avatarImg).toBeInTheDocument();
expect(avatarImg).toHaveAttribute('src', '/object-avatar.png');
});
it('displays Bot icon fallback when no avatar is provided', () => {
const agentWithoutAvatar = {
...mockAgent,
avatar: undefined,
};
render(<AgentCard agent={agentWithoutAvatar} onClick={mockOnClick} />);
// Check for Bot icon presence by looking for the svg with lucide-bot class
const botIcon = document.querySelector('.lucide-bot');
expect(botIcon).toBeInTheDocument();
});
it('calls onClick when card is clicked', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
const card = screen.getByRole('button');
fireEvent.click(card);
expect(mockOnClick).toHaveBeenCalledTimes(1);
});
it('calls onClick when Enter key is pressed', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
const card = screen.getByRole('button');
fireEvent.keyDown(card, { key: 'Enter' });
expect(mockOnClick).toHaveBeenCalledTimes(1);
});
it('calls onClick when Space key is pressed', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
const card = screen.getByRole('button');
fireEvent.keyDown(card, { key: ' ' });
expect(mockOnClick).toHaveBeenCalledTimes(1);
});
it('does not call onClick for other keys', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
const card = screen.getByRole('button');
fireEvent.keyDown(card, { key: 'Escape' });
expect(mockOnClick).not.toHaveBeenCalled();
});
it('applies additional className when provided', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} className="custom-class" />);
const card = screen.getByRole('button');
expect(card).toHaveClass('custom-class');
});
it('handles missing support contact gracefully', () => {
const agentWithoutContact = {
...mockAgent,
support_contact: undefined,
authorName: undefined,
};
render(<AgentCard agent={agentWithoutContact} onClick={mockOnClick} />);
expect(screen.getByText('Test Agent')).toBeInTheDocument();
expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument();
expect(screen.queryByText(/Created by/)).not.toBeInTheDocument();
});
it('displays authorName when support_contact is missing', () => {
const agentWithAuthorName = {
...mockAgent,
support_contact: undefined,
authorName: 'John Doe',
};
render(<AgentCard agent={agentWithAuthorName} onClick={mockOnClick} />);
expect(screen.getByText('Created by')).toBeInTheDocument();
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
it('displays support_contact email when name is missing', () => {
const agentWithEmailOnly = {
...mockAgent,
support_contact: { email: 'contact@example.com' },
authorName: undefined,
};
render(<AgentCard agent={agentWithEmailOnly} onClick={mockOnClick} />);
expect(screen.getByText('Created by')).toBeInTheDocument();
expect(screen.getByText('contact@example.com')).toBeInTheDocument();
});
it('prioritizes support_contact name over authorName', () => {
const agentWithBoth = {
...mockAgent,
support_contact: { name: 'Support Team' },
authorName: 'John Doe',
};
render(<AgentCard agent={agentWithBoth} onClick={mockOnClick} />);
expect(screen.getByText('Created by')).toBeInTheDocument();
expect(screen.getByText('Support Team')).toBeInTheDocument();
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
});
it('prioritizes name over email in support_contact', () => {
const agentWithNameAndEmail = {
...mockAgent,
support_contact: {
name: 'Support Team',
email: 'support@example.com',
},
authorName: undefined,
};
render(<AgentCard agent={agentWithNameAndEmail} onClick={mockOnClick} />);
expect(screen.getByText('Created by')).toBeInTheDocument();
expect(screen.getByText('Support Team')).toBeInTheDocument();
expect(screen.queryByText('support@example.com')).not.toBeInTheDocument();
});
it('has proper accessibility attributes', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
const card = screen.getByRole('button');
expect(card).toHaveAttribute('tabIndex', '0');
expect(card).toHaveAttribute('aria-label', 'com_agents_agent_card_label');
});
});

View file

@ -0,0 +1,90 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import AgentCategoryDisplay from '../AgentCategoryDisplay';
// Mock the useAgentCategories hook
jest.mock('~/hooks/Agents', () => ({
useAgentCategories: () => ({
categories: [
{
value: 'general',
label: 'General',
icon: <span data-testid="icon-general">{''}</span>,
className: 'w-full',
},
{
value: 'hr',
label: 'HR',
icon: <span data-testid="icon-hr">{''}</span>,
className: 'w-full',
},
{
value: 'rd',
label: 'R&D',
icon: <span data-testid="icon-rd">{''}</span>,
className: 'w-full',
},
{
value: 'finance',
label: 'Finance',
icon: <span data-testid="icon-finance">{''}</span>,
className: 'w-full',
},
],
emptyCategory: {
value: '',
label: 'General',
className: 'w-full',
},
}),
}));
describe('AgentCategoryDisplay', () => {
it('should display the proper label for a category', () => {
render(<AgentCategoryDisplay category="rd" />);
expect(screen.getByText('R&D')).toBeInTheDocument();
});
it('should display the icon when showIcon is true', () => {
render(<AgentCategoryDisplay category="finance" showIcon={true} />);
expect(screen.getByTestId('icon-finance')).toBeInTheDocument();
expect(screen.getByText('Finance')).toBeInTheDocument();
});
it('should not display the icon when showIcon is false', () => {
render(<AgentCategoryDisplay category="hr" showIcon={false} />);
expect(screen.queryByTestId('icon-hr')).not.toBeInTheDocument();
expect(screen.getByText('HR')).toBeInTheDocument();
});
it('should apply custom classnames', () => {
render(<AgentCategoryDisplay category="general" className="test-class" />);
expect(screen.getByText('General').parentElement).toHaveClass('test-class');
});
it('should not render anything for unknown categories', () => {
const { container } = render(<AgentCategoryDisplay category="unknown" />);
expect(container).toBeEmptyDOMElement();
});
it('should not render anything when no category is provided', () => {
const { container } = render(<AgentCategoryDisplay />);
expect(container).toBeEmptyDOMElement();
});
it('should not render anything for empty category when showEmptyFallback is false', () => {
const { container } = render(<AgentCategoryDisplay category="" />);
expect(container).toBeEmptyDOMElement();
});
it('should render empty category placeholder when showEmptyFallback is true', () => {
render(<AgentCategoryDisplay category="" showEmptyFallback={true} />);
expect(screen.getByText('General')).toBeInTheDocument();
});
it('should apply custom iconClassName to the icon', () => {
render(<AgentCategoryDisplay category="general" iconClassName="custom-icon-class" />);
const iconElement = screen.getByTestId('icon-general').parentElement;
expect(iconElement).toHaveClass('custom-icon-class');
});
});

View file

@ -0,0 +1,359 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, useNavigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RecoilRoot } from 'recoil';
import type t from 'librechat-data-provider';
import AgentDetail from '../AgentDetail';
import { useToast } from '~/hooks';
import useLocalize from '~/hooks/useLocalize';
// Mock dependencies
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
}));
jest.mock('~/hooks', () => ({
useToast: jest.fn(),
}));
jest.mock('~/hooks/useLocalize', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('~/utils/agents', () => ({
renderAgentAvatar: jest.fn((agent, options) => (
<div data-testid="agent-avatar" data-size={options?.size} />
)),
}));
// Mock clipboard API
Object.assign(navigator, {
clipboard: {
writeText: jest.fn(),
},
});
const mockNavigate = jest.fn();
const mockShowToast = jest.fn();
const mockLocalize = jest.fn((key: string) => key);
const mockAgent: t.Agent = {
id: 'test-agent-id',
name: 'Test Agent',
description: 'This is a test agent for unit testing',
avatar: {
filepath: '/path/to/avatar.png',
source: 'local' as const,
},
model: 'gpt-4',
provider: 'openai',
instructions: 'You are a helpful test agent',
tools: [],
code_interpreter: false,
file_search: false,
author: 'test-user-id',
author_name: 'Test User',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
version: 1,
support_contact: {
name: 'Support Team',
email: 'support@test.com',
},
};
// Helper function to render with providers
const renderWithProviders = (ui: React.ReactElement, options = {}) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<MemoryRouter>{children}</MemoryRouter>
</RecoilRoot>
</QueryClientProvider>
);
return render(ui, { wrapper: Wrapper, ...options });
};
describe('AgentDetail', () => {
beforeEach(() => {
jest.clearAllMocks();
(useNavigate as jest.Mock).mockReturnValue(mockNavigate);
(useToast as jest.Mock).mockReturnValue({ showToast: mockShowToast });
(useLocalize as jest.Mock).mockReturnValue(mockLocalize);
// Reset clipboard mock
(navigator.clipboard.writeText as jest.Mock).mockResolvedValue(undefined);
});
const defaultProps = {
agent: mockAgent,
isOpen: true,
onClose: jest.fn(),
};
describe('Rendering', () => {
it('should render agent details correctly', () => {
renderWithProviders(<AgentDetail {...defaultProps} />);
expect(screen.getByText('Test Agent')).toBeInTheDocument();
expect(screen.getByText('This is a test agent for unit testing')).toBeInTheDocument();
expect(screen.getByTestId('agent-avatar')).toBeInTheDocument();
expect(screen.getByTestId('agent-avatar')).toHaveAttribute('data-size', 'xl');
});
it('should render contact information when available', () => {
renderWithProviders(<AgentDetail {...defaultProps} />);
expect(screen.getByText('com_agents_contact:')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Support Team' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Support Team' })).toHaveAttribute(
'href',
'mailto:support@test.com',
);
});
it('should not render contact information when not available', () => {
const agentWithoutContact = { ...mockAgent };
delete (agentWithoutContact as any).support_contact;
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithoutContact} />);
expect(screen.queryByText('com_agents_contact:')).not.toBeInTheDocument();
});
it('should render loading state when agent is null', () => {
renderWithProviders(<AgentDetail {...defaultProps} agent={null as any} />);
expect(screen.getByText('com_agents_loading')).toBeInTheDocument();
expect(screen.getByText('com_agents_no_description')).toBeInTheDocument();
});
it('should render 3-dot menu button', () => {
renderWithProviders(<AgentDetail {...defaultProps} />);
const menuButton = screen.getByRole('button', { name: 'More options' });
expect(menuButton).toBeInTheDocument();
expect(menuButton).toHaveAttribute('aria-haspopup', 'menu');
});
it('should render Start Chat button', () => {
renderWithProviders(<AgentDetail {...defaultProps} />);
const startChatButton = screen.getByRole('button', { name: 'com_agents_start_chat' });
expect(startChatButton).toBeInTheDocument();
expect(startChatButton).not.toBeDisabled();
});
});
describe('Interactions', () => {
it('should navigate to chat when Start Chat button is clicked', async () => {
const user = userEvent.setup();
renderWithProviders(<AgentDetail {...defaultProps} />);
const startChatButton = screen.getByRole('button', { name: 'com_agents_start_chat' });
await user.click(startChatButton);
expect(mockNavigate).toHaveBeenCalledWith('/c/new?agent_id=test-agent-id');
});
it('should not navigate when agent is null', async () => {
const user = userEvent.setup();
renderWithProviders(<AgentDetail {...defaultProps} agent={null as any} />);
const startChatButton = screen.getByRole('button', { name: 'com_agents_start_chat' });
expect(startChatButton).toBeDisabled();
await user.click(startChatButton);
expect(mockNavigate).not.toHaveBeenCalled();
});
it('should open dropdown when 3-dot menu is clicked', async () => {
const user = userEvent.setup();
renderWithProviders(<AgentDetail {...defaultProps} />);
const menuButton = screen.getByRole('button', { name: 'More options' });
await user.click(menuButton);
expect(screen.getByRole('button', { name: 'com_agents_copy_link' })).toBeInTheDocument();
});
it('should close dropdown when clicking outside', async () => {
const user = userEvent.setup();
renderWithProviders(<AgentDetail {...defaultProps} />);
// Open dropdown
const menuButton = screen.getByRole('button', { name: 'More options' });
await user.click(menuButton);
expect(screen.getByRole('button', { name: 'com_agents_copy_link' })).toBeInTheDocument();
// Click outside (on the agent name)
const agentName = screen.getByText('Test Agent');
await user.click(agentName);
await waitFor(() => {
expect(
screen.queryByRole('button', { name: 'com_agents_copy_link' }),
).not.toBeInTheDocument();
});
});
it('should copy link and show success toast when Copy Link is clicked', async () => {
const user = userEvent.setup();
renderWithProviders(<AgentDetail {...defaultProps} />);
// Open dropdown
const menuButton = screen.getByRole('button', { name: 'More options' });
await user.click(menuButton);
// Click copy link
const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' });
await user.click(copyLinkButton);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
`${window.location.origin}/c/new?agent_id=test-agent-id`,
);
expect(mockShowToast).toHaveBeenCalledWith({
message: 'Link copied',
});
// Dropdown should close
await waitFor(() => {
expect(
screen.queryByRole('button', { name: 'com_agents_copy_link' }),
).not.toBeInTheDocument();
});
});
it('should show error toast when clipboard write fails', async () => {
const user = userEvent.setup();
(navigator.clipboard.writeText as jest.Mock).mockRejectedValue(new Error('Clipboard error'));
renderWithProviders(<AgentDetail {...defaultProps} />);
// Open dropdown and click copy link
const menuButton = screen.getByRole('button', { name: 'More options' });
await user.click(menuButton);
const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' });
await user.click(copyLinkButton);
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({
message: 'com_agents_link_copy_failed',
});
});
});
it('should call onClose when dialog is closed', () => {
const mockOnClose = jest.fn();
render(<AgentDetail {...defaultProps} onClose={mockOnClose} isOpen={false} />);
// Since we're testing the onOpenChange callback, we need to trigger it
// This would normally be done by the Dialog component when ESC is pressed or overlay is clicked
// We'll test this by checking that onClose is properly passed to the Dialog
expect(mockOnClose).toBeDefined();
});
});
describe('Accessibility', () => {
it('should have proper ARIA attributes', () => {
renderWithProviders(<AgentDetail {...defaultProps} />);
const menuButton = screen.getByRole('button', { name: 'More options' });
expect(menuButton).toHaveAttribute('aria-haspopup', 'menu');
expect(menuButton).toHaveAttribute('aria-label', 'More options');
});
it('should support keyboard navigation for dropdown', async () => {
const user = userEvent.setup();
renderWithProviders(<AgentDetail {...defaultProps} />);
const menuButton = screen.getByRole('button', { name: 'More options' });
// Focus and open with Enter key
menuButton.focus();
await user.keyboard('{Enter}');
expect(screen.getByRole('button', { name: 'com_agents_copy_link' })).toBeInTheDocument();
});
it('should have proper focus management', async () => {
const user = userEvent.setup();
renderWithProviders(<AgentDetail {...defaultProps} />);
const menuButton = screen.getByRole('button', { name: 'More options' });
await user.click(menuButton);
const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' });
expect(copyLinkButton).toHaveClass('focus:bg-surface-hover', 'focus:outline-none');
});
});
describe('Edge Cases', () => {
it('should handle agent with only email contact', () => {
const agentWithEmailOnly = {
...mockAgent,
support_contact: {
email: 'support@test.com',
},
};
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithEmailOnly} />);
expect(screen.getByRole('link', { name: 'support@test.com' })).toBeInTheDocument();
});
it('should handle agent with only name contact', () => {
const agentWithNameOnly = {
...mockAgent,
support_contact: {
name: 'Support Team',
},
};
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithNameOnly} />);
expect(screen.getByText('Support Team')).toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('should handle very long description with proper text wrapping', () => {
const agentWithLongDescription = {
...mockAgent,
description:
'This is a very long description that should wrap properly and be displayed in multiple lines when the content exceeds the available width of the container.',
};
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithLongDescription} />);
const description = screen.getByText(agentWithLongDescription.description);
expect(description).toHaveClass('whitespace-pre-wrap');
});
it('should handle special characters in agent name', () => {
const agentWithSpecialChars = {
...mockAgent,
name: 'Test Agent™ & Co. (v2.0)',
};
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithSpecialChars} />);
expect(screen.getByText('Test Agent™ & Co. (v2.0)')).toBeInTheDocument();
});
});
});

View file

@ -1,31 +1,41 @@
import React from 'react';
import { SystemRoles } from 'librechat-data-provider';
import { render, screen } from '@testing-library/react';
import type { UseMutationResult } from '@tanstack/react-query';
import '@testing-library/jest-dom/extend-expect';
import type { Agent, AgentCreateParams, TUser } from 'librechat-data-provider';
import AgentFooter from '../AgentFooter';
import { Panel } from '~/common';
import type { Agent, AgentCreateParams, TUser } from 'librechat-data-provider';
import { SystemRoles } from 'librechat-data-provider';
import * as reactHookForm from 'react-hook-form';
import * as hooks from '~/hooks';
import type { UseMutationResult } from '@tanstack/react-query';
const mockUseWatch = jest.fn();
const mockUseAuthContext = jest.fn();
const mockUseHasAccess = jest.fn();
const mockUseResourcePermissions = jest.fn();
jest.mock('react-hook-form', () => ({
useFormContext: () => ({
control: {},
}),
useWatch: () => {
return {
agent: {
name: 'Test Agent',
author: 'user-123',
projectIds: ['project-1'],
isCollaborative: false,
},
id: 'agent-123',
};
},
useWatch: (params) => mockUseWatch(params),
}));
// Default mock implementations
mockUseWatch.mockImplementation(({ name }) => {
if (name === 'agent') {
return {
_id: 'agent-db-123',
name: 'Test Agent',
author: 'user-123',
projectIds: ['project-1'],
isCollaborative: false,
};
}
if (name === 'id') {
return 'agent-123';
}
return undefined;
});
const mockUser = {
id: 'user-123',
username: 'testuser',
@ -39,6 +49,26 @@ const mockUser = {
updatedAt: '2023-01-01T00:00:00.000Z',
} as TUser;
// Default auth context
mockUseAuthContext.mockReturnValue({
user: mockUser,
token: 'mock-token',
isAuthenticated: true,
error: undefined,
login: jest.fn(),
logout: jest.fn(),
setError: jest.fn(),
roles: {},
});
// Default access and permissions
mockUseHasAccess.mockReturnValue(true);
mockUseResourcePermissions.mockReturnValue({
hasPermission: () => true,
isLoading: false,
permissionBits: 0,
});
jest.mock('~/hooks', () => ({
useLocalize: () => (key) => {
const translations = {
@ -47,17 +77,9 @@ jest.mock('~/hooks', () => ({
};
return translations[key] || key;
},
useAuthContext: () => ({
user: mockUser,
token: 'mock-token',
isAuthenticated: true,
error: undefined,
login: jest.fn(),
logout: jest.fn(),
setError: jest.fn(),
roles: {},
}),
useHasAccess: () => true,
useAuthContext: () => mockUseAuthContext(),
useHasAccess: () => mockUseHasAccess(),
useResourcePermissions: () => mockUseResourcePermissions(),
}));
const createBaseMutation = <T = Agent, P = any>(
@ -126,9 +148,9 @@ jest.mock('../DeleteButton', () => ({
default: jest.fn(() => <div data-testid="delete-button" />),
}));
jest.mock('../ShareAgent', () => ({
jest.mock('../Sharing/GrantAccessDialog', () => ({
__esModule: true,
default: jest.fn(() => <div data-testid="share-agent" />),
default: jest.fn(() => <div data-testid="grant-access-dialog" />),
}));
jest.mock('../DuplicateAgent', () => ({
@ -186,6 +208,40 @@ describe('AgentFooter', () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset to default mock implementations
mockUseWatch.mockImplementation(({ name }) => {
if (name === 'agent') {
return {
_id: 'agent-db-123',
name: 'Test Agent',
author: 'user-123',
projectIds: ['project-1'],
isCollaborative: false,
};
}
if (name === 'id') {
return 'agent-123';
}
return undefined;
});
// Reset auth context to default user
mockUseAuthContext.mockReturnValue({
user: mockUser,
token: 'mock-token',
isAuthenticated: true,
error: undefined,
login: jest.fn(),
logout: jest.fn(),
setError: jest.fn(),
roles: {},
});
// Reset access and permissions to defaults
mockUseHasAccess.mockReturnValue(true);
mockUseResourcePermissions.mockReturnValue({
hasPermission: () => true,
isLoading: false,
permissionBits: 0,
});
});
describe('Main Functionality', () => {
@ -196,8 +252,8 @@ describe('AgentFooter', () => {
expect(screen.getByTestId('version-button')).toBeInTheDocument();
expect(screen.getByTestId('delete-button')).toBeInTheDocument();
expect(screen.queryByTestId('admin-settings')).not.toBeInTheDocument();
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
expect(screen.queryByTestId('duplicate-agent')).not.toBeInTheDocument();
expect(screen.getByTestId('grant-access-dialog')).toBeInTheDocument();
expect(screen.getByTestId('duplicate-agent')).toBeInTheDocument();
expect(document.querySelector('.spinner')).not.toBeInTheDocument();
});
@ -227,42 +283,125 @@ describe('AgentFooter', () => {
});
test('adjusts UI based on agent ID existence', () => {
jest.spyOn(reactHookForm, 'useWatch').mockImplementation(() => ({
agent: { name: 'Test Agent', author: 'user-123' },
id: undefined,
}));
mockUseWatch.mockImplementation(({ name }) => {
if (name === 'agent') {
return null; // No agent means no delete/share/duplicate buttons
}
if (name === 'id') {
return undefined; // No ID means create mode
}
return undefined;
});
// When there's no agent, permissions should also return false
mockUseResourcePermissions.mockReturnValue({
hasPermission: () => false,
isLoading: false,
permissionBits: 0,
});
render(<AgentFooter {...defaultProps} />);
expect(screen.getByText('Save')).toBeInTheDocument();
expect(screen.getByTestId('version-button')).toBeInTheDocument();
});
test('adjusts UI based on user role', () => {
jest.spyOn(hooks, 'useAuthContext').mockReturnValue(createAuthContext(mockUsers.admin));
render(<AgentFooter {...defaultProps} />);
expect(screen.queryByTestId('admin-settings')).not.toBeInTheDocument();
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
jest.clearAllMocks();
jest.spyOn(hooks, 'useAuthContext').mockReturnValue(createAuthContext(mockUsers.different));
render(<AgentFooter {...defaultProps} />);
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
expect(screen.getByText('Create')).toBeInTheDocument();
expect(screen.queryByTestId('version-button')).not.toBeInTheDocument();
expect(screen.queryByTestId('delete-button')).not.toBeInTheDocument();
expect(screen.queryByTestId('grant-access-dialog')).not.toBeInTheDocument();
expect(screen.queryByTestId('duplicate-agent')).not.toBeInTheDocument();
});
test('adjusts UI based on permissions', () => {
jest.spyOn(hooks, 'useHasAccess').mockReturnValue(false);
test('adjusts UI based on user role', () => {
mockUseAuthContext.mockReturnValue(createAuthContext(mockUsers.admin));
const { unmount } = render(<AgentFooter {...defaultProps} />);
expect(screen.getByTestId('admin-settings')).toBeInTheDocument();
expect(screen.getByTestId('grant-access-dialog')).toBeInTheDocument();
// Clean up the first render
unmount();
jest.clearAllMocks();
mockUseAuthContext.mockReturnValue(createAuthContext(mockUsers.different));
mockUseWatch.mockImplementation(({ name }) => {
if (name === 'agent') {
return { name: 'Test Agent', author: 'different-author', _id: 'agent-123' };
}
if (name === 'id') {
return 'agent-123';
}
return undefined;
});
render(<AgentFooter {...defaultProps} />);
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
expect(screen.queryByTestId('grant-access-dialog')).toBeInTheDocument(); // Still shows because hasAccess is true
expect(screen.queryByTestId('duplicate-agent')).not.toBeInTheDocument(); // Should not show for different author
});
test('adjusts UI based on permissions', () => {
mockUseHasAccess.mockReturnValue(false);
// Also need to ensure the agent is not owned by the user and user is not admin
mockUseWatch.mockImplementation(({ name }) => {
if (name === 'agent') {
return {
_id: 'agent-db-123',
name: 'Test Agent',
author: 'different-user', // Different author
projectIds: ['project-1'],
isCollaborative: false,
};
}
if (name === 'id') {
return 'agent-123';
}
return undefined;
});
// Mock permissions to not allow sharing
mockUseResourcePermissions.mockReturnValue({
hasPermission: () => false, // No permissions
isLoading: false,
permissionBits: 0,
});
render(<AgentFooter {...defaultProps} />);
expect(screen.queryByTestId('grant-access-dialog')).not.toBeInTheDocument();
});
test('hides action buttons when permissions are loading', () => {
// Ensure we have an agent that would normally show buttons
mockUseWatch.mockImplementation(({ name }) => {
if (name === 'agent') {
return {
_id: 'agent-db-123',
name: 'Test Agent',
author: 'user-123', // Same as current user
projectIds: ['project-1'],
isCollaborative: false,
};
}
if (name === 'id') {
return 'agent-123';
}
return undefined;
});
mockUseResourcePermissions.mockReturnValue({
hasPermission: () => true,
isLoading: true, // This should hide the buttons
permissionBits: 0,
});
render(<AgentFooter {...defaultProps} />);
expect(screen.queryByTestId('delete-button')).not.toBeInTheDocument();
expect(screen.queryByTestId('grant-access-dialog')).not.toBeInTheDocument();
// Duplicate button should still show as it doesn't depend on permissions loading
expect(screen.getByTestId('duplicate-agent')).toBeInTheDocument();
});
});
describe('Edge Cases', () => {
test('handles null agent data', () => {
jest.spyOn(reactHookForm, 'useWatch').mockImplementation(() => ({
agent: null,
id: 'agent-123',
}));
mockUseWatch.mockImplementation(({ name }) => {
if (name === 'agent') {
return null;
}
if (name === 'id') {
return 'agent-123';
}
return undefined;
});
render(<AgentFooter {...defaultProps} />);
expect(screen.getByText('Save')).toBeInTheDocument();

View file

@ -0,0 +1,339 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import AgentGrid from '../AgentGrid';
import { useDynamicAgentQuery } from '~/hooks/Agents';
import type t from 'librechat-data-provider';
// Mock the dynamic agent query hook
jest.mock('~/hooks/Agents', () => ({
useDynamicAgentQuery: jest.fn(),
}));
// Mock useLocalize hook
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
const mockTranslations: Record<string, string> = {
com_agents_top_picks: 'Top Picks',
com_agents_all: 'All Agents',
com_agents_recommended: 'Our recommended agents',
com_agents_results_for: 'Results for "{{query}}"',
com_agents_see_more: 'See more',
com_agents_error_loading: 'Error loading agents',
com_agents_error_searching: 'Error searching agents',
com_agents_no_results: 'No agents found. Try another search term.',
com_agents_none_in_category: 'No agents found in this category',
};
return mockTranslations[key] || key;
});
// Mock getCategoryDisplayName and getCategoryDescription
jest.mock('~/utils/agents', () => ({
getCategoryDisplayName: (category: string) => {
const names: Record<string, string> = {
promoted: 'Top Picks',
all: 'All',
general: 'General',
hr: 'HR',
finance: 'Finance',
};
return names[category] || category;
},
getCategoryDescription: (category: string) => {
const descriptions: Record<string, string> = {
promoted: 'Our recommended agents',
all: 'Browse all available agents',
general: 'General purpose agents',
hr: 'HR agents',
finance: 'Finance agents',
};
return descriptions[category] || '';
},
}));
const mockUseDynamicAgentQuery = useDynamicAgentQuery as jest.MockedFunction<
typeof useDynamicAgentQuery
>;
describe('AgentGrid Integration with useDynamicAgentQuery', () => {
const mockOnSelectAgent = jest.fn();
const mockAgents: Partial<t.Agent>[] = [
{
id: '1',
name: 'Test Agent 1',
description: 'First test agent',
avatar: '/avatar1.png',
},
{
id: '2',
name: 'Test Agent 2',
description: 'Second test agent',
avatar: { filepath: '/avatar2.png' },
},
];
const defaultMockQueryResult = {
data: {
agents: mockAgents,
pagination: {
current: 1,
hasMore: true,
total: 10,
},
},
isLoading: false,
error: null,
isFetching: false,
queryType: 'promoted' as const,
};
beforeEach(() => {
jest.clearAllMocks();
mockUseDynamicAgentQuery.mockReturnValue(defaultMockQueryResult);
});
describe('Query Integration', () => {
it('should call useDynamicAgentQuery with correct parameters', () => {
render(
<AgentGrid category="finance" searchQuery="test query" onSelectAgent={mockOnSelectAgent} />,
);
expect(mockUseDynamicAgentQuery).toHaveBeenCalledWith({
category: 'finance',
searchQuery: 'test query',
page: 1,
limit: 6,
});
});
it('should update page when "See More" is clicked', async () => {
render(<AgentGrid category="hr" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
const seeMoreButton = screen.getByText('See more');
fireEvent.click(seeMoreButton);
await waitFor(() => {
expect(mockUseDynamicAgentQuery).toHaveBeenCalledWith({
category: 'hr',
searchQuery: '',
page: 2,
limit: 6,
});
});
});
it('should reset page when category changes', () => {
const { rerender } = render(
<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />,
);
// Simulate clicking "See More" to increment page
const seeMoreButton = screen.getByText('See more');
fireEvent.click(seeMoreButton);
// Change category - should reset page to 1
rerender(<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(mockUseDynamicAgentQuery).toHaveBeenLastCalledWith({
category: 'finance',
searchQuery: '',
page: 1,
limit: 6,
});
});
it('should reset page when search query changes', () => {
const { rerender } = render(
<AgentGrid category="hr" searchQuery="" onSelectAgent={mockOnSelectAgent} />,
);
// Change search query - should reset page to 1
rerender(
<AgentGrid category="hr" searchQuery="new search" onSelectAgent={mockOnSelectAgent} />,
);
expect(mockUseDynamicAgentQuery).toHaveBeenLastCalledWith({
category: 'hr',
searchQuery: 'new search',
page: 1,
limit: 6,
});
});
});
describe('Different Query Types Display', () => {
it('should display correct title for promoted category', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
queryType: 'promoted',
});
render(<AgentGrid category="promoted" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.getByText('Top Picks')).toBeInTheDocument();
expect(screen.getByText('Our recommended agents')).toBeInTheDocument();
});
it('should display correct title for search results', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
queryType: 'search',
});
render(
<AgentGrid category="all" searchQuery="test search" onSelectAgent={mockOnSelectAgent} />,
);
expect(screen.getByText('Results for "test search"')).toBeInTheDocument();
});
it('should display correct title for specific category', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
queryType: 'category',
});
render(<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.getByText('Finance')).toBeInTheDocument();
expect(screen.getByText('Finance agents')).toBeInTheDocument();
});
it('should display correct title for all category', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
queryType: 'all',
});
render(<AgentGrid category="all" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.getByText('All')).toBeInTheDocument();
expect(screen.getByText('Browse all available agents')).toBeInTheDocument();
});
});
describe('Loading and Error States', () => {
it('should show loading skeleton when isLoading is true and no data', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
data: undefined,
isLoading: true,
});
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
// Should show loading skeletons
const loadingElements = screen.getAllByRole('generic');
const hasLoadingClass = loadingElements.some((el) => el.className.includes('animate-pulse'));
expect(hasLoadingClass).toBe(true);
});
it('should show error message when there is an error', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
data: undefined,
error: new Error('Test error'),
});
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.getByText('Error loading agents')).toBeInTheDocument();
});
it('should show loading spinner when fetching more data', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
isFetching: true,
});
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
// Should show agents and loading spinner for pagination
expect(screen.getByText('Test Agent 1')).toBeInTheDocument();
expect(screen.getByText('Test Agent 2')).toBeInTheDocument();
});
});
describe('Agent Interaction', () => {
it('should call onSelectAgent when agent card is clicked', () => {
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
const agentCard = screen.getByLabelText('Test Agent 1 agent card');
fireEvent.click(agentCard);
expect(mockOnSelectAgent).toHaveBeenCalledWith(mockAgents[0]);
});
});
describe('Pagination', () => {
it('should show "See More" button when hasMore is true', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
data: {
agents: mockAgents,
pagination: {
current: 1,
hasMore: true,
total: 10,
},
},
});
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.getByText('See more')).toBeInTheDocument();
});
it('should not show "See More" button when hasMore is false', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
data: {
agents: mockAgents,
pagination: {
current: 1,
hasMore: false,
total: 2,
},
},
});
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.queryByText('See more')).not.toBeInTheDocument();
});
});
describe('Empty States', () => {
it('should show empty state for search results', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
data: {
agents: [],
pagination: { current: 1, hasMore: false, total: 0 },
},
queryType: 'search',
});
render(
<AgentGrid category="all" searchQuery="no results" onSelectAgent={mockOnSelectAgent} />,
);
expect(screen.getByText('No agents found. Try another search term.')).toBeInTheDocument();
});
it('should show empty state for category with no agents', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
data: {
agents: [],
pagination: { current: 1, hasMore: false, total: 0 },
},
queryType: 'category',
});
render(<AgentGrid category="hr" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.getByText('No agents found in this category')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,229 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import CategoryTabs from '../CategoryTabs';
import type t from 'librechat-data-provider';
// Mock useLocalize hook
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
const mockTranslations: Record<string, string> = {
com_agents_top_picks: 'Top Picks',
com_agents_all: 'All',
com_agents_no_categories: 'No categories available',
com_agents_category_tabs_label: 'Agent Categories',
com_ui_agent_category_general: 'General',
com_ui_agent_category_hr: 'HR',
com_ui_agent_category_rd: 'R&D',
com_ui_agent_category_finance: 'Finance',
com_ui_agent_category_it: 'IT',
com_ui_agent_category_sales: 'Sales',
com_ui_agent_category_aftersales: 'After Sales',
};
return mockTranslations[key] || key;
});
describe('CategoryTabs', () => {
const mockCategories: t.TMarketplaceCategory[] = [
{ value: 'promoted', label: 'Top Picks', description: 'Our recommended agents', count: 5 },
{ value: 'all', label: 'All', description: 'All available agents', count: 20 },
{ value: 'general', label: 'General', description: 'General purpose agents', count: 8 },
{ value: 'hr', label: 'HR', description: 'HR agents', count: 3 },
{ value: 'finance', label: 'Finance', description: 'Finance agents', count: 4 },
];
const mockOnChange = jest.fn();
const user = userEvent.setup();
beforeEach(() => {
mockOnChange.mockClear();
});
it('renders provided categories', () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="promoted"
isLoading={false}
onChange={mockOnChange}
/>,
);
// Check for provided categories
expect(screen.getByText('Top Picks')).toBeInTheDocument();
expect(screen.getByText('All')).toBeInTheDocument();
expect(screen.getByText('General')).toBeInTheDocument();
expect(screen.getByText('HR')).toBeInTheDocument();
expect(screen.getByText('Finance')).toBeInTheDocument();
});
it('handles loading state properly', () => {
render(
<CategoryTabs
categories={[]}
activeTab="promoted"
isLoading={true}
onChange={mockOnChange}
/>,
);
// SmartLoader should handle loading behavior correctly
// The component should render without crashing during loading
expect(screen.queryByText('No categories available')).not.toBeInTheDocument();
});
it('highlights the active tab', () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="general"
isLoading={false}
onChange={mockOnChange}
/>,
);
const generalTab = screen.getByText('General').closest('button');
expect(generalTab).toHaveClass('text-gray-900');
// Should have active underline
const underline = generalTab?.querySelector('.absolute.bottom-0');
expect(underline).toBeInTheDocument();
});
it('calls onChange when a tab is clicked', async () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="promoted"
isLoading={false}
onChange={mockOnChange}
/>,
);
const hrTab = screen.getByText('HR');
await user.click(hrTab);
expect(mockOnChange).toHaveBeenCalledWith('hr');
});
it('handles promoted tab click correctly', async () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="general"
isLoading={false}
onChange={mockOnChange}
/>,
);
const topPicksTab = screen.getByText('Top Picks');
await user.click(topPicksTab);
expect(mockOnChange).toHaveBeenCalledWith('promoted');
});
it('handles all tab click correctly', async () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="promoted"
isLoading={false}
onChange={mockOnChange}
/>,
);
const allTab = screen.getByText('All');
await user.click(allTab);
expect(mockOnChange).toHaveBeenCalledWith('all');
});
it('shows inactive state for non-selected tabs', () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="promoted"
isLoading={false}
onChange={mockOnChange}
/>,
);
const generalTab = screen.getByText('General').closest('button');
expect(generalTab).toHaveClass('text-gray-600');
// Should not have active underline
const underline = generalTab?.querySelector('.absolute.bottom-0');
expect(underline).not.toBeInTheDocument();
});
it('renders with proper accessibility', () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="promoted"
isLoading={false}
onChange={mockOnChange}
/>,
);
const tabs = screen.getAllByRole('tab');
expect(tabs.length).toBe(5);
// Verify all tabs are properly clickable buttons
tabs.forEach((tab) => {
expect(tab.tagName).toBe('BUTTON');
});
});
it('handles keyboard navigation', async () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="promoted"
isLoading={false}
onChange={mockOnChange}
/>,
);
const generalTab = screen.getByText('General').closest('button')!;
// Focus the button and click it
generalTab.focus();
expect(document.activeElement).toBe(generalTab);
await user.click(generalTab);
expect(mockOnChange).toHaveBeenCalledWith('general');
});
it('shows empty state when categories prop is empty', () => {
render(
<CategoryTabs
categories={[]}
activeTab="promoted"
isLoading={false}
onChange={mockOnChange}
/>,
);
// Should show empty state message (localized)
expect(screen.getByText('No categories available')).toBeInTheDocument();
});
it('maintains consistent ordering of categories', () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="promoted"
isLoading={false}
onChange={mockOnChange}
/>,
);
const tabs = screen.getAllByRole('tab');
const tabTexts = tabs.map((tab) => tab.textContent);
// Check that promoted is first and all is second
expect(tabTexts[0]).toBe('Top Picks');
expect(tabTexts[1]).toBe('All');
expect(tabTexts.length).toBe(5);
});
});

View file

@ -0,0 +1,302 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { ErrorDisplay } from '../ErrorDisplay';
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
// Mock the localize hook
const mockLocalize = jest.fn((key: string, options?: any) => {
const translations: Record<string, string> = {
com_agents_error_title: 'Something went wrong',
com_agents_error_generic: 'We encountered an issue while loading the content.',
com_agents_error_suggestion_generic: 'Please try refreshing the page or try again later.',
com_agents_error_network_title: 'Connection Problem',
com_agents_error_network_message: 'Unable to connect to the server.',
com_agents_error_network_suggestion: 'Check your internet connection and try again.',
com_agents_error_not_found_title: 'Not Found',
com_agents_error_not_found_message: 'The requested content could not be found.',
com_agents_error_not_found_suggestion:
'Try browsing other options or go back to the marketplace.',
com_agents_error_invalid_request: 'Invalid Request',
com_agents_error_bad_request_message: 'The request could not be processed.',
com_agents_error_bad_request_suggestion: 'Please check your input and try again.',
com_agents_error_server_title: 'Server Error',
com_agents_error_server_message: 'The server is temporarily unavailable.',
com_agents_error_server_suggestion: 'Please try again in a few moments.',
com_agents_error_search_title: 'Search Error',
com_agents_error_category_title: 'Category Error',
com_agents_search_no_results: `No agents found for "${options?.query}"`,
com_agents_category_empty: `No agents found in the ${options?.category} category`,
com_agents_error_retry: 'Try Again',
};
return translations[key] || key;
});
jest.mock('~/hooks/useLocalize', () => () => mockLocalize);
describe('ErrorDisplay', () => {
beforeEach(() => {
mockLocalize.mockClear();
});
describe('Backend error responses', () => {
it('displays user-friendly message from backend response', () => {
const error = {
response: {
data: {
userMessage: 'Unable to load agents. Please try refreshing the page.',
suggestion: 'Try refreshing the page or check your network connection',
},
},
};
render(<ErrorDisplay error={error} />);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
expect(
screen.getByText('Unable to load agents. Please try refreshing the page.'),
).toBeInTheDocument();
expect(
screen.getByText('💡 Try refreshing the page or check your network connection'),
).toBeInTheDocument();
});
it('handles search context with backend response', () => {
const error = {
response: {
data: {
userMessage: 'Search is temporarily unavailable. Please try again.',
suggestion: 'Try a different search term or check your network connection',
},
},
};
render(<ErrorDisplay error={error} context={{ searchQuery: 'test query' }} />);
expect(screen.getByText('Search Error')).toBeInTheDocument();
expect(
screen.getByText('Search is temporarily unavailable. Please try again.'),
).toBeInTheDocument();
});
});
describe('Network errors', () => {
it('displays network error message', () => {
const error = {
code: 'NETWORK_ERROR',
message: 'Network Error',
};
render(<ErrorDisplay error={error} />);
expect(screen.getByText('Connection Problem')).toBeInTheDocument();
expect(screen.getByText('Unable to connect to the server.')).toBeInTheDocument();
expect(
screen.getByText('💡 Check your internet connection and try again.'),
).toBeInTheDocument();
});
it('handles timeout errors', () => {
const error = {
code: 'ECONNABORTED',
message: 'timeout of 5000ms exceeded',
};
render(<ErrorDisplay error={error} />);
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_timeout_title');
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_timeout_message');
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_timeout_suggestion');
});
});
describe('HTTP status codes', () => {
it('handles 404 errors with search context', () => {
const error = {
response: {
status: 404,
data: {},
},
};
render(<ErrorDisplay error={error} context={{ searchQuery: 'nonexistent agent' }} />);
expect(screen.getByText('Not Found')).toBeInTheDocument();
expect(screen.getByText('No agents found for "nonexistent agent"')).toBeInTheDocument();
});
it('handles 404 errors with category context', () => {
const error = {
response: {
status: 404,
data: {},
},
};
render(<ErrorDisplay error={error} context={{ category: 'productivity' }} />);
expect(screen.getByText('Not Found')).toBeInTheDocument();
expect(screen.getByText('No agents found in the productivity category')).toBeInTheDocument();
});
it('handles 400 bad request errors', () => {
const error = {
response: {
status: 400,
data: {
error: 'Search query is required',
userMessage: 'Please enter a search term to find agents',
suggestion: 'Enter a search term to find agents by name or description',
},
},
};
render(<ErrorDisplay error={error} />);
expect(screen.getByText('Invalid Request')).toBeInTheDocument();
expect(screen.getByText('Please enter a search term to find agents')).toBeInTheDocument();
expect(
screen.getByText('💡 Enter a search term to find agents by name or description'),
).toBeInTheDocument();
});
it('handles 500 server errors', () => {
const error = {
response: {
status: 500,
data: {},
},
};
render(<ErrorDisplay error={error} />);
expect(screen.getByText('Server Error')).toBeInTheDocument();
expect(screen.getByText('The server is temporarily unavailable.')).toBeInTheDocument();
expect(screen.getByText('💡 Please try again in a few moments.')).toBeInTheDocument();
});
});
describe('Retry functionality', () => {
it('displays retry button when onRetry is provided', () => {
const mockRetry = jest.fn();
const error = {
response: {
data: {
userMessage: 'Unable to load agents. Please try refreshing the page.',
},
},
};
render(<ErrorDisplay error={error} onRetry={mockRetry} />);
const retryButton = screen.getByText('Try Again');
expect(retryButton).toBeInTheDocument();
fireEvent.click(retryButton);
expect(mockRetry).toHaveBeenCalledTimes(1);
});
it('does not display retry button when onRetry is not provided', () => {
const error = {
response: {
data: {
userMessage: 'Unable to load agents. Please try refreshing the page.',
},
},
};
render(<ErrorDisplay error={error} />);
expect(screen.queryByText('Try Again')).not.toBeInTheDocument();
});
});
describe('Context-aware titles', () => {
it('shows search error title for search context', () => {
const error = { message: 'Some error' };
render(<ErrorDisplay error={error} context={{ searchQuery: 'test' }} />);
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_search_title');
});
it('shows category error title for category context', () => {
const error = { message: 'Some error' };
render(<ErrorDisplay error={error} context={{ category: 'productivity' }} />);
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_category_title');
});
it('shows generic error title when no context', () => {
const error = { message: 'Some error' };
render(<ErrorDisplay error={error} />);
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_title');
});
});
describe('Fallback error handling', () => {
it('handles unknown errors gracefully', () => {
const error = {
message: 'Unknown error occurred',
};
render(<ErrorDisplay error={error} />);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
expect(
screen.getByText('We encountered an issue while loading the content.'),
).toBeInTheDocument();
expect(
screen.getByText('💡 Please try refreshing the page or try again later.'),
).toBeInTheDocument();
});
it('handles null/undefined errors', () => {
render(<ErrorDisplay error={null} />);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
expect(
screen.getByText('We encountered an issue while loading the content.'),
).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('renders error icon with proper accessibility', () => {
const error = { message: 'Test error' };
render(<ErrorDisplay error={error} />);
const errorIcon = screen.getByRole('img', { hidden: true });
expect(errorIcon).toBeInTheDocument();
});
it('has proper heading structure', () => {
const error = { message: 'Test error' };
render(<ErrorDisplay error={error} />);
const heading = screen.getByRole('heading', { level: 3 });
expect(heading).toHaveTextContent('Something went wrong');
});
});
});
export default {};

View file

@ -0,0 +1,134 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { EModelEndpoint } from 'librechat-data-provider';
import { MarketplaceProvider } from '../MarketplaceContext';
import { useChatContext } from '~/Providers';
// Mock the ChatContext from Providers
jest.mock('~/Providers', () => ({
ChatContext: {
Provider: ({ children, value }: { children: React.ReactNode; value: any }) => (
<div data-testid="chat-context-provider" data-value={JSON.stringify(value)}>
{children}
</div>
),
},
useChatContext: jest.fn(),
}));
const mockedUseChatContext = useChatContext as jest.MockedFunction<typeof useChatContext>;
// Test component that consumes the context
const TestConsumer: React.FC = () => {
const context = mockedUseChatContext();
return (
<div>
<div data-testid="endpoint">{context?.conversation?.endpoint}</div>
<div data-testid="conversation-id">{context?.conversation?.conversationId}</div>
<div data-testid="title">{context?.conversation?.title}</div>
</div>
);
};
describe('MarketplaceProvider', () => {
beforeEach(() => {
mockedUseChatContext.mockClear();
});
it('provides correct marketplace context values', () => {
const mockContext = {
conversation: {
endpoint: EModelEndpoint.agents,
conversationId: 'marketplace',
title: 'Agent Marketplace',
},
};
mockedUseChatContext.mockReturnValue(mockContext);
render(
<MarketplaceProvider>
<TestConsumer />
</MarketplaceProvider>,
);
expect(screen.getByTestId('endpoint')).toHaveTextContent(EModelEndpoint.agents);
expect(screen.getByTestId('conversation-id')).toHaveTextContent('marketplace');
expect(screen.getByTestId('title')).toHaveTextContent('Agent Marketplace');
});
it('creates ChatContext.Provider with correct structure', () => {
render(
<MarketplaceProvider>
<div>{/* eslint-disable-line i18next/no-literal-string */}Test Child</div>
</MarketplaceProvider>,
);
const provider = screen.getByTestId('chat-context-provider');
expect(provider).toBeInTheDocument();
const valueData = JSON.parse(provider.getAttribute('data-value') || '{}');
expect(valueData.conversation).toEqual({
endpoint: EModelEndpoint.agents,
conversationId: 'marketplace',
title: 'Agent Marketplace',
});
});
it('renders children correctly', () => {
render(
<MarketplaceProvider>
<div data-testid="test-child">
{/* eslint-disable-line i18next/no-literal-string */}Test Content
</div>
</MarketplaceProvider>,
);
expect(screen.getByTestId('test-child')).toBeInTheDocument();
expect(screen.getByTestId('test-child')).toHaveTextContent('Test Content');
});
it('provides stable context value (memoization)', () => {
const { rerender } = render(
<MarketplaceProvider>
<TestConsumer />
</MarketplaceProvider>,
);
const firstProvider = screen.getByTestId('chat-context-provider');
const firstValue = firstProvider.getAttribute('data-value');
// Rerender should provide the same memoized value
rerender(
<MarketplaceProvider>
<TestConsumer />
</MarketplaceProvider>,
);
const secondProvider = screen.getByTestId('chat-context-provider');
const secondValue = secondProvider.getAttribute('data-value');
expect(firstValue).toBe(secondValue);
});
it('provides minimal context without bloated functions', () => {
render(
<MarketplaceProvider>
<div>{/* eslint-disable-line i18next/no-literal-string */}Test</div>
</MarketplaceProvider>,
);
const provider = screen.getByTestId('chat-context-provider');
const valueData = JSON.parse(provider.getAttribute('data-value') || '{}');
// Should only have conversation object, not 44 empty functions
expect(Object.keys(valueData)).toContain('conversation');
expect(valueData.conversation).toEqual({
endpoint: EModelEndpoint.agents,
conversationId: 'marketplace',
title: 'Agent Marketplace',
});
});
});

View file

@ -0,0 +1,141 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import SearchBar from '../SearchBar';
// Mock useLocalize hook
jest.mock('~/hooks/useLocalize', () => () => (key: string) => key);
// Mock useDebounce hook
jest.mock('~/hooks', () => ({
useDebounce: (value: string, delay: number) => value, // Return value immediately for testing
}));
describe('SearchBar', () => {
const mockOnSearch = jest.fn();
const user = userEvent.setup();
beforeEach(() => {
mockOnSearch.mockClear();
});
it('renders with correct placeholder', () => {
render(<SearchBar value="" onSearch={mockOnSearch} />);
const input = screen.getByRole('textbox');
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute('placeholder', 'com_agents_search_placeholder');
});
it('displays the provided value', () => {
render(<SearchBar value="test query" onSearch={mockOnSearch} />);
const input = screen.getByDisplayValue('test query');
expect(input).toBeInTheDocument();
});
it('calls onSearch when user types', async () => {
render(<SearchBar value="" onSearch={mockOnSearch} />);
const input = screen.getByRole('textbox');
await user.type(input, 'test');
// Should call onSearch for each character due to debounce mock
expect(mockOnSearch).toHaveBeenCalled();
});
it('shows clear button when there is text', () => {
render(<SearchBar value="test" onSearch={mockOnSearch} />);
const clearButton = screen.getByRole('button', { name: 'com_agents_clear_search' });
expect(clearButton).toBeInTheDocument();
});
it('does not show clear button when text is empty', () => {
render(<SearchBar value="" onSearch={mockOnSearch} />);
const clearButton = screen.queryByRole('button', { name: 'com_agents_clear_search' });
expect(clearButton).not.toBeInTheDocument();
});
it('clears search when clear button is clicked', async () => {
render(<SearchBar value="test" onSearch={mockOnSearch} />);
const input = screen.getByRole('textbox');
const clearButton = screen.getByRole('button', { name: 'com_agents_clear_search' });
// Verify initial state
expect(input).toHaveValue('test');
await user.click(clearButton);
// Verify onSearch is called and input is cleared
expect(mockOnSearch).toHaveBeenCalledWith('');
expect(input).toHaveValue('');
});
it('updates internal state when value prop changes', () => {
const { rerender } = render(<SearchBar value="initial" onSearch={mockOnSearch} />);
expect(screen.getByDisplayValue('initial')).toBeInTheDocument();
rerender(<SearchBar value="updated" onSearch={mockOnSearch} />);
expect(screen.getByDisplayValue('updated')).toBeInTheDocument();
});
it('has proper accessibility attributes', () => {
render(<SearchBar value="" onSearch={mockOnSearch} />);
const input = screen.getByRole('textbox');
expect(input).toHaveAttribute('aria-label', 'com_agents_search_aria');
});
it('applies custom className', () => {
render(<SearchBar value="" onSearch={mockOnSearch} className="custom-class" />);
const container = screen.getByRole('textbox').closest('div');
expect(container).toHaveClass('custom-class');
});
it('prevents form submission on clear button click', async () => {
const handleSubmit = jest.fn();
render(
<form onSubmit={handleSubmit}>
<SearchBar value="test" onSearch={mockOnSearch} />
</form>,
);
const clearButton = screen.getByRole('button', { name: 'com_agents_clear_search' });
await user.click(clearButton);
expect(handleSubmit).not.toHaveBeenCalled();
});
it('handles rapid typing correctly', async () => {
render(<SearchBar value="" onSearch={mockOnSearch} />);
const input = screen.getByRole('textbox');
// Type multiple characters quickly
await user.type(input, 'quick');
// Should handle all characters
expect(input).toHaveValue('quick');
});
it('maintains focus after clear button click', async () => {
render(<SearchBar value="test" onSearch={mockOnSearch} />);
const input = screen.getByRole('textbox');
const clearButton = screen.getByRole('button', { name: 'com_agents_clear_search' });
input.focus();
await user.click(clearButton);
// Input should still be in the document and ready for new input
expect(input).toBeInTheDocument();
});
});

View file

@ -0,0 +1,370 @@
import React from 'react';
import { render, screen, waitFor, act } from '@testing-library/react';
import { SmartLoader, useHasData } from '../SmartLoader';
// Mock setTimeout and clearTimeout for testing
jest.useFakeTimers();
describe('SmartLoader', () => {
const LoadingComponent = () => <div data-testid="loading">Loading...</div>;
const ContentComponent = () => (
<div data-testid="content">
{/* eslint-disable-line i18next/no-literal-string */}Content loaded
</div>
);
beforeEach(() => {
jest.clearAllTimers();
});
afterEach(() => {
jest.useRealTimers();
jest.useFakeTimers();
});
describe('Basic functionality', () => {
it('shows content immediately when not loading', () => {
render(
<SmartLoader isLoading={false} hasData={true} loadingComponent={<LoadingComponent />}>
<ContentComponent />
</SmartLoader>,
);
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
});
it('shows content immediately when loading but has existing data', () => {
render(
<SmartLoader isLoading={true} hasData={true} loadingComponent={<LoadingComponent />}>
<ContentComponent />
</SmartLoader>,
);
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
});
it('shows content initially, then loading after delay when loading with no data', async () => {
render(
<SmartLoader
isLoading={true}
hasData={false}
delay={150}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
// Initially shows content
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
// After delay, shows loading
act(() => {
jest.advanceTimersByTime(150);
});
await waitFor(() => {
expect(screen.getByTestId('loading')).toBeInTheDocument();
expect(screen.queryByTestId('content')).not.toBeInTheDocument();
});
});
it('prevents loading flash for quick responses', async () => {
const { rerender } = render(
<SmartLoader
isLoading={true}
hasData={false}
delay={150}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
// Initially shows content
expect(screen.getByTestId('content')).toBeInTheDocument();
// Advance time but not past delay
act(() => {
jest.advanceTimersByTime(100);
});
// Loading finishes before delay
rerender(
<SmartLoader
isLoading={false}
hasData={true}
delay={150}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
// Should still show content, never showed loading
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
// Advance past original delay to ensure loading doesn't appear
act(() => {
jest.advanceTimersByTime(100);
});
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
});
});
describe('Delay behavior', () => {
it('respects custom delay times', async () => {
render(
<SmartLoader
isLoading={true}
hasData={false}
delay={300}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
// Should show content initially
expect(screen.getByTestId('content')).toBeInTheDocument();
// Should not show loading before delay
act(() => {
jest.advanceTimersByTime(250);
});
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
// Should show loading after delay
act(() => {
jest.advanceTimersByTime(60);
});
await waitFor(() => {
expect(screen.getByTestId('loading')).toBeInTheDocument();
});
});
it('uses default delay when not specified', async () => {
render(
<SmartLoader isLoading={true} hasData={false} loadingComponent={<LoadingComponent />}>
<ContentComponent />
</SmartLoader>,
);
// Should show content initially
expect(screen.getByTestId('content')).toBeInTheDocument();
// Should show loading after default delay (150ms)
act(() => {
jest.advanceTimersByTime(150);
});
await waitFor(() => {
expect(screen.getByTestId('loading')).toBeInTheDocument();
});
});
});
describe('State transitions', () => {
it('immediately hides loading when loading completes', async () => {
const { rerender } = render(
<SmartLoader
isLoading={true}
hasData={false}
delay={100}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
// Advance past delay to show loading
act(() => {
jest.advanceTimersByTime(100);
});
await waitFor(() => {
expect(screen.getByTestId('loading')).toBeInTheDocument();
});
// Loading completes
rerender(
<SmartLoader
isLoading={false}
hasData={true}
delay={100}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
// Should immediately show content
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
});
it('handles rapid loading state changes correctly', async () => {
const { rerender } = render(
<SmartLoader
isLoading={true}
hasData={false}
delay={100}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
// Rapid state changes
rerender(
<SmartLoader
isLoading={false}
hasData={true}
delay={100}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
rerender(
<SmartLoader
isLoading={true}
hasData={false}
delay={100}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
// Should show content throughout rapid changes
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
});
});
describe('CSS classes', () => {
it('applies custom className', () => {
const { container } = render(
<SmartLoader
isLoading={false}
hasData={true}
loadingComponent={<LoadingComponent />}
className="custom-class"
>
<ContentComponent />
</SmartLoader>,
);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveClass('custom-class');
});
it('applies className to both loading and content states', async () => {
const { container } = render(
<SmartLoader
isLoading={true}
hasData={false}
delay={50}
loadingComponent={<LoadingComponent />}
className="custom-class"
>
<ContentComponent />
</SmartLoader>,
);
// Content state
expect(container.firstChild).toHaveClass('custom-class');
// Loading state
act(() => {
jest.advanceTimersByTime(50);
});
await waitFor(() => {
expect(container.firstChild).toHaveClass('custom-class');
});
});
});
});
describe('useHasData', () => {
const TestComponent: React.FC<{ data: any }> = ({ data }) => {
const hasData = useHasData(data);
return <div data-testid="result">{hasData ? 'has-data' : 'no-data'}</div>;
};
it('returns false for null data', () => {
render(<TestComponent data={null} />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
it('returns false for undefined data', () => {
render(<TestComponent data={undefined} />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
it('detects empty agents array as no data', () => {
render(<TestComponent data={{ agents: [] }} />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
it('detects non-empty agents array as has data', () => {
render(<TestComponent data={{ agents: [{ id: '1', name: 'Test' }] }} />);
expect(screen.getByTestId('result')).toHaveTextContent('has-data');
});
it('detects invalid agents property as no data', () => {
render(<TestComponent data={{ agents: 'not-array' }} />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
it('detects empty array as no data', () => {
render(<TestComponent data={[]} />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
it('detects non-empty array as has data', () => {
render(<TestComponent data={[{ name: 'category1' }]} />);
expect(screen.getByTestId('result')).toHaveTextContent('has-data');
});
it('detects agent with id as has data', () => {
render(<TestComponent data={{ id: '123', name: 'Test Agent' }} />);
expect(screen.getByTestId('result')).toHaveTextContent('has-data');
});
it('detects agent with name only as has data', () => {
render(<TestComponent data={{ name: 'Test Agent' }} />);
expect(screen.getByTestId('result')).toHaveTextContent('has-data');
});
it('detects object without id or name as no data', () => {
render(<TestComponent data={{ description: 'Some description' }} />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
it('handles string data as no data', () => {
render(<TestComponent data="some string" />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
it('handles number data as no data', () => {
render(<TestComponent data={42} />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
it('handles boolean data as no data', () => {
render(<TestComponent data={true} />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
});