mirror of https://github.com/langgenius/dify.git
1290 lines
37 KiB
TypeScript
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()
|
|
})
|
|
})
|