| import React from 'react'; |
| import { render, screen, fireEvent } 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'; |
| import * as t from 'librechat-data-provider'; |
|
|
| |
| 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(), |
| })), |
| }); |
|
|
| |
| jest.mock('recoil', () => ({ |
| useRecoilValue: jest.fn(() => 'en'), |
| RecoilRoot: ({ children }: any) => children, |
| atom: jest.fn(() => ({})), |
| atomFamily: jest.fn(() => ({})), |
| selector: jest.fn(() => ({})), |
| selectorFamily: jest.fn(() => ({})), |
| useRecoilState: jest.fn(() => ['en', jest.fn()]), |
| useSetRecoilState: jest.fn(() => jest.fn()), |
| })); |
|
|
| |
| jest.mock('react-i18next', () => ({ |
| useTranslation: () => ({ |
| t: (key: string) => key, |
| i18n: { changeLanguage: jest.fn() }, |
| }), |
| })); |
|
|
| |
| const mockLocalize = 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_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', |
| com_agents_created_by: 'by', |
| com_agents_top_picks: 'Top Picks', |
| com_agents_all_category: 'All', |
| |
| com_agents_error_suggestion_generic: 'Try refreshing the page or check your network connection', |
| com_agents_error_network_title: 'Network Error', |
| 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_suggestion: 'The requested resource could not be found', |
| com_agents_error_invalid_request: 'Invalid Request', |
| com_agents_error_bad_request_message: 'The request was invalid', |
| 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: 'An internal server error occurred', |
| com_agents_error_server_suggestion: 'Please try again later', |
| com_agents_error_title: 'Error', |
| com_agents_error_generic: 'An unexpected error occurred', |
| com_agents_error_search_title: 'Search Error', |
| com_agents_error_category_title: 'Category Error', |
| com_agents_search_no_results: `No results found for "${options?.query}"`, |
| com_agents_category_empty: `No agents found in ${options?.category} category`, |
| com_agents_error_not_found_message: 'The requested resource could not be found', |
| }; |
| return translations[key] || key; |
| }); |
|
|
| |
| jest.mock('~/hooks/useLocalize', () => ({ |
| __esModule: true, |
| default: () => mockLocalize, |
| })); |
|
|
| |
| jest.mock('~/hooks', () => ({ |
| useLocalize: () => mockLocalize, |
| useDebounce: jest.fn(), |
| useAgentCategories: jest.fn(), |
| })); |
|
|
| jest.mock('~/data-provider/Agents', () => ({ |
| useMarketplaceAgentsInfiniteQuery: jest.fn(), |
| })); |
|
|
| |
| jest.mock('~/utils/agents', () => ({ |
| renderAgentAvatar: jest.fn(() => <div data-testid="agent-avatar" />), |
| getContactDisplayName: jest.fn((agent) => agent.authorName), |
| })); |
|
|
| |
| jest.mock('../SmartLoader', () => ({ |
| SmartLoader: ({ children, isLoading }: any) => (isLoading ? <div>Loading...</div> : children), |
| useHasData: jest.fn(() => true), |
| })); |
|
|
| |
| import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents'; |
| import { useAgentCategories, useDebounce } from '~/hooks'; |
|
|
| |
| const mockUseMarketplaceAgentsInfiniteQuery = jest.mocked(useMarketplaceAgentsInfiniteQuery); |
| const mockUseAgentCategories = jest.mocked(useAgentCategories); |
| const mockUseDebounce = jest.mocked(useDebounce); |
|
|
| |
| const createWrapper = () => { |
| const queryClient = new QueryClient({ |
| defaultOptions: { queries: { retry: false } }, |
| }); |
|
|
| return ({ children }: { children: React.ReactNode }) => ( |
| <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> |
| ); |
| }; |
|
|
| describe('Accessibility Improvements', () => { |
| beforeEach(() => { |
| mockUseMarketplaceAgentsInfiniteQuery.mockClear(); |
| mockUseAgentCategories.mockClear(); |
| mockUseDebounce.mockClear(); |
|
|
| |
| mockUseDebounce.mockImplementation((value) => value); |
| mockUseAgentCategories.mockReturnValue({ |
| categories: [ |
| { value: 'promoted', label: 'Top Picks' }, |
| { value: 'all', label: 'All' }, |
| { value: 'productivity', label: 'Productivity' }, |
| ], |
| emptyCategory: { value: 'all', label: 'All' }, |
| isLoading: false, |
| error: null, |
| }); |
| }); |
|
|
| describe('CategoryTabs Accessibility', () => { |
| const categories = [ |
| { value: 'promoted', label: 'Top Picks', count: 5 }, |
| { value: 'all', label: 'All', count: 20 }, |
| { value: 'productivity', label: 'Productivity', count: 8 }, |
| ]; |
|
|
| it('implements proper tablist role and ARIA attributes', () => { |
| render( |
| <CategoryTabs |
| categories={categories} |
| activeTab="promoted" |
| isLoading={false} |
| onChange={jest.fn()} |
| />, |
| ); |
|
|
| |
| const tablist = screen.getByRole('tablist'); |
| expect(tablist).toBeInTheDocument(); |
| expect(tablist).toHaveAttribute('aria-label', 'Agent Categories'); |
| expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); |
|
|
| |
| const tabs = screen.getAllByRole('tab'); |
| expect(tabs).toHaveLength(3); |
|
|
| tabs.forEach((tab) => { |
| 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: /Top Picks category/ }); |
|
|
| |
| 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: /Top Picks category/ }); |
| const allTab = screen.getByRole('tab', { name: /All category/ }); |
|
|
| |
| 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()} />); |
|
|
| |
| const searchRegion = screen.getByRole('search'); |
| expect(searchRegion).toBeInTheDocument(); |
|
|
| |
| const searchInput = screen.getByRole('textbox'); |
| expect(searchInput).toHaveAttribute('id', 'agent-search'); |
| expect(searchInput).toHaveAttribute('aria-label', 'Search agents'); |
| expect(searchInput).toHaveAttribute( |
| 'aria-describedby', |
| 'search-instructions search-results-count', |
| ); |
|
|
| |
| const hiddenLabel = screen.getByLabelText('Search agents'); |
| expect(hiddenLabel).toBeInTheDocument(); |
| }); |
|
|
| 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()} />); |
|
|
| |
| 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', |
| created_at: 1704067200000, |
| avatar: null, |
| instructions: 'Test instructions', |
| provider: 'openai' as const, |
| model: 'gpt-4', |
| model_parameters: { |
| temperature: 0.7, |
| maxContextTokens: 4096, |
| max_context_tokens: 4096, |
| max_output_tokens: 1024, |
| top_p: 1, |
| frequency_penalty: 0, |
| presence_penalty: 0, |
| }, |
| }; |
|
|
| it('provides comprehensive ARIA labels', () => { |
| render(<AgentCard agent={mockAgent as t.Agent} 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('supports keyboard interaction', () => { |
| const onClick = jest.fn(); |
| render(<AgentCard agent={mockAgent as t.Agent} 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(() => { |
| mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({ |
| data: { |
| pages: [ |
| { |
| data: [ |
| { id: '1', name: 'Agent 1', description: 'First agent' }, |
| { id: '2', name: 'Agent 2', description: 'Second agent' }, |
| ], |
| }, |
| ], |
| }, |
| isLoading: false, |
| error: null, |
| } as any); |
| }); |
|
|
| it('implements proper tabpanel structure', () => { |
| const Wrapper = createWrapper(); |
| render( |
| <Wrapper> |
| <AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} /> |
| </Wrapper>, |
| ); |
|
|
| |
| 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>, |
| ); |
|
|
| |
| const grid = screen.getByRole('grid'); |
| expect(grid).toBeInTheDocument(); |
| expect(grid).toHaveAttribute('aria-label', 'Showing 2 agents in All category'); |
|
|
| |
| const gridcells = screen.getAllByRole('gridcell'); |
| expect(gridcells).toHaveLength(2); |
| }); |
|
|
| it('announces loading states to screen readers', () => { |
| mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({ |
| data: { |
| pages: [{ data: [{ id: '1', name: 'Agent 1' }] }], |
| }, |
| isFetching: true, |
| hasNextPage: true, |
| isFetchingNextPage: true, |
| isLoading: false, |
| error: null, |
| } as any); |
|
|
| const Wrapper = createWrapper(); |
| render( |
| <Wrapper> |
| <AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} /> |
| </Wrapper>, |
| ); |
|
|
| |
| const loadingStatus = screen.getByRole('status'); |
| expect(loadingStatus).toBeInTheDocument(); |
| expect(loadingStatus).toHaveAttribute('aria-live', 'polite'); |
| expect(loadingStatus).toHaveAttribute('aria-label', 'Loading...'); |
|
|
| |
| const srText = screen.getByText('Loading...'); |
| expect(srText).toHaveClass('sr-only'); |
| }); |
|
|
| it('provides accessible empty states', () => { |
| mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({ |
| data: { |
| pages: [{ data: [] }], |
| }, |
| isLoading: false, |
| isFetching: false, |
| error: null, |
| } as any); |
|
|
| const Wrapper = createWrapper(); |
| render( |
| <Wrapper> |
| <AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} /> |
| </Wrapper>, |
| ); |
|
|
| |
| 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()} />); |
|
|
| |
| const alert = screen.getByRole('alert'); |
| expect(alert).toHaveAttribute('aria-live', 'assertive'); |
| expect(alert).toHaveAttribute('aria-atomic', 'true'); |
|
|
| |
| const heading = screen.getByRole('heading', { level: 3 }); |
| 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} />); |
|
|
| |
| expect(screen.getByText(/unable to load agents/i)).toHaveAttribute('id', 'error-message'); |
|
|
| |
| 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()} />); |
|
|
| |
| const searchInput = container.querySelector('input'); |
| expect(searchInput?.className).toContain('focus:'); |
| }); |
|
|
| it('provides visible focus indicators on interactive elements', () => { |
| render( |
| <CategoryTabs |
| categories={[{ value: 'test', label: 'Test', count: 1 }]} |
| activeTab="test" |
| isLoading={false} |
| onChange={jest.fn()} |
| />, |
| ); |
|
|
| const tab = screen.getByRole('tab'); |
| |
| expect(tab).toHaveAttribute('aria-selected', 'true'); |
| expect(tab).toHaveAttribute('tabIndex', '0'); |
| |
| expect(tab).toHaveAttribute('role', 'tab'); |
| }); |
| }); |
|
|
| describe('Screen Reader Announcements', () => { |
| it('includes live regions for dynamic content', () => { |
| const Wrapper = createWrapper(); |
| render( |
| <Wrapper> |
| <AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} /> |
| </Wrapper>, |
| ); |
|
|
| |
| const liveRegion = document.querySelector('[aria-live="polite"]'); |
| expect(liveRegion).toBeInTheDocument(); |
| }); |
|
|
| it('provides screen reader only content', () => { |
| render(<SearchBar value="" onSearch={jest.fn()} />); |
|
|
| |
| const srOnlyElement = document.querySelector('.sr-only'); |
| expect(srOnlyElement).toBeInTheDocument(); |
| }); |
| }); |
| }); |
|
|