dify/web/app/components/plugins/marketplace/search-box/index.spec.tsx

1290 lines
37 KiB
TypeScript

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<string, string> = {
'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<string, Tag> = mockTags.reduce((acc, tag) => {
acc[tag.name] = tag
return acc
}, {} as Record<string, Tag>)
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 (
<div data-testid="portal-elem" data-open={open}>
{children}
</div>
)
},
PortalToFollowElemTrigger: ({ children, onClick, className }: {
children: React.ReactNode
onClick: () => void
className?: string
}) => (
<div data-testid="portal-trigger" onClick={onClick} className={className}>
{children}
</div>
),
PortalToFollowElemContent: ({ children, className }: {
children: React.ReactNode
className?: string
}) => {
// Only render content when portal is open
if (!mockPortalOpenState)
return null
return (
<div data-testid="portal-content" className={className}>
{children}
</div>
)
},
}))
// ================================
// 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(<SearchBox {...defaultProps} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render with marketplace mode styling', () => {
const { container } = render(
<SearchBox {...defaultProps} usedInMarketplace />,
)
// In marketplace mode, TagsFilter comes before input
expect(container.querySelector('.rounded-xl')).toBeInTheDocument()
})
it('should render with non-marketplace mode styling', () => {
const { container } = render(
<SearchBox {...defaultProps} usedInMarketplace={false} />,
)
// In non-marketplace mode, search icon appears first
expect(container.querySelector('.radius-md')).toBeInTheDocument()
})
it('should render placeholder correctly', () => {
render(<SearchBox {...defaultProps} placeholder="Search here..." />)
expect(screen.getByPlaceholderText('Search here...')).toBeInTheDocument()
})
it('should render search input with current value', () => {
render(<SearchBox {...defaultProps} search="test query" />)
expect(screen.getByDisplayValue('test query')).toBeInTheDocument()
})
it('should render TagsFilter component', () => {
render(<SearchBox {...defaultProps} />)
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
})
// ================================
// Marketplace Mode Tests
// ================================
describe('Marketplace Mode', () => {
it('should render TagsFilter before input in marketplace mode', () => {
render(<SearchBox {...defaultProps} usedInMarketplace />)
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(<SearchBox {...defaultProps} usedInMarketplace search="test" />)
// 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(<SearchBox {...defaultProps} usedInMarketplace search="" />)
// 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(
<SearchBox {...defaultProps} usedInMarketplace={false} />,
)
// Search icon should be present
expect(container.querySelector('.text-components-input-text-placeholder')).toBeInTheDocument()
})
it('should render clear button when search has value', () => {
render(<SearchBox {...defaultProps} usedInMarketplace={false} search="test" />)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
})
it('should render TagsFilter after input in non-marketplace mode', () => {
render(<SearchBox {...defaultProps} usedInMarketplace={false} />)
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(<SearchBox {...defaultProps} usedInMarketplace={false} autoFocus />)
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(<SearchBox {...defaultProps} onSearchChange={onSearchChange} />)
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(
<SearchBox
{...defaultProps}
onSearchChange={onSearchChange}
usedInMarketplace
search="test"
/>,
)
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(
<SearchBox
{...defaultProps}
onSearchChange={onSearchChange}
usedInMarketplace={false}
search="test"
/>,
)
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(<SearchBox {...defaultProps} onSearchChange={onSearchChange} />)
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(<SearchBox {...defaultProps} supportAddCustomTool />)
// 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(
<SearchBox {...defaultProps} supportAddCustomTool={false} />,
)
// 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(
<SearchBox
{...defaultProps}
supportAddCustomTool
onShowAddCustomCollectionModal={onShowAddCustomCollectionModal}
/>,
)
// 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(
<SearchBox {...defaultProps} wrapperClassName="custom-wrapper-class" />,
)
expect(container.querySelector('.custom-wrapper-class')).toBeInTheDocument()
})
it('should apply inputClassName correctly', () => {
const { container } = render(
<SearchBox {...defaultProps} inputClassName="custom-input-class" />,
)
expect(container.querySelector('.custom-input-class')).toBeInTheDocument()
})
it('should pass locale to TagsFilter', () => {
render(<SearchBox {...defaultProps} locale="zh-CN" />)
// TagsFilter should be rendered with locale
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should handle empty placeholder', () => {
render(<SearchBox {...defaultProps} placeholder="" />)
expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', '')
})
it('should use default placeholder when not provided', () => {
render(<SearchBox {...defaultProps} />)
expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', '')
})
})
// ================================
// Edge Cases Tests
// ================================
describe('Edge Cases', () => {
it('should handle empty search value', () => {
render(<SearchBox {...defaultProps} search="" />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toHaveValue('')
})
it('should handle empty tags array', () => {
render(<SearchBox {...defaultProps} tags={[]} />)
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should handle special characters in search', () => {
const onSearchChange = vi.fn()
render(<SearchBox {...defaultProps} onSearchChange={onSearchChange} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '<script>alert("xss")</script>' } })
expect(onSearchChange).toHaveBeenCalledWith('<script>alert("xss")</script>')
})
it('should handle very long search strings', () => {
const longString = 'a'.repeat(1000)
render(<SearchBox {...defaultProps} search={longString} />)
expect(screen.getByDisplayValue(longString)).toBeInTheDocument()
})
it('should handle whitespace-only search', () => {
const onSearchChange = vi.fn()
render(<SearchBox {...defaultProps} onSearchChange={onSearchChange} />)
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(<SearchBoxWrapper />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render with locale prop', () => {
render(<SearchBoxWrapper locale="en-US" />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render in marketplace mode', () => {
const { container } = render(<SearchBoxWrapper />)
expect(container.querySelector('.rounded-xl')).toBeInTheDocument()
})
it('should apply correct wrapper classes', () => {
const { container } = render(<SearchBoxWrapper />)
// 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(<SearchBoxWrapper />)
expect(screen.getByDisplayValue('context search')).toBeInTheDocument()
})
it('should call handleSearchPluginTextChange when search changes', () => {
render(<SearchBoxWrapper />)
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(<SearchBoxWrapper />)
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
})
describe('Translation', () => {
it('should use translation for placeholder', () => {
render(<SearchBoxWrapper />)
expect(screen.getByPlaceholderText('Search plugins')).toBeInTheDocument()
})
it('should pass locale to useMixedTranslation', () => {
render(<SearchBoxWrapper locale="zh-CN" />)
// 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(<MarketplaceTrigger {...defaultProps} />)
expect(screen.getByText('All Tags')).toBeInTheDocument()
})
it('should show "All Tags" when no tags selected', () => {
render(<MarketplaceTrigger {...defaultProps} selectedTagsLength={0} />)
expect(screen.getByText('All Tags')).toBeInTheDocument()
})
it('should show arrow down icon when no tags selected', () => {
const { container } = render(
<MarketplaceTrigger {...defaultProps} selectedTagsLength={0} />,
)
// 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(
<MarketplaceTrigger
{...defaultProps}
selectedTagsLength={1}
tags={['agent']}
/>,
)
expect(screen.getByText('Agent')).toBeInTheDocument()
})
it('should show multiple tag labels separated by comma', () => {
render(
<MarketplaceTrigger
{...defaultProps}
selectedTagsLength={2}
tags={['agent', 'rag']}
/>,
)
expect(screen.getByText('Agent,RAG')).toBeInTheDocument()
})
it('should show +N indicator when more than 2 tags selected', () => {
render(
<MarketplaceTrigger
{...defaultProps}
selectedTagsLength={4}
tags={['agent', 'rag', 'search', 'image']}
/>,
)
expect(screen.getByText('+2')).toBeInTheDocument()
})
it('should only show first 2 tags in label', () => {
render(
<MarketplaceTrigger
{...defaultProps}
selectedTagsLength={3}
tags={['agent', 'rag', 'search']}
/>,
)
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(
<MarketplaceTrigger
{...defaultProps}
selectedTagsLength={1}
tags={['agent']}
/>,
)
// 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(
<MarketplaceTrigger {...defaultProps} selectedTagsLength={0} />,
)
// 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(
<MarketplaceTrigger
{...defaultProps}
selectedTagsLength={2}
tags={['agent', 'rag']}
onTagsChange={onTagsChange}
/>,
)
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(
<MarketplaceTrigger {...defaultProps} open selectedTagsLength={0} />,
)
expect(container.querySelector('.bg-state-base-hover')).toBeInTheDocument()
})
it('should apply border styling when tags are selected', () => {
const { container } = render(
<MarketplaceTrigger
{...defaultProps}
selectedTagsLength={1}
tags={['agent']}
/>,
)
expect(container.querySelector('.border-components-button-secondary-border')).toBeInTheDocument()
})
})
describe('Props Variations', () => {
it('should handle locale prop', () => {
render(<MarketplaceTrigger {...defaultProps} locale="zh-CN" />)
expect(screen.getByText('All Tags')).toBeInTheDocument()
})
it('should handle empty tagsMap', () => {
const { container } = render(
<MarketplaceTrigger {...defaultProps} tagsMap={{}} tags={[]} />,
)
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(<ToolSelectorTrigger {...defaultProps} />)
expect(container).toBeInTheDocument()
})
it('should render price tag icon', () => {
const { container } = render(<ToolSelectorTrigger {...defaultProps} />)
expect(container.querySelector('.size-4')).toBeInTheDocument()
})
})
describe('Selected Tags Display', () => {
it('should show selected tag labels when tags are selected', () => {
render(
<ToolSelectorTrigger
{...defaultProps}
selectedTagsLength={1}
tags={['agent']}
/>,
)
expect(screen.getByText('Agent')).toBeInTheDocument()
})
it('should show multiple tag labels separated by comma', () => {
render(
<ToolSelectorTrigger
{...defaultProps}
selectedTagsLength={2}
tags={['agent', 'rag']}
/>,
)
expect(screen.getByText('Agent,RAG')).toBeInTheDocument()
})
it('should show +N indicator when more than 2 tags selected', () => {
render(
<ToolSelectorTrigger
{...defaultProps}
selectedTagsLength={4}
tags={['agent', 'rag', 'search', 'image']}
/>,
)
expect(screen.getByText('+2')).toBeInTheDocument()
})
it('should not show tag labels when no tags selected', () => {
render(<ToolSelectorTrigger {...defaultProps} selectedTagsLength={0} />)
expect(screen.queryByText('Agent')).not.toBeInTheDocument()
})
})
describe('Clear Tags Button', () => {
it('should show clear button when tags are selected', () => {
const { container } = render(
<ToolSelectorTrigger
{...defaultProps}
selectedTagsLength={1}
tags={['agent']}
/>,
)
expect(container.querySelector('.text-text-quaternary')).toBeInTheDocument()
})
it('should not show clear button when no tags selected', () => {
const { container } = render(
<ToolSelectorTrigger {...defaultProps} selectedTagsLength={0} />,
)
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(
<ToolSelectorTrigger
{...defaultProps}
selectedTagsLength={2}
tags={['agent', 'rag']}
onTagsChange={onTagsChange}
/>,
)
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(
<div onClick={parentClickHandler}>
<ToolSelectorTrigger
{...defaultProps}
selectedTagsLength={1}
tags={['agent']}
onTagsChange={onTagsChange}
/>
</div>,
)
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(
<ToolSelectorTrigger {...defaultProps} open selectedTagsLength={0} />,
)
expect(container.querySelector('.bg-state-base-hover')).toBeInTheDocument()
})
it('should apply border styling when tags are selected', () => {
const { container } = render(
<ToolSelectorTrigger
{...defaultProps}
selectedTagsLength={1}
tags={['agent']}
/>,
)
expect(container.querySelector('.border-components-button-secondary-border')).toBeInTheDocument()
})
it('should not apply hover styling when open but has tags', () => {
const { container } = render(
<ToolSelectorTrigger
{...defaultProps}
open
selectedTagsLength={1}
tags={['agent']}
/>,
)
// 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(
<ToolSelectorTrigger
{...defaultProps}
selectedTagsLength={1}
tags={['agent']}
tagsMap={mockTagsMap}
/>,
)
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(
<SearchBox
search=""
onSearchChange={vi.fn()}
tags={[]}
onTagsChange={vi.fn()}
/>,
)
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should pass usedInMarketplace prop to TagsFilter', () => {
render(
<SearchBox
search=""
onSearchChange={vi.fn()}
tags={[]}
onTagsChange={vi.fn()}
usedInMarketplace
/>,
)
// MarketplaceTrigger should show "All Tags"
expect(screen.getByText('All Tags')).toBeInTheDocument()
})
it('should show selected tags count in TagsFilter trigger', () => {
render(
<SearchBox
search=""
onSearchChange={vi.fn()}
tags={['agent', 'rag', 'search']}
onTagsChange={vi.fn()}
usedInMarketplace
/>,
)
expect(screen.getByText('+1')).toBeInTheDocument()
})
})
describe('Dropdown Behavior', () => {
it('should open dropdown when trigger is clicked', async () => {
render(
<SearchBox
search=""
onSearchChange={vi.fn()}
tags={[]}
onTagsChange={vi.fn()}
/>,
)
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(
<SearchBox
search=""
onSearchChange={vi.fn()}
tags={[]}
onTagsChange={vi.fn()}
/>,
)
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(
<SearchBox
search=""
onSearchChange={vi.fn()}
tags={[]}
onTagsChange={vi.fn()}
/>,
)
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(
<SearchBox
search=""
onSearchChange={vi.fn()}
tags={[]}
onTagsChange={onTagsChange}
/>,
)
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(
<SearchBox
search=""
onSearchChange={vi.fn()}
tags={['agent']}
onTagsChange={onTagsChange}
/>,
)
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(
<SearchBox
search=""
onSearchChange={vi.fn()}
tags={['agent']}
onTagsChange={onTagsChange}
/>,
)
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(
<SearchBox
search=""
onSearchChange={vi.fn()}
tags={[]}
onTagsChange={vi.fn()}
/>,
)
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(
<SearchBox
search=""
onSearchChange={vi.fn()}
tags={[]}
onTagsChange={vi.fn()}
/>,
)
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(
<SearchBox
search=""
onSearchChange={vi.fn()}
tags={['agent']}
onTagsChange={vi.fn()}
/>,
)
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(
<SearchBox
search=""
onSearchChange={vi.fn()}
tags={[]}
onTagsChange={vi.fn()}
/>,
)
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(
<SearchBox
search=""
onSearchChange={vi.fn()}
tags={[]}
onTagsChange={vi.fn()}
placeholder="Search plugins"
/>,
)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
expect(input).toHaveAttribute('placeholder', 'Search plugins')
})
it('should have clickable tag options in dropdown', async () => {
render(<SearchBox search="" onSearchChange={vi.fn()} tags={[]} onTagsChange={vi.fn()} />)
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(
<SearchBox
search=""
onSearchChange={onSearchChange}
tags={[]}
onTagsChange={onTagsChange}
usedInMarketplace
/>,
)
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(
<SearchBox
search="test"
onSearchChange={vi.fn()}
tags={['agent', 'rag']}
onTagsChange={vi.fn()}
usedInMarketplace
supportAddCustomTool
onShowAddCustomCollectionModal={vi.fn()}
placeholder="Search plugins"
locale="en-US"
wrapperClassName="custom-wrapper"
inputClassName="custom-input"
autoFocus={false}
/>,
)
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(
<SearchBox
search="initial"
onSearchChange={onSearchChange}
tags={[]}
onTagsChange={vi.fn()}
/>,
)
expect(screen.getByDisplayValue('initial')).toBeInTheDocument()
rerender(
<SearchBox
search="updated"
onSearchChange={onSearchChange}
tags={[]}
onTagsChange={vi.fn()}
/>,
)
expect(screen.getByDisplayValue('updated')).toBeInTheDocument()
})
})