mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
- Refactor tests to use PromptGroup roles instead of Project roles. - Initialize models and seed default roles in test setup. - Update error handling for non-existent resource types. - Ensure proper cleanup of test data while retaining seeded roles.
532 lines
18 KiB
TypeScript
532 lines
18 KiB
TypeScript
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';
|
|
|
|
// 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 Recoil
|
|
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()),
|
|
}));
|
|
|
|
// Mock react-i18next
|
|
jest.mock('react-i18next', () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string) => key,
|
|
i18n: { changeLanguage: jest.fn() },
|
|
}),
|
|
}));
|
|
|
|
// Create the localize function once to be reused
|
|
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_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',
|
|
com_agents_created_by: 'by',
|
|
com_agents_top_picks: 'Top Picks',
|
|
// ErrorDisplay translations
|
|
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;
|
|
});
|
|
|
|
// Mock useLocalize specifically
|
|
jest.mock('~/hooks/useLocalize', () => ({
|
|
__esModule: true,
|
|
default: () => mockLocalize,
|
|
}));
|
|
|
|
// Mock hooks
|
|
jest.mock('~/hooks', () => ({
|
|
useLocalize: () => mockLocalize,
|
|
useDebounce: jest.fn(),
|
|
useAgentCategories: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/data-provider/Agents', () => ({
|
|
useMarketplaceAgentsInfiniteQuery: jest.fn(),
|
|
}));
|
|
|
|
// Mock utility functions
|
|
jest.mock('~/utils/agents', () => ({
|
|
renderAgentAvatar: jest.fn(() => <div data-testid="agent-avatar" />),
|
|
getContactDisplayName: jest.fn((agent) => agent.authorName),
|
|
}));
|
|
|
|
// Mock SmartLoader
|
|
jest.mock('../SmartLoader', () => ({
|
|
SmartLoader: ({ children, isLoading }: any) => (isLoading ? <div>Loading...</div> : children),
|
|
useHasData: jest.fn(() => true),
|
|
}));
|
|
|
|
// Import the actual modules to get the mocked functions
|
|
import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
|
|
import { useAgentCategories, useDebounce } from '~/hooks';
|
|
|
|
// Get typed mock functions
|
|
const mockUseMarketplaceAgentsInfiniteQuery = jest.mocked(useMarketplaceAgentsInfiniteQuery);
|
|
const mockUseAgentCategories = jest.mocked(useAgentCategories);
|
|
const mockUseDebounce = jest.mocked(useDebounce);
|
|
|
|
// 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(() => {
|
|
mockUseMarketplaceAgentsInfiniteQuery.mockClear();
|
|
mockUseAgentCategories.mockClear();
|
|
mockUseDebounce.mockClear();
|
|
|
|
// Default mock implementations
|
|
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()}
|
|
/>,
|
|
);
|
|
|
|
// 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) => {
|
|
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 tab/ });
|
|
|
|
// 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: /Top Picks tab/ });
|
|
const allTab = screen.getByRole('tab', { name: /All tab/ });
|
|
|
|
// 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('textbox');
|
|
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 exists
|
|
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()} />);
|
|
|
|
// 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',
|
|
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('handles agents without descriptions', () => {
|
|
const agentWithoutDesc = { ...mockAgent, description: undefined };
|
|
render(<AgentCard agent={agentWithoutDesc as any as t.Agent} onClick={jest.fn()} />);
|
|
|
|
expect(screen.getByText('No description available')).toBeInTheDocument();
|
|
});
|
|
|
|
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>,
|
|
);
|
|
|
|
// 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', () => {
|
|
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>,
|
|
);
|
|
|
|
// Check for loading announcement when fetching more data
|
|
const loadingStatus = screen.getByRole('status');
|
|
expect(loadingStatus).toBeInTheDocument();
|
|
expect(loadingStatus).toHaveAttribute('aria-live', 'polite');
|
|
expect(loadingStatus).toHaveAttribute('aria-label', 'Loading...');
|
|
|
|
// Check for screen reader text
|
|
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>,
|
|
);
|
|
|
|
// 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: 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} />);
|
|
|
|
// 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={[{ value: 'test', label: 'Test', count: 1 }]}
|
|
activeTab="test"
|
|
isLoading={false}
|
|
onChange={jest.fn()}
|
|
/>,
|
|
);
|
|
|
|
const tab = screen.getByRole('tab');
|
|
// Check that the tab has proper ARIA attributes for accessibility
|
|
expect(tab).toHaveAttribute('aria-selected', 'true');
|
|
expect(tab).toHaveAttribute('tabIndex', '0');
|
|
// Check that tab has proper role and can receive focus
|
|
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>,
|
|
);
|
|
|
|
// 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();
|
|
});
|
|
});
|
|
});
|