import type { Tag } from '@/app/components/plugins/hooks' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import SearchBox from './index' import SearchBoxWrapper from './search-box-wrapper' import MarketplaceTrigger from './trigger/marketplace' import ToolSelectorTrigger from './trigger/tool-selector' // ================================ // Mock external dependencies only // ================================ // Mock useMixedTranslation hook vi.mock('../hooks', () => ({ useMixedTranslation: (_locale?: string) => ({ t: (key: string) => { const translations: Record = { 'pluginTags.allTags': 'All Tags', 'pluginTags.searchTags': 'Search tags', 'plugin.searchPlugins': 'Search plugins', } return translations[key] || key }, }), })) // Mock useMarketplaceContext const mockContextValues = { searchPluginText: '', handleSearchPluginTextChange: vi.fn(), filterPluginTags: [] as string[], handleFilterPluginTagsChange: vi.fn(), } vi.mock('../context', () => ({ useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues), })) // Mock useTags hook const mockTags: Tag[] = [ { name: 'agent', label: 'Agent' }, { name: 'rag', label: 'RAG' }, { name: 'search', label: 'Search' }, { name: 'image', label: 'Image' }, { name: 'videos', label: 'Videos' }, ] const mockTagsMap: Record = mockTags.reduce((acc, tag) => { acc[tag.name] = tag return acc }, {} as Record) vi.mock('@/app/components/plugins/hooks', () => ({ useTags: () => ({ tags: mockTags, tagsMap: mockTagsMap, }), })) // Mock portal-to-follow-elem with shared open state let mockPortalOpenState = false vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ PortalToFollowElem: ({ children, open }: { children: React.ReactNode open: boolean }) => { mockPortalOpenState = open return (
{children}
) }, PortalToFollowElemTrigger: ({ children, onClick, className }: { children: React.ReactNode onClick: () => void className?: string }) => (
{children}
), PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode className?: string }) => { // Only render content when portal is open if (!mockPortalOpenState) return null return (
{children}
) }, })) // ================================ // SearchBox Component Tests // ================================ describe('SearchBox', () => { const defaultProps = { search: '', onSearchChange: vi.fn(), tags: [] as string[], onTagsChange: vi.fn(), } beforeEach(() => { vi.clearAllMocks() mockPortalOpenState = false }) // ================================ // Rendering Tests // ================================ describe('Rendering', () => { it('should render without crashing', () => { render() expect(screen.getByRole('textbox')).toBeInTheDocument() }) it('should render with marketplace mode styling', () => { const { container } = render( , ) // In marketplace mode, TagsFilter comes before input expect(container.querySelector('.rounded-xl')).toBeInTheDocument() }) it('should render with non-marketplace mode styling', () => { const { container } = render( , ) // In non-marketplace mode, search icon appears first expect(container.querySelector('.radius-md')).toBeInTheDocument() }) it('should render placeholder correctly', () => { render() expect(screen.getByPlaceholderText('Search here...')).toBeInTheDocument() }) it('should render search input with current value', () => { render() expect(screen.getByDisplayValue('test query')).toBeInTheDocument() }) it('should render TagsFilter component', () => { render() expect(screen.getByTestId('portal-elem')).toBeInTheDocument() }) }) // ================================ // Marketplace Mode Tests // ================================ describe('Marketplace Mode', () => { it('should render TagsFilter before input in marketplace mode', () => { render() const portalElem = screen.getByTestId('portal-elem') const input = screen.getByRole('textbox') // Both should be rendered expect(portalElem).toBeInTheDocument() expect(input).toBeInTheDocument() }) it('should render clear button when search has value in marketplace mode', () => { render() // ActionButton with close icon should be rendered const buttons = screen.getAllByRole('button') expect(buttons.length).toBeGreaterThan(0) }) it('should not render clear button when search is empty in marketplace mode', () => { const { container } = render() // RiCloseLine icon should not be visible (it's within ActionButton) const closeIcons = container.querySelectorAll('.size-4') // Only filter icons should be present, not close button expect(closeIcons.length).toBeLessThan(3) }) }) // ================================ // Non-Marketplace Mode Tests // ================================ describe('Non-Marketplace Mode', () => { it('should render search icon at the beginning', () => { const { container } = render( , ) // Search icon should be present expect(container.querySelector('.text-components-input-text-placeholder')).toBeInTheDocument() }) it('should render clear button when search has value', () => { render() const buttons = screen.getAllByRole('button') expect(buttons.length).toBeGreaterThan(0) }) it('should render TagsFilter after input in non-marketplace mode', () => { render() const portalElem = screen.getByTestId('portal-elem') const input = screen.getByRole('textbox') expect(portalElem).toBeInTheDocument() expect(input).toBeInTheDocument() }) it('should set autoFocus when prop is true', () => { render() const input = screen.getByRole('textbox') // autoFocus is a boolean attribute that React handles specially expect(input).toBeInTheDocument() }) }) // ================================ // User Interactions Tests // ================================ describe('User Interactions', () => { it('should call onSearchChange when input value changes', () => { const onSearchChange = vi.fn() render() const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'new search' } }) expect(onSearchChange).toHaveBeenCalledWith('new search') }) it('should call onSearchChange with empty string when clear button is clicked in marketplace mode', () => { const onSearchChange = vi.fn() render( , ) const buttons = screen.getAllByRole('button') // Find the clear button (the one in the search area) const clearButton = buttons[buttons.length - 1] fireEvent.click(clearButton) expect(onSearchChange).toHaveBeenCalledWith('') }) it('should call onSearchChange with empty string when clear button is clicked in non-marketplace mode', () => { const onSearchChange = vi.fn() render( , ) const buttons = screen.getAllByRole('button') // First button should be the clear button in non-marketplace mode fireEvent.click(buttons[0]) expect(onSearchChange).toHaveBeenCalledWith('') }) it('should handle rapid typing correctly', () => { const onSearchChange = vi.fn() render() const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'a' } }) fireEvent.change(input, { target: { value: 'ab' } }) fireEvent.change(input, { target: { value: 'abc' } }) expect(onSearchChange).toHaveBeenCalledTimes(3) expect(onSearchChange).toHaveBeenLastCalledWith('abc') }) }) // ================================ // Add Custom Tool Button Tests // ================================ describe('Add Custom Tool Button', () => { it('should render add custom tool button when supportAddCustomTool is true', () => { render() // The add button should be rendered const buttons = screen.getAllByRole('button') expect(buttons.length).toBeGreaterThanOrEqual(1) }) it('should not render add custom tool button when supportAddCustomTool is false', () => { const { container } = render( , ) // Check for the rounded-full button which is the add button const addButton = container.querySelector('.rounded-full') expect(addButton).not.toBeInTheDocument() }) it('should call onShowAddCustomCollectionModal when add button is clicked', () => { const onShowAddCustomCollectionModal = vi.fn() render( , ) // Find the add button (it has rounded-full class) const buttons = screen.getAllByRole('button') const addButton = buttons.find(btn => btn.className.includes('rounded-full'), ) if (addButton) { fireEvent.click(addButton) expect(onShowAddCustomCollectionModal).toHaveBeenCalledTimes(1) } }) }) // ================================ // Props Variations Tests // ================================ describe('Props Variations', () => { it('should apply wrapperClassName correctly', () => { const { container } = render( , ) expect(container.querySelector('.custom-wrapper-class')).toBeInTheDocument() }) it('should apply inputClassName correctly', () => { const { container } = render( , ) expect(container.querySelector('.custom-input-class')).toBeInTheDocument() }) it('should pass locale to TagsFilter', () => { render() // TagsFilter should be rendered with locale expect(screen.getByTestId('portal-elem')).toBeInTheDocument() }) it('should handle empty placeholder', () => { render() expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', '') }) it('should use default placeholder when not provided', () => { render() expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', '') }) }) // ================================ // Edge Cases Tests // ================================ describe('Edge Cases', () => { it('should handle empty search value', () => { render() expect(screen.getByRole('textbox')).toBeInTheDocument() expect(screen.getByRole('textbox')).toHaveValue('') }) it('should handle empty tags array', () => { render() expect(screen.getByTestId('portal-elem')).toBeInTheDocument() }) it('should handle special characters in search', () => { const onSearchChange = vi.fn() render() const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: '' } }) expect(onSearchChange).toHaveBeenCalledWith('') }) it('should handle very long search strings', () => { const longString = 'a'.repeat(1000) render() expect(screen.getByDisplayValue(longString)).toBeInTheDocument() }) it('should handle whitespace-only search', () => { const onSearchChange = vi.fn() render() const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: ' ' } }) expect(onSearchChange).toHaveBeenCalledWith(' ') }) }) }) // ================================ // SearchBoxWrapper Component Tests // ================================ describe('SearchBoxWrapper', () => { beforeEach(() => { vi.clearAllMocks() mockPortalOpenState = false // Reset context values mockContextValues.searchPluginText = '' mockContextValues.filterPluginTags = [] }) describe('Rendering', () => { it('should render without crashing', () => { render() expect(screen.getByRole('textbox')).toBeInTheDocument() }) it('should render with locale prop', () => { render() expect(screen.getByRole('textbox')).toBeInTheDocument() }) it('should render in marketplace mode', () => { const { container } = render() expect(container.querySelector('.rounded-xl')).toBeInTheDocument() }) it('should apply correct wrapper classes', () => { const { container } = render() // Check for z-[11] class from wrapper expect(container.querySelector('.z-\\[11\\]')).toBeInTheDocument() }) }) describe('Context Integration', () => { it('should use searchPluginText from context', () => { mockContextValues.searchPluginText = 'context search' render() expect(screen.getByDisplayValue('context search')).toBeInTheDocument() }) it('should call handleSearchPluginTextChange when search changes', () => { render() const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'new search' } }) expect(mockContextValues.handleSearchPluginTextChange).toHaveBeenCalledWith('new search') }) it('should use filterPluginTags from context', () => { mockContextValues.filterPluginTags = ['agent', 'rag'] render() expect(screen.getByTestId('portal-elem')).toBeInTheDocument() }) }) describe('Translation', () => { it('should use translation for placeholder', () => { render() expect(screen.getByPlaceholderText('Search plugins')).toBeInTheDocument() }) it('should pass locale to useMixedTranslation', () => { render() // Translation should still work expect(screen.getByPlaceholderText('Search plugins')).toBeInTheDocument() }) }) }) // ================================ // MarketplaceTrigger Component Tests // ================================ describe('MarketplaceTrigger', () => { const defaultProps = { selectedTagsLength: 0, open: false, tags: [] as string[], tagsMap: mockTagsMap, onTagsChange: vi.fn(), } beforeEach(() => { vi.clearAllMocks() }) describe('Rendering', () => { it('should render without crashing', () => { render() expect(screen.getByText('All Tags')).toBeInTheDocument() }) it('should show "All Tags" when no tags selected', () => { render() expect(screen.getByText('All Tags')).toBeInTheDocument() }) it('should show arrow down icon when no tags selected', () => { const { container } = render( , ) // Arrow down icon should be present expect(container.querySelector('.size-4')).toBeInTheDocument() }) }) describe('Selected Tags Display', () => { it('should show selected tag labels when tags are selected', () => { render( , ) expect(screen.getByText('Agent')).toBeInTheDocument() }) it('should show multiple tag labels separated by comma', () => { render( , ) expect(screen.getByText('Agent,RAG')).toBeInTheDocument() }) it('should show +N indicator when more than 2 tags selected', () => { render( , ) expect(screen.getByText('+2')).toBeInTheDocument() }) it('should only show first 2 tags in label', () => { render( , ) expect(screen.getByText('Agent,RAG')).toBeInTheDocument() expect(screen.queryByText('Search')).not.toBeInTheDocument() }) }) describe('Clear Tags Button', () => { it('should show clear button when tags are selected', () => { const { container } = render( , ) // RiCloseCircleFill icon should be present expect(container.querySelector('.text-text-quaternary')).toBeInTheDocument() }) it('should not show clear button when no tags selected', () => { const { container } = render( , ) // Clear button should not be present expect(container.querySelector('.text-text-quaternary')).not.toBeInTheDocument() }) it('should call onTagsChange with empty array when clear is clicked', () => { const onTagsChange = vi.fn() const { container } = render( , ) const clearButton = container.querySelector('.text-text-quaternary') if (clearButton) { fireEvent.click(clearButton) expect(onTagsChange).toHaveBeenCalledWith([]) } }) }) describe('Open State Styling', () => { it('should apply hover styling when open and no tags selected', () => { const { container } = render( , ) expect(container.querySelector('.bg-state-base-hover')).toBeInTheDocument() }) it('should apply border styling when tags are selected', () => { const { container } = render( , ) expect(container.querySelector('.border-components-button-secondary-border')).toBeInTheDocument() }) }) describe('Props Variations', () => { it('should handle locale prop', () => { render() expect(screen.getByText('All Tags')).toBeInTheDocument() }) it('should handle empty tagsMap', () => { const { container } = render( , ) expect(container).toBeInTheDocument() }) }) }) // ================================ // ToolSelectorTrigger Component Tests // ================================ describe('ToolSelectorTrigger', () => { const defaultProps = { selectedTagsLength: 0, open: false, tags: [] as string[], tagsMap: mockTagsMap, onTagsChange: vi.fn(), } beforeEach(() => { vi.clearAllMocks() }) describe('Rendering', () => { it('should render without crashing', () => { const { container } = render() expect(container).toBeInTheDocument() }) it('should render price tag icon', () => { const { container } = render() expect(container.querySelector('.size-4')).toBeInTheDocument() }) }) describe('Selected Tags Display', () => { it('should show selected tag labels when tags are selected', () => { render( , ) expect(screen.getByText('Agent')).toBeInTheDocument() }) it('should show multiple tag labels separated by comma', () => { render( , ) expect(screen.getByText('Agent,RAG')).toBeInTheDocument() }) it('should show +N indicator when more than 2 tags selected', () => { render( , ) expect(screen.getByText('+2')).toBeInTheDocument() }) it('should not show tag labels when no tags selected', () => { render() expect(screen.queryByText('Agent')).not.toBeInTheDocument() }) }) describe('Clear Tags Button', () => { it('should show clear button when tags are selected', () => { const { container } = render( , ) expect(container.querySelector('.text-text-quaternary')).toBeInTheDocument() }) it('should not show clear button when no tags selected', () => { const { container } = render( , ) expect(container.querySelector('.text-text-quaternary')).not.toBeInTheDocument() }) it('should call onTagsChange with empty array when clear is clicked', () => { const onTagsChange = vi.fn() const { container } = render( , ) const clearButton = container.querySelector('.text-text-quaternary') if (clearButton) { fireEvent.click(clearButton) expect(onTagsChange).toHaveBeenCalledWith([]) } }) it('should stop propagation when clear button is clicked', () => { const onTagsChange = vi.fn() const parentClickHandler = vi.fn() const { container } = render(
, ) const clearButton = container.querySelector('.text-text-quaternary') if (clearButton) { fireEvent.click(clearButton) expect(onTagsChange).toHaveBeenCalledWith([]) // Parent should not be called due to stopPropagation expect(parentClickHandler).not.toHaveBeenCalled() } }) }) describe('Open State Styling', () => { it('should apply hover styling when open and no tags selected', () => { const { container } = render( , ) expect(container.querySelector('.bg-state-base-hover')).toBeInTheDocument() }) it('should apply border styling when tags are selected', () => { const { container } = render( , ) expect(container.querySelector('.border-components-button-secondary-border')).toBeInTheDocument() }) it('should not apply hover styling when open but has tags', () => { const { container } = render( , ) // Should have border styling, not hover expect(container.querySelector('.border-components-button-secondary-border')).toBeInTheDocument() }) }) describe('Edge Cases', () => { it('should render with single tag correctly', () => { render( , ) expect(screen.getByText('Agent')).toBeInTheDocument() }) }) }) // ================================ // TagsFilter Component Tests (Integration) // ================================ describe('TagsFilter', () => { // We need to import TagsFilter separately for these tests // since it uses the mocked portal components beforeEach(() => { vi.clearAllMocks() mockPortalOpenState = false }) describe('Integration with SearchBox', () => { it('should render TagsFilter within SearchBox', () => { render( , ) expect(screen.getByTestId('portal-elem')).toBeInTheDocument() }) it('should pass usedInMarketplace prop to TagsFilter', () => { render( , ) // MarketplaceTrigger should show "All Tags" expect(screen.getByText('All Tags')).toBeInTheDocument() }) it('should show selected tags count in TagsFilter trigger', () => { render( , ) expect(screen.getByText('+1')).toBeInTheDocument() }) }) describe('Dropdown Behavior', () => { it('should open dropdown when trigger is clicked', async () => { render( , ) const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) await waitFor(() => { expect(screen.getByTestId('portal-content')).toBeInTheDocument() }) }) it('should close dropdown when trigger is clicked again', async () => { render( , ) const trigger = screen.getByTestId('portal-trigger') // Open fireEvent.click(trigger) await waitFor(() => { expect(screen.getByTestId('portal-content')).toBeInTheDocument() }) // Close fireEvent.click(trigger) await waitFor(() => { expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() }) }) }) describe('Tag Selection', () => { it('should display tag options when dropdown is open', async () => { render( , ) const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) await waitFor(() => { expect(screen.getByText('Agent')).toBeInTheDocument() expect(screen.getByText('RAG')).toBeInTheDocument() }) }) it('should call onTagsChange when a tag is selected', async () => { const onTagsChange = vi.fn() render( , ) const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) await waitFor(() => { expect(screen.getByText('Agent')).toBeInTheDocument() }) const agentOption = screen.getByText('Agent') fireEvent.click(agentOption.parentElement!) expect(onTagsChange).toHaveBeenCalledWith(['agent']) }) it('should call onTagsChange to remove tag when already selected', async () => { const onTagsChange = vi.fn() render( , ) const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) await waitFor(() => { // Multiple 'Agent' texts exist - one in trigger, one in dropdown expect(screen.getAllByText('Agent').length).toBeGreaterThanOrEqual(1) }) // Get the portal content and find the tag option within it const portalContent = screen.getByTestId('portal-content') const agentOption = portalContent.querySelector('div[class*="cursor-pointer"]') if (agentOption) { fireEvent.click(agentOption) expect(onTagsChange).toHaveBeenCalled() } }) it('should add to existing tags when selecting new tag', async () => { const onTagsChange = vi.fn() render( , ) const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) await waitFor(() => { expect(screen.getByText('RAG')).toBeInTheDocument() }) const ragOption = screen.getByText('RAG') fireEvent.click(ragOption.parentElement!) expect(onTagsChange).toHaveBeenCalledWith(['agent', 'rag']) }) }) describe('Search Tags Feature', () => { it('should render search input in dropdown', async () => { render( , ) const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) await waitFor(() => { const inputs = screen.getAllByRole('textbox') expect(inputs.length).toBeGreaterThanOrEqual(1) }) }) it('should filter tags based on search text', async () => { render( , ) const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) await waitFor(() => { expect(screen.getByText('Agent')).toBeInTheDocument() }) const inputs = screen.getAllByRole('textbox') const searchInput = inputs.find(input => input.getAttribute('placeholder') === 'Search tags', ) if (searchInput) { fireEvent.change(searchInput, { target: { value: 'agent' } }) expect(screen.getByText('Agent')).toBeInTheDocument() } }) }) describe('Checkbox State', () => { // Note: The Checkbox component is a custom div-based component, not native checkbox it('should display tag options with proper selection state', async () => { render( , ) const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) await waitFor(() => { // 'Agent' appears both in trigger (selected) and dropdown expect(screen.getAllByText('Agent').length).toBeGreaterThanOrEqual(1) }) // Verify dropdown content is rendered expect(screen.getByTestId('portal-content')).toBeInTheDocument() }) it('should render tag options when dropdown is open', async () => { render( , ) const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) await waitFor(() => { expect(screen.getByTestId('portal-content')).toBeInTheDocument() }) // When no tags selected, these should appear once each in dropdown expect(screen.getByText('Agent')).toBeInTheDocument() expect(screen.getByText('RAG')).toBeInTheDocument() expect(screen.getByText('Search')).toBeInTheDocument() }) }) }) // ================================ // Accessibility Tests // ================================ describe('Accessibility', () => { beforeEach(() => { vi.clearAllMocks() mockPortalOpenState = false }) it('should have accessible search input', () => { render( , ) const input = screen.getByRole('textbox') expect(input).toBeInTheDocument() expect(input).toHaveAttribute('placeholder', 'Search plugins') }) it('should have clickable tag options in dropdown', async () => { render() fireEvent.click(screen.getByTestId('portal-trigger')) await waitFor(() => { expect(screen.getByText('Agent')).toBeInTheDocument() }) }) }) // ================================ // Combined Workflow Tests // ================================ describe('Combined Workflows', () => { beforeEach(() => { vi.clearAllMocks() mockPortalOpenState = false }) it('should handle search and tag filter together', async () => { const onSearchChange = vi.fn() const onTagsChange = vi.fn() render( , ) const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'search query' } }) expect(onSearchChange).toHaveBeenCalledWith('search query') const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) await waitFor(() => { expect(screen.getByText('Agent')).toBeInTheDocument() }) const agentOption = screen.getByText('Agent') fireEvent.click(agentOption.parentElement!) expect(onTagsChange).toHaveBeenCalledWith(['agent']) }) it('should work with all features enabled', () => { render( , ) expect(screen.getByDisplayValue('test')).toBeInTheDocument() expect(screen.getByText('Agent,RAG')).toBeInTheDocument() expect(screen.getByTestId('portal-elem')).toBeInTheDocument() }) it('should handle prop changes correctly', () => { const onSearchChange = vi.fn() const { rerender } = render( , ) expect(screen.getByDisplayValue('initial')).toBeInTheDocument() rerender( , ) expect(screen.getByDisplayValue('updated')).toBeInTheDocument() }) })