test: add comprehensive unit tests for JinaReader and WaterCrawl comp… (#29768)
Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
This commit is contained in:
555
web/app/components/datasets/create/website/base.spec.tsx
Normal file
555
web/app/components/datasets/create/website/base.spec.tsx
Normal file
@@ -0,0 +1,555 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Input from './base/input'
|
||||
import Header from './base/header'
|
||||
import CrawledResult from './base/crawled-result'
|
||||
import CrawledResultItem from './base/crawled-result-item'
|
||||
import type { CrawlResultItem } from '@/models/datasets'
|
||||
|
||||
// ============================================================================
|
||||
// Test Data Factories
|
||||
// ============================================================================
|
||||
|
||||
const createCrawlResultItem = (overrides: Partial<CrawlResultItem> = {}): CrawlResultItem => ({
|
||||
title: 'Test Page Title',
|
||||
markdown: '# Test Content',
|
||||
description: 'Test description',
|
||||
source_url: 'https://example.com/page',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Input Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Input', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
const createInputProps = (overrides: Partial<Parameters<typeof Input>[0]> = {}) => ({
|
||||
value: '',
|
||||
onChange: jest.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render text input by default', () => {
|
||||
const props = createInputProps()
|
||||
render(<Input {...props} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).toHaveAttribute('type', 'text')
|
||||
})
|
||||
|
||||
it('should render number input when isNumber is true', () => {
|
||||
const props = createInputProps({ isNumber: true, value: 0 })
|
||||
render(<Input {...props} />)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).toHaveAttribute('type', 'number')
|
||||
expect(input).toHaveAttribute('min', '0')
|
||||
})
|
||||
|
||||
it('should render with placeholder', () => {
|
||||
const props = createInputProps({ placeholder: 'Enter URL' })
|
||||
render(<Input {...props} />)
|
||||
|
||||
expect(screen.getByPlaceholderText('Enter URL')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with initial value', () => {
|
||||
const props = createInputProps({ value: 'test value' })
|
||||
render(<Input {...props} />)
|
||||
|
||||
expect(screen.getByDisplayValue('test value')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Text Input Behavior', () => {
|
||||
it('should call onChange with string value for text input', async () => {
|
||||
const onChange = jest.fn()
|
||||
const props = createInputProps({ onChange })
|
||||
|
||||
render(<Input {...props} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
await userEvent.type(input, 'hello')
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('h')
|
||||
expect(onChange).toHaveBeenCalledWith('e')
|
||||
expect(onChange).toHaveBeenCalledWith('l')
|
||||
expect(onChange).toHaveBeenCalledWith('l')
|
||||
expect(onChange).toHaveBeenCalledWith('o')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Number Input Behavior', () => {
|
||||
it('should call onChange with parsed integer for number input', () => {
|
||||
const onChange = jest.fn()
|
||||
const props = createInputProps({ isNumber: true, onChange, value: 0 })
|
||||
|
||||
render(<Input {...props} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
fireEvent.change(input, { target: { value: '42' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('should call onChange with empty string when input is NaN', () => {
|
||||
const onChange = jest.fn()
|
||||
const props = createInputProps({ isNumber: true, onChange, value: 0 })
|
||||
|
||||
render(<Input {...props} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'abc' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('should call onChange with empty string when input is empty', () => {
|
||||
const onChange = jest.fn()
|
||||
const props = createInputProps({ isNumber: true, onChange, value: 5 })
|
||||
|
||||
render(<Input {...props} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('should clamp negative values to MIN_VALUE (0)', () => {
|
||||
const onChange = jest.fn()
|
||||
const props = createInputProps({ isNumber: true, onChange, value: 0 })
|
||||
|
||||
render(<Input {...props} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
fireEvent.change(input, { target: { value: '-5' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('should handle decimal input by parsing as integer', () => {
|
||||
const onChange = jest.fn()
|
||||
const props = createInputProps({ isNumber: true, onChange, value: 0 })
|
||||
|
||||
render(<Input {...props} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
fireEvent.change(input, { target: { value: '3.7' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Memoization', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
expect(Input.$$typeof).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Header Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Header', () => {
|
||||
const createHeaderProps = (overrides: Partial<Parameters<typeof Header>[0]> = {}) => ({
|
||||
title: 'Test Title',
|
||||
docTitle: 'Documentation',
|
||||
docLink: 'https://docs.example.com',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render title', () => {
|
||||
const props = createHeaderProps()
|
||||
render(<Header {...props} />)
|
||||
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render doc link', () => {
|
||||
const props = createHeaderProps()
|
||||
render(<Header {...props} />)
|
||||
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveAttribute('href', 'https://docs.example.com')
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
|
||||
it('should render button text when not in pipeline', () => {
|
||||
const props = createHeaderProps({ buttonText: 'Configure' })
|
||||
render(<Header {...props} />)
|
||||
|
||||
expect(screen.getByText('Configure')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render button text when in pipeline', () => {
|
||||
const props = createHeaderProps({ isInPipeline: true, buttonText: 'Configure' })
|
||||
render(<Header {...props} />)
|
||||
|
||||
expect(screen.queryByText('Configure')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isInPipeline Prop', () => {
|
||||
it('should apply pipeline styles when isInPipeline is true', () => {
|
||||
const props = createHeaderProps({ isInPipeline: true })
|
||||
render(<Header {...props} />)
|
||||
|
||||
const titleElement = screen.getByText('Test Title')
|
||||
expect(titleElement).toHaveClass('system-sm-semibold')
|
||||
})
|
||||
|
||||
it('should apply default styles when isInPipeline is false', () => {
|
||||
const props = createHeaderProps({ isInPipeline: false })
|
||||
render(<Header {...props} />)
|
||||
|
||||
const titleElement = screen.getByText('Test Title')
|
||||
expect(titleElement).toHaveClass('system-md-semibold')
|
||||
})
|
||||
|
||||
it('should apply compact button styles when isInPipeline is true', () => {
|
||||
const props = createHeaderProps({ isInPipeline: true })
|
||||
render(<Header {...props} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('size-6')
|
||||
expect(button).toHaveClass('px-1')
|
||||
})
|
||||
|
||||
it('should apply default button styles when isInPipeline is false', () => {
|
||||
const props = createHeaderProps({ isInPipeline: false })
|
||||
render(<Header {...props} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('gap-x-0.5')
|
||||
expect(button).toHaveClass('px-1.5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClickConfiguration when button is clicked', async () => {
|
||||
const onClickConfiguration = jest.fn()
|
||||
const props = createHeaderProps({ onClickConfiguration })
|
||||
|
||||
render(<Header {...props} />)
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(onClickConfiguration).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Memoization', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
expect(Header.$$typeof).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// CrawledResultItem Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('CrawledResultItem', () => {
|
||||
const createItemProps = (overrides: Partial<Parameters<typeof CrawledResultItem>[0]> = {}) => ({
|
||||
payload: createCrawlResultItem(),
|
||||
isChecked: false,
|
||||
isPreview: false,
|
||||
onCheckChange: jest.fn(),
|
||||
onPreview: jest.fn(),
|
||||
testId: 'test-item',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render title and source URL', () => {
|
||||
const props = createItemProps({
|
||||
payload: createCrawlResultItem({
|
||||
title: 'My Page',
|
||||
source_url: 'https://mysite.com',
|
||||
}),
|
||||
})
|
||||
render(<CrawledResultItem {...props} />)
|
||||
|
||||
expect(screen.getByText('My Page')).toBeInTheDocument()
|
||||
expect(screen.getByText('https://mysite.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render checkbox (custom Checkbox component)', () => {
|
||||
const props = createItemProps()
|
||||
render(<CrawledResultItem {...props} />)
|
||||
|
||||
// Find checkbox by data-testid
|
||||
const checkbox = screen.getByTestId('checkbox-test-item')
|
||||
expect(checkbox).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render preview button', () => {
|
||||
const props = createItemProps()
|
||||
render(<CrawledResultItem {...props} />)
|
||||
|
||||
expect(screen.getByText('datasetCreation.stepOne.website.preview')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Checkbox Behavior', () => {
|
||||
it('should call onCheckChange with true when unchecked item is clicked', async () => {
|
||||
const onCheckChange = jest.fn()
|
||||
const props = createItemProps({ isChecked: false, onCheckChange })
|
||||
|
||||
render(<CrawledResultItem {...props} />)
|
||||
const checkbox = screen.getByTestId('checkbox-test-item')
|
||||
await userEvent.click(checkbox)
|
||||
|
||||
expect(onCheckChange).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should call onCheckChange with false when checked item is clicked', async () => {
|
||||
const onCheckChange = jest.fn()
|
||||
const props = createItemProps({ isChecked: true, onCheckChange })
|
||||
|
||||
render(<CrawledResultItem {...props} />)
|
||||
const checkbox = screen.getByTestId('checkbox-test-item')
|
||||
await userEvent.click(checkbox)
|
||||
|
||||
expect(onCheckChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Preview Behavior', () => {
|
||||
it('should call onPreview when preview button is clicked', async () => {
|
||||
const onPreview = jest.fn()
|
||||
const props = createItemProps({ onPreview })
|
||||
|
||||
render(<CrawledResultItem {...props} />)
|
||||
await userEvent.click(screen.getByText('datasetCreation.stepOne.website.preview'))
|
||||
|
||||
expect(onPreview).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should apply active style when isPreview is true', () => {
|
||||
const props = createItemProps({ isPreview: true })
|
||||
const { container } = render(<CrawledResultItem {...props} />)
|
||||
|
||||
const wrapper = container.firstChild
|
||||
expect(wrapper).toHaveClass('bg-state-base-active')
|
||||
})
|
||||
|
||||
it('should not apply active style when isPreview is false', () => {
|
||||
const props = createItemProps({ isPreview: false })
|
||||
const { container } = render(<CrawledResultItem {...props} />)
|
||||
|
||||
const wrapper = container.firstChild
|
||||
expect(wrapper).not.toHaveClass('bg-state-base-active')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Memoization', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
expect(CrawledResultItem.$$typeof).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// CrawledResult Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('CrawledResult', () => {
|
||||
const createResultProps = (overrides: Partial<Parameters<typeof CrawledResult>[0]> = {}) => ({
|
||||
list: [
|
||||
createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
|
||||
createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
|
||||
createCrawlResultItem({ source_url: 'https://page3.com', title: 'Page 3' }),
|
||||
],
|
||||
checkedList: [],
|
||||
onSelectedChange: jest.fn(),
|
||||
onPreview: jest.fn(),
|
||||
usedTime: 2.5,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Helper functions to get checkboxes by data-testid
|
||||
const getSelectAllCheckbox = () => screen.getByTestId('checkbox-select-all')
|
||||
const getItemCheckbox = (index: number) => screen.getByTestId(`checkbox-item-${index}`)
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render all items in list', () => {
|
||||
const props = createResultProps()
|
||||
render(<CrawledResult {...props} />)
|
||||
|
||||
expect(screen.getByText('Page 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Page 2')).toBeInTheDocument()
|
||||
expect(screen.getByText('Page 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render time info', () => {
|
||||
const props = createResultProps({ usedTime: 3.456 })
|
||||
render(<CrawledResult {...props} />)
|
||||
|
||||
// The component uses i18n, so we check for the key pattern
|
||||
expect(screen.getByText(/scrapTimeInfo/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render select all checkbox', () => {
|
||||
const props = createResultProps()
|
||||
render(<CrawledResult {...props} />)
|
||||
|
||||
expect(screen.getByText('datasetCreation.stepOne.website.selectAll')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render reset all when all items are checked', () => {
|
||||
const list = [
|
||||
createCrawlResultItem({ source_url: 'https://page1.com' }),
|
||||
createCrawlResultItem({ source_url: 'https://page2.com' }),
|
||||
]
|
||||
const props = createResultProps({ list, checkedList: list })
|
||||
render(<CrawledResult {...props} />)
|
||||
|
||||
expect(screen.getByText('datasetCreation.stepOne.website.resetAll')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Select All / Deselect All', () => {
|
||||
it('should call onSelectedChange with all items when select all is clicked', async () => {
|
||||
const onSelectedChange = jest.fn()
|
||||
const list = [
|
||||
createCrawlResultItem({ source_url: 'https://page1.com' }),
|
||||
createCrawlResultItem({ source_url: 'https://page2.com' }),
|
||||
]
|
||||
const props = createResultProps({ list, checkedList: [], onSelectedChange })
|
||||
|
||||
render(<CrawledResult {...props} />)
|
||||
await userEvent.click(getSelectAllCheckbox())
|
||||
|
||||
expect(onSelectedChange).toHaveBeenCalledWith(list)
|
||||
})
|
||||
|
||||
it('should call onSelectedChange with empty array when reset all is clicked', async () => {
|
||||
const onSelectedChange = jest.fn()
|
||||
const list = [
|
||||
createCrawlResultItem({ source_url: 'https://page1.com' }),
|
||||
createCrawlResultItem({ source_url: 'https://page2.com' }),
|
||||
]
|
||||
const props = createResultProps({ list, checkedList: list, onSelectedChange })
|
||||
|
||||
render(<CrawledResult {...props} />)
|
||||
await userEvent.click(getSelectAllCheckbox())
|
||||
|
||||
expect(onSelectedChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Individual Item Selection', () => {
|
||||
it('should add item to checkedList when unchecked item is checked', async () => {
|
||||
const onSelectedChange = jest.fn()
|
||||
const list = [
|
||||
createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
|
||||
createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
|
||||
]
|
||||
const props = createResultProps({ list, checkedList: [], onSelectedChange })
|
||||
|
||||
render(<CrawledResult {...props} />)
|
||||
await userEvent.click(getItemCheckbox(0))
|
||||
|
||||
expect(onSelectedChange).toHaveBeenCalledWith([list[0]])
|
||||
})
|
||||
|
||||
it('should remove item from checkedList when checked item is unchecked', async () => {
|
||||
const onSelectedChange = jest.fn()
|
||||
const list = [
|
||||
createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
|
||||
createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
|
||||
]
|
||||
const props = createResultProps({ list, checkedList: [list[0]], onSelectedChange })
|
||||
|
||||
render(<CrawledResult {...props} />)
|
||||
await userEvent.click(getItemCheckbox(0))
|
||||
|
||||
expect(onSelectedChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('should preserve other checked items when unchecking one item', async () => {
|
||||
const onSelectedChange = jest.fn()
|
||||
const list = [
|
||||
createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
|
||||
createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
|
||||
createCrawlResultItem({ source_url: 'https://page3.com', title: 'Page 3' }),
|
||||
]
|
||||
const props = createResultProps({ list, checkedList: [list[0], list[1]], onSelectedChange })
|
||||
|
||||
render(<CrawledResult {...props} />)
|
||||
// Click the first item's checkbox to uncheck it
|
||||
await userEvent.click(getItemCheckbox(0))
|
||||
|
||||
expect(onSelectedChange).toHaveBeenCalledWith([list[1]])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Preview Behavior', () => {
|
||||
it('should call onPreview with correct item when preview is clicked', async () => {
|
||||
const onPreview = jest.fn()
|
||||
const list = [
|
||||
createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
|
||||
createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
|
||||
]
|
||||
const props = createResultProps({ list, onPreview })
|
||||
|
||||
render(<CrawledResult {...props} />)
|
||||
|
||||
// Click preview on second item
|
||||
const previewButtons = screen.getAllByText('datasetCreation.stepOne.website.preview')
|
||||
await userEvent.click(previewButtons[1])
|
||||
|
||||
expect(onPreview).toHaveBeenCalledWith(list[1])
|
||||
})
|
||||
|
||||
it('should track preview index correctly', async () => {
|
||||
const onPreview = jest.fn()
|
||||
const list = [
|
||||
createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
|
||||
createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
|
||||
]
|
||||
const props = createResultProps({ list, onPreview })
|
||||
|
||||
render(<CrawledResult {...props} />)
|
||||
|
||||
// Click preview on first item
|
||||
const previewButtons = screen.getAllByText('datasetCreation.stepOne.website.preview')
|
||||
await userEvent.click(previewButtons[0])
|
||||
|
||||
expect(onPreview).toHaveBeenCalledWith(list[0])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Memoization', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
expect(CrawledResult.$$typeof).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty list', () => {
|
||||
const props = createResultProps({ list: [], checkedList: [] })
|
||||
render(<CrawledResult {...props} />)
|
||||
|
||||
// Should still render the header with resetAll (empty list = all checked)
|
||||
expect(screen.getByText('datasetCreation.stepOne.website.resetAll')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle className prop', () => {
|
||||
const props = createResultProps({ className: 'custom-class' })
|
||||
const { container } = render(<CrawledResult {...props} />)
|
||||
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,7 @@ type Props = {
|
||||
label: string
|
||||
labelClassName?: string
|
||||
tooltip?: string
|
||||
testId?: string
|
||||
}
|
||||
|
||||
const CheckboxWithLabel: FC<Props> = ({
|
||||
@@ -21,10 +22,11 @@ const CheckboxWithLabel: FC<Props> = ({
|
||||
label,
|
||||
labelClassName,
|
||||
tooltip,
|
||||
testId,
|
||||
}) => {
|
||||
return (
|
||||
<label className={cn(className, 'flex h-7 items-center space-x-2')}>
|
||||
<Checkbox checked={isChecked} onCheck={() => onChange(!isChecked)} />
|
||||
<Checkbox checked={isChecked} onCheck={() => onChange(!isChecked)} id={testId} />
|
||||
<div className={cn('text-sm font-normal text-text-secondary', labelClassName)}>{label}</div>
|
||||
{tooltip && (
|
||||
<Tooltip
|
||||
|
||||
@@ -13,6 +13,7 @@ type Props = {
|
||||
isPreview: boolean
|
||||
onCheckChange: (checked: boolean) => void
|
||||
onPreview: () => void
|
||||
testId?: string
|
||||
}
|
||||
|
||||
const CrawledResultItem: FC<Props> = ({
|
||||
@@ -21,6 +22,7 @@ const CrawledResultItem: FC<Props> = ({
|
||||
isChecked,
|
||||
onCheckChange,
|
||||
onPreview,
|
||||
testId,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -31,7 +33,7 @@ const CrawledResultItem: FC<Props> = ({
|
||||
<div className={cn(isPreview ? 'bg-state-base-active' : 'group hover:bg-state-base-hover', 'cursor-pointer rounded-lg p-2')}>
|
||||
<div className='relative flex'>
|
||||
<div className='flex h-5 items-center'>
|
||||
<Checkbox className='mr-2 shrink-0' checked={isChecked} onCheck={handleCheckChange} />
|
||||
<Checkbox className='mr-2 shrink-0' checked={isChecked} onCheck={handleCheckChange} id={testId} />
|
||||
</div>
|
||||
<div className='flex min-w-0 grow flex-col'>
|
||||
<div
|
||||
|
||||
@@ -61,8 +61,10 @@ const CrawledResult: FC<Props> = ({
|
||||
<div className='flex h-[34px] items-center justify-between px-4'>
|
||||
<CheckboxWithLabel
|
||||
isChecked={isCheckAll}
|
||||
onChange={handleCheckedAll} label={isCheckAll ? t(`${I18N_PREFIX}.resetAll`) : t(`${I18N_PREFIX}.selectAll`)}
|
||||
onChange={handleCheckedAll}
|
||||
label={isCheckAll ? t(`${I18N_PREFIX}.resetAll`) : t(`${I18N_PREFIX}.selectAll`)}
|
||||
labelClassName='system-[13px] leading-[16px] font-medium text-text-secondary'
|
||||
testId='select-all'
|
||||
/>
|
||||
<div className='text-xs text-text-tertiary'>
|
||||
{t(`${I18N_PREFIX}.scrapTimeInfo`, {
|
||||
@@ -80,6 +82,7 @@ const CrawledResult: FC<Props> = ({
|
||||
payload={item}
|
||||
isChecked={checkedList.some(checkedItem => checkedItem.source_url === item.source_url)}
|
||||
onCheckChange={handleItemCheckChange(item)}
|
||||
testId={`item-${index}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import UrlInput from './base/url-input'
|
||||
|
||||
// Mock doc link context
|
||||
jest.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => () => 'https://docs.example.com',
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// UrlInput Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('UrlInput', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// Helper to create default props for UrlInput
|
||||
const createUrlInputProps = (overrides: Partial<Parameters<typeof UrlInput>[0]> = {}) => ({
|
||||
isRunning: false,
|
||||
onRun: jest.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange
|
||||
const props = createUrlInputProps()
|
||||
|
||||
// Act
|
||||
render(<UrlInput {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render input with placeholder from docLink', () => {
|
||||
// Arrange
|
||||
const props = createUrlInputProps()
|
||||
|
||||
// Act
|
||||
render(<UrlInput {...props} />)
|
||||
|
||||
// Assert
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveAttribute('placeholder', 'https://docs.example.com')
|
||||
})
|
||||
|
||||
it('should render run button with correct text when not running', () => {
|
||||
// Arrange
|
||||
const props = createUrlInputProps({ isRunning: false })
|
||||
|
||||
// Act
|
||||
render(<UrlInput {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render button without text when running', () => {
|
||||
// Arrange
|
||||
const props = createUrlInputProps({ isRunning: true })
|
||||
|
||||
// Act
|
||||
render(<UrlInput {...props} />)
|
||||
|
||||
// Assert - find button by data-testid when in loading state
|
||||
const runButton = screen.getByTestId('url-input-run-button')
|
||||
expect(runButton).toBeInTheDocument()
|
||||
// Button text should be empty when running
|
||||
expect(runButton).not.toHaveTextContent(/run/i)
|
||||
})
|
||||
|
||||
it('should show loading state on button when running', () => {
|
||||
// Arrange
|
||||
const onRun = jest.fn()
|
||||
const props = createUrlInputProps({ isRunning: true, onRun })
|
||||
|
||||
// Act
|
||||
render(<UrlInput {...props} />)
|
||||
|
||||
// Assert - find button by data-testid when in loading state
|
||||
const runButton = screen.getByTestId('url-input-run-button')
|
||||
expect(runButton).toBeInTheDocument()
|
||||
|
||||
// Verify button is empty (loading state removes text)
|
||||
expect(runButton).not.toHaveTextContent(/run/i)
|
||||
|
||||
// Verify clicking doesn't trigger onRun when loading
|
||||
fireEvent.click(runButton)
|
||||
expect(onRun).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Input Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Input', () => {
|
||||
it('should update URL value when user types', async () => {
|
||||
// Arrange
|
||||
const props = createUrlInputProps()
|
||||
|
||||
// Act
|
||||
render(<UrlInput {...props} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
await userEvent.type(input, 'https://test.com')
|
||||
|
||||
// Assert
|
||||
expect(input).toHaveValue('https://test.com')
|
||||
})
|
||||
|
||||
it('should handle URL input clearing', async () => {
|
||||
// Arrange
|
||||
const props = createUrlInputProps()
|
||||
|
||||
// Act
|
||||
render(<UrlInput {...props} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
await userEvent.type(input, 'https://test.com')
|
||||
await userEvent.clear(input)
|
||||
|
||||
// Assert
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should handle special characters in URL', async () => {
|
||||
// Arrange
|
||||
const props = createUrlInputProps()
|
||||
|
||||
// Act
|
||||
render(<UrlInput {...props} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
await userEvent.type(input, 'https://example.com/path?query=value&foo=bar')
|
||||
|
||||
// Assert
|
||||
expect(input).toHaveValue('https://example.com/path?query=value&foo=bar')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Button Click Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Button Click', () => {
|
||||
it('should call onRun with URL when button is clicked', async () => {
|
||||
// Arrange
|
||||
const onRun = jest.fn()
|
||||
const props = createUrlInputProps({ onRun })
|
||||
|
||||
// Act
|
||||
render(<UrlInput {...props} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
await userEvent.type(input, 'https://run-test.com')
|
||||
await userEvent.click(screen.getByRole('button', { name: /run/i }))
|
||||
|
||||
// Assert
|
||||
expect(onRun).toHaveBeenCalledWith('https://run-test.com')
|
||||
expect(onRun).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onRun with empty string if no URL entered', async () => {
|
||||
// Arrange
|
||||
const onRun = jest.fn()
|
||||
const props = createUrlInputProps({ onRun })
|
||||
|
||||
// Act
|
||||
render(<UrlInput {...props} />)
|
||||
await userEvent.click(screen.getByRole('button', { name: /run/i }))
|
||||
|
||||
// Assert
|
||||
expect(onRun).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('should not call onRun when isRunning is true', async () => {
|
||||
// Arrange
|
||||
const onRun = jest.fn()
|
||||
const props = createUrlInputProps({ onRun, isRunning: true })
|
||||
|
||||
// Act
|
||||
render(<UrlInput {...props} />)
|
||||
const runButton = screen.getByTestId('url-input-run-button')
|
||||
fireEvent.click(runButton)
|
||||
|
||||
// Assert
|
||||
expect(onRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call onRun when already running', async () => {
|
||||
// Arrange
|
||||
const onRun = jest.fn()
|
||||
|
||||
// First render with isRunning=false, type URL, then rerender with isRunning=true
|
||||
const { rerender } = render(<UrlInput isRunning={false} onRun={onRun} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
await userEvent.type(input, 'https://test.com')
|
||||
|
||||
// Rerender with isRunning=true to simulate a running state
|
||||
rerender(<UrlInput isRunning={true} onRun={onRun} />)
|
||||
|
||||
// Find and click the button by data-testid (loading state has no text)
|
||||
const runButton = screen.getByTestId('url-input-run-button')
|
||||
fireEvent.click(runButton)
|
||||
|
||||
// Assert - onRun should not be called due to early return at line 28
|
||||
expect(onRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should prevent multiple clicks when already running', async () => {
|
||||
// Arrange
|
||||
const onRun = jest.fn()
|
||||
const props = createUrlInputProps({ onRun, isRunning: true })
|
||||
|
||||
// Act
|
||||
render(<UrlInput {...props} />)
|
||||
const runButton = screen.getByTestId('url-input-run-button')
|
||||
fireEvent.click(runButton)
|
||||
fireEvent.click(runButton)
|
||||
fireEvent.click(runButton)
|
||||
|
||||
// Assert
|
||||
expect(onRun).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Props Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Props', () => {
|
||||
it('should respond to isRunning prop change', () => {
|
||||
// Arrange
|
||||
const props = createUrlInputProps({ isRunning: false })
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<UrlInput {...props} />)
|
||||
expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument()
|
||||
|
||||
// Change isRunning to true
|
||||
rerender(<UrlInput {...props} isRunning={true} />)
|
||||
|
||||
// Assert - find button by data-testid and verify it's now in loading state
|
||||
const runButton = screen.getByTestId('url-input-run-button')
|
||||
expect(runButton).toBeInTheDocument()
|
||||
// When loading, the button text should be empty
|
||||
expect(runButton).not.toHaveTextContent(/run/i)
|
||||
})
|
||||
|
||||
it('should call updated onRun callback after prop change', async () => {
|
||||
// Arrange
|
||||
const onRun1 = jest.fn()
|
||||
const onRun2 = jest.fn()
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<UrlInput isRunning={false} onRun={onRun1} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
await userEvent.type(input, 'https://first.com')
|
||||
|
||||
// Change onRun callback
|
||||
rerender(<UrlInput isRunning={false} onRun={onRun2} />)
|
||||
await userEvent.click(screen.getByRole('button', { name: /run/i }))
|
||||
|
||||
// Assert - new callback should be called
|
||||
expect(onRun1).not.toHaveBeenCalled()
|
||||
expect(onRun2).toHaveBeenCalledWith('https://first.com')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Callback Stability Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Callback Stability', () => {
|
||||
it('should use memoized handleUrlChange callback', async () => {
|
||||
// Arrange
|
||||
const props = createUrlInputProps()
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<UrlInput {...props} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
await userEvent.type(input, 'a')
|
||||
|
||||
// Rerender with same props
|
||||
rerender(<UrlInput {...props} />)
|
||||
await userEvent.type(input, 'b')
|
||||
|
||||
// Assert - input should work correctly across rerenders
|
||||
expect(input).toHaveValue('ab')
|
||||
})
|
||||
|
||||
it('should maintain URL state across rerenders', async () => {
|
||||
// Arrange
|
||||
const props = createUrlInputProps()
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<UrlInput {...props} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
await userEvent.type(input, 'https://stable.com')
|
||||
|
||||
// Rerender
|
||||
rerender(<UrlInput {...props} />)
|
||||
|
||||
// Assert - URL should be maintained
|
||||
expect(input).toHaveValue('https://stable.com')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Component Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Component Memoization', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
// Assert
|
||||
expect(UrlInput.$$typeof).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Edge Cases Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle very long URLs', async () => {
|
||||
// Arrange
|
||||
const props = createUrlInputProps()
|
||||
const longUrl = `https://example.com/${'a'.repeat(1000)}`
|
||||
|
||||
// Act
|
||||
render(<UrlInput {...props} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
await userEvent.type(input, longUrl)
|
||||
|
||||
// Assert
|
||||
expect(input).toHaveValue(longUrl)
|
||||
})
|
||||
|
||||
it('should handle URLs with unicode characters', async () => {
|
||||
// Arrange
|
||||
const props = createUrlInputProps()
|
||||
const unicodeUrl = 'https://example.com/路径/测试'
|
||||
|
||||
// Act
|
||||
render(<UrlInput {...props} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
await userEvent.type(input, unicodeUrl)
|
||||
|
||||
// Assert
|
||||
expect(input).toHaveValue(unicodeUrl)
|
||||
})
|
||||
|
||||
it('should handle rapid typing', async () => {
|
||||
// Arrange
|
||||
const props = createUrlInputProps()
|
||||
|
||||
// Act
|
||||
render(<UrlInput {...props} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
await userEvent.type(input, 'https://rapid.com', { delay: 1 })
|
||||
|
||||
// Assert
|
||||
expect(input).toHaveValue('https://rapid.com')
|
||||
})
|
||||
|
||||
it('should handle keyboard enter to trigger run', async () => {
|
||||
// Arrange - Note: This tests if the button can be activated via keyboard
|
||||
const onRun = jest.fn()
|
||||
const props = createUrlInputProps({ onRun })
|
||||
|
||||
// Act
|
||||
render(<UrlInput {...props} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
await userEvent.type(input, 'https://enter.com')
|
||||
|
||||
// Focus button and press enter
|
||||
const button = screen.getByRole('button', { name: /run/i })
|
||||
button.focus()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
|
||||
// Assert
|
||||
expect(onRun).toHaveBeenCalledWith('https://enter.com')
|
||||
})
|
||||
|
||||
it('should handle empty URL submission', async () => {
|
||||
// Arrange
|
||||
const onRun = jest.fn()
|
||||
const props = createUrlInputProps({ onRun })
|
||||
|
||||
// Act
|
||||
render(<UrlInput {...props} />)
|
||||
await userEvent.click(screen.getByRole('button', { name: /run/i }))
|
||||
|
||||
// Assert - should call with empty string
|
||||
expect(onRun).toHaveBeenCalledWith('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -41,6 +41,7 @@ const UrlInput: FC<Props> = ({
|
||||
onClick={handleOnRun}
|
||||
className='ml-2'
|
||||
loading={isRunning}
|
||||
data-testid='url-input-run-button'
|
||||
>
|
||||
{!isRunning ? t(`${I18N_PREFIX}.run`) : ''}
|
||||
</Button>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,7 @@ const Options: FC<Props> = ({
|
||||
isChecked={payload.crawl_sub_pages}
|
||||
onChange={handleChange('crawl_sub_pages')}
|
||||
labelClassName='text-[13px] leading-[16px] font-medium text-text-secondary'
|
||||
testId='crawl-sub-pages'
|
||||
/>
|
||||
<CheckboxWithLabel
|
||||
label={t(`${I18N_PREFIX}.useSitemap`)}
|
||||
@@ -44,6 +45,7 @@ const Options: FC<Props> = ({
|
||||
onChange={handleChange('use_sitemap')}
|
||||
tooltip={t(`${I18N_PREFIX}.useSitemapTooltip`) as string}
|
||||
labelClassName='text-[13px] leading-[16px] font-medium text-text-secondary'
|
||||
testId='use-sitemap'
|
||||
/>
|
||||
<div className='flex justify-between space-x-4'>
|
||||
<Field
|
||||
|
||||
1812
web/app/components/datasets/create/website/watercrawl/index.spec.tsx
Normal file
1812
web/app/components/datasets/create/website/watercrawl/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,7 @@ const Options: FC<Props> = ({
|
||||
isChecked={payload.crawl_sub_pages}
|
||||
onChange={handleChange('crawl_sub_pages')}
|
||||
labelClassName='text-[13px] leading-[16px] font-medium text-text-secondary'
|
||||
testId='crawl-sub-pages'
|
||||
/>
|
||||
<div className='flex justify-between space-x-4'>
|
||||
<Field
|
||||
@@ -78,6 +79,7 @@ const Options: FC<Props> = ({
|
||||
isChecked={payload.only_main_content}
|
||||
onChange={handleChange('only_main_content')}
|
||||
labelClassName='text-[13px] leading-[16px] font-medium text-text-secondary'
|
||||
testId='only-main-content'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user