Files
dify/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.spec.tsx
2025-12-19 17:49:51 +08:00

1498 lines
48 KiB
TypeScript

import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import React from 'react'
import WebsiteCrawl from './index'
import type { CrawlResultItem } from '@/models/datasets'
import { CrawlStep } from '@/models/datasets'
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
// ==========================================
// Mock Modules
// ==========================================
// Note: react-i18next uses global mock from web/__mocks__/react-i18next.ts
// Mock useDocLink - context hook requires mocking
const mockDocLink = jest.fn((path?: string) => `https://docs.example.com${path || ''}`)
jest.mock('@/context/i18n', () => ({
useDocLink: () => mockDocLink,
}))
// Mock dataset-detail context - context provider requires mocking
let mockPipelineId: string | undefined = 'pipeline-123'
jest.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (s: any) => any) => selector({ dataset: { pipeline_id: mockPipelineId } }),
}))
// Mock modal context - context provider requires mocking
const mockSetShowAccountSettingModal = jest.fn()
jest.mock('@/context/modal-context', () => ({
useModalContextSelector: (selector: (s: any) => any) => selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }),
}))
// Mock ssePost - API service requires mocking
const mockSsePost = jest.fn()
jest.mock('@/service/base', () => ({
ssePost: (...args: any[]) => mockSsePost(...args),
}))
// Mock useGetDataSourceAuth - API service hook requires mocking
const mockUseGetDataSourceAuth = jest.fn()
jest.mock('@/service/use-datasource', () => ({
useGetDataSourceAuth: (params: any) => mockUseGetDataSourceAuth(params),
}))
// Mock usePipeline hooks - API service hooks require mocking
const mockUseDraftPipelinePreProcessingParams = jest.fn()
const mockUsePublishedPipelinePreProcessingParams = jest.fn()
jest.mock('@/service/use-pipeline', () => ({
useDraftPipelinePreProcessingParams: (...args: any[]) => mockUseDraftPipelinePreProcessingParams(...args),
usePublishedPipelinePreProcessingParams: (...args: any[]) => mockUsePublishedPipelinePreProcessingParams(...args),
}))
// Note: zustand/react/shallow useShallow is imported directly (simple utility function)
// Mock store
const mockStoreState = {
crawlResult: undefined as { data: CrawlResultItem[]; time_consuming: number | string } | undefined,
step: CrawlStep.init,
websitePages: [] as CrawlResultItem[],
previewIndex: -1,
currentCredentialId: '',
setWebsitePages: jest.fn(),
setCurrentWebsite: jest.fn(),
setPreviewIndex: jest.fn(),
setStep: jest.fn(),
setCrawlResult: jest.fn(),
}
const mockGetState = jest.fn(() => mockStoreState)
const mockDataSourceStore = { getState: mockGetState }
jest.mock('../store', () => ({
useDataSourceStoreWithSelector: (selector: (s: any) => any) => selector(mockStoreState),
useDataSourceStore: () => mockDataSourceStore,
}))
// Mock Header component
jest.mock('../base/header', () => {
const MockHeader = (props: any) => (
<div data-testid="header">
<span data-testid="header-doc-title">{props.docTitle}</span>
<span data-testid="header-doc-link">{props.docLink}</span>
<span data-testid="header-plugin-name">{props.pluginName}</span>
<span data-testid="header-credential-id">{props.currentCredentialId}</span>
<button data-testid="header-config-btn" onClick={props.onClickConfiguration}>Configure</button>
<button data-testid="header-credential-change" onClick={() => props.onCredentialChange('new-cred-id')}>Change Credential</button>
<span data-testid="header-credentials-count">{props.credentials?.length || 0}</span>
</div>
)
return MockHeader
})
// Mock Options component
const mockOptionsSubmit = jest.fn()
jest.mock('./base/options', () => {
const MockOptions = (props: any) => (
<div data-testid="options">
<span data-testid="options-step">{props.step}</span>
<span data-testid="options-run-disabled">{String(props.runDisabled)}</span>
<span data-testid="options-variables-count">{props.variables?.length || 0}</span>
<button
data-testid="options-submit-btn"
onClick={() => {
mockOptionsSubmit()
props.onSubmit({ url: 'https://example.com', depth: 2 })
}}
>
Submit
</button>
</div>
)
return MockOptions
})
// Mock Crawling component
jest.mock('./base/crawling', () => {
const MockCrawling = (props: any) => (
<div data-testid="crawling">
<span data-testid="crawling-crawled-num">{props.crawledNum}</span>
<span data-testid="crawling-total-num">{props.totalNum}</span>
</div>
)
return MockCrawling
})
// Mock ErrorMessage component
jest.mock('./base/error-message', () => {
const MockErrorMessage = (props: any) => (
<div data-testid="error-message" className={props.className}>
<span data-testid="error-title">{props.title}</span>
<span data-testid="error-msg">{props.errorMsg}</span>
</div>
)
return MockErrorMessage
})
// Mock CrawledResult component
jest.mock('./base/crawled-result', () => {
const MockCrawledResult = (props: any) => (
<div data-testid="crawled-result" className={props.className}>
<span data-testid="crawled-result-count">{props.list?.length || 0}</span>
<span data-testid="crawled-result-checked-count">{props.checkedList?.length || 0}</span>
<span data-testid="crawled-result-used-time">{props.usedTime}</span>
<span data-testid="crawled-result-preview-index">{props.previewIndex}</span>
<span data-testid="crawled-result-show-preview">{String(props.showPreview)}</span>
<span data-testid="crawled-result-multiple-choice">{String(props.isMultipleChoice)}</span>
<button
data-testid="crawled-result-select-change"
onClick={() => props.onSelectedChange([{ source_url: 'https://example.com', title: 'Test' }])}
>
Change Selection
</button>
<button
data-testid="crawled-result-preview"
onClick={() => props.onPreview?.({ source_url: 'https://example.com', title: 'Test' }, 0)}
>
Preview
</button>
</div>
)
return MockCrawledResult
})
// ==========================================
// Test Data Builders
// ==========================================
const createMockNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({
title: 'Test Node',
plugin_id: 'plugin-123',
provider_type: 'website',
provider_name: 'website-provider',
datasource_name: 'website-ds',
datasource_label: 'Website Crawler',
datasource_parameters: {},
datasource_configurations: {},
...overrides,
} as DataSourceNodeType)
const createMockCrawlResultItem = (overrides?: Partial<CrawlResultItem>): CrawlResultItem => ({
source_url: 'https://example.com/page1',
title: 'Test Page 1',
markdown: '# Test content',
description: 'Test description',
...overrides,
})
const createMockCredential = (overrides?: Partial<{ id: string; name: string }>) => ({
id: 'cred-1',
name: 'Test Credential',
avatar_url: 'https://example.com/avatar.png',
credential: {},
is_default: false,
type: 'oauth2',
...overrides,
})
type WebsiteCrawlProps = React.ComponentProps<typeof WebsiteCrawl>
const createDefaultProps = (overrides?: Partial<WebsiteCrawlProps>): WebsiteCrawlProps => ({
nodeId: 'node-1',
nodeData: createMockNodeData(),
onCredentialChange: jest.fn(),
isInPipeline: false,
supportBatchUpload: true,
...overrides,
})
// ==========================================
// Test Suites
// ==========================================
describe('WebsiteCrawl', () => {
beforeEach(() => {
jest.clearAllMocks()
// Reset store state
mockStoreState.crawlResult = undefined
mockStoreState.step = CrawlStep.init
mockStoreState.websitePages = []
mockStoreState.previewIndex = -1
mockStoreState.currentCredentialId = ''
mockStoreState.setWebsitePages = jest.fn()
mockStoreState.setCurrentWebsite = jest.fn()
mockStoreState.setPreviewIndex = jest.fn()
mockStoreState.setStep = jest.fn()
mockStoreState.setCrawlResult = jest.fn()
// Reset context values
mockPipelineId = 'pipeline-123'
mockSetShowAccountSettingModal.mockClear()
// Default mock return values
mockUseGetDataSourceAuth.mockReturnValue({
data: { result: [createMockCredential()] },
})
mockUseDraftPipelinePreProcessingParams.mockReturnValue({
data: { variables: [] },
isFetching: false,
})
mockUsePublishedPipelinePreProcessingParams.mockReturnValue({
data: { variables: [] },
isFetching: false,
})
mockGetState.mockReturnValue(mockStoreState)
})
// ==========================================
// Rendering Tests
// ==========================================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(screen.getByTestId('header')).toBeInTheDocument()
expect(screen.getByTestId('options')).toBeInTheDocument()
})
it('should render Header with correct props', () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-123'
const props = createDefaultProps({
nodeData: createMockNodeData({ datasource_label: 'My Website Crawler' }),
})
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(screen.getByTestId('header-doc-title')).toHaveTextContent('Docs')
expect(screen.getByTestId('header-plugin-name')).toHaveTextContent('My Website Crawler')
expect(screen.getByTestId('header-credential-id')).toHaveTextContent('cred-123')
})
it('should render Options with correct props', () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
const props = createDefaultProps()
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(screen.getByTestId('options')).toBeInTheDocument()
expect(screen.getByTestId('options-step')).toHaveTextContent(CrawlStep.init)
})
it('should not render Crawling or CrawledResult when step is init', () => {
// Arrange
mockStoreState.step = CrawlStep.init
const props = createDefaultProps()
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(screen.queryByTestId('crawling')).not.toBeInTheDocument()
expect(screen.queryByTestId('crawled-result')).not.toBeInTheDocument()
expect(screen.queryByTestId('error-message')).not.toBeInTheDocument()
})
it('should render Crawling when step is running', () => {
// Arrange
mockStoreState.step = CrawlStep.running
const props = createDefaultProps()
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(screen.getByTestId('crawling')).toBeInTheDocument()
expect(screen.queryByTestId('crawled-result')).not.toBeInTheDocument()
expect(screen.queryByTestId('error-message')).not.toBeInTheDocument()
})
it('should render CrawledResult when step is finished with no error', () => {
// Arrange
mockStoreState.step = CrawlStep.finished
mockStoreState.crawlResult = {
data: [createMockCrawlResultItem()],
time_consuming: 1.5,
}
const props = createDefaultProps()
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(screen.getByTestId('crawled-result')).toBeInTheDocument()
expect(screen.queryByTestId('crawling')).not.toBeInTheDocument()
expect(screen.queryByTestId('error-message')).not.toBeInTheDocument()
})
})
// ==========================================
// Props Testing
// ==========================================
describe('Props', () => {
describe('nodeId prop', () => {
it('should use nodeId in datasourceNodeRunURL for non-pipeline mode', async () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
const props = createDefaultProps({
nodeId: 'custom-node-id',
isInPipeline: false,
})
// Act
render(<WebsiteCrawl {...props} />)
// Assert - Options uses nodeId through usePreProcessingParams
expect(mockUsePublishedPipelinePreProcessingParams).toHaveBeenCalledWith(
{ pipeline_id: 'pipeline-123', node_id: 'custom-node-id' },
true,
)
})
})
describe('nodeData prop', () => {
it('should pass plugin_id and provider_name to useGetDataSourceAuth', () => {
// Arrange
const nodeData = createMockNodeData({
plugin_id: 'my-plugin-id',
provider_name: 'my-provider',
})
const props = createDefaultProps({ nodeData })
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(mockUseGetDataSourceAuth).toHaveBeenCalledWith({
pluginId: 'my-plugin-id',
provider: 'my-provider',
})
})
it('should pass datasource_label to Header as pluginName', () => {
// Arrange
const nodeData = createMockNodeData({
datasource_label: 'Custom Website Scraper',
})
const props = createDefaultProps({ nodeData })
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(screen.getByTestId('header-plugin-name')).toHaveTextContent('Custom Website Scraper')
})
})
describe('isInPipeline prop', () => {
it('should use draft URL when isInPipeline is true', () => {
// Arrange
const props = createDefaultProps({ isInPipeline: true })
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(mockUseDraftPipelinePreProcessingParams).toHaveBeenCalled()
expect(mockUsePublishedPipelinePreProcessingParams).not.toHaveBeenCalled()
})
it('should use published URL when isInPipeline is false', () => {
// Arrange
const props = createDefaultProps({ isInPipeline: false })
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(mockUsePublishedPipelinePreProcessingParams).toHaveBeenCalled()
expect(mockUseDraftPipelinePreProcessingParams).not.toHaveBeenCalled()
})
it('should pass showPreview as false to CrawledResult when isInPipeline is true', () => {
// Arrange
mockStoreState.step = CrawlStep.finished
mockStoreState.crawlResult = {
data: [createMockCrawlResultItem()],
time_consuming: 1.5,
}
const props = createDefaultProps({ isInPipeline: true })
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(screen.getByTestId('crawled-result-show-preview')).toHaveTextContent('false')
})
it('should pass showPreview as true to CrawledResult when isInPipeline is false', () => {
// Arrange
mockStoreState.step = CrawlStep.finished
mockStoreState.crawlResult = {
data: [createMockCrawlResultItem()],
time_consuming: 1.5,
}
const props = createDefaultProps({ isInPipeline: false })
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(screen.getByTestId('crawled-result-show-preview')).toHaveTextContent('true')
})
})
describe('supportBatchUpload prop', () => {
it('should pass isMultipleChoice as true to CrawledResult when supportBatchUpload is true', () => {
// Arrange
mockStoreState.step = CrawlStep.finished
mockStoreState.crawlResult = {
data: [createMockCrawlResultItem()],
time_consuming: 1.5,
}
const props = createDefaultProps({ supportBatchUpload: true })
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(screen.getByTestId('crawled-result-multiple-choice')).toHaveTextContent('true')
})
it('should pass isMultipleChoice as false to CrawledResult when supportBatchUpload is false', () => {
// Arrange
mockStoreState.step = CrawlStep.finished
mockStoreState.crawlResult = {
data: [createMockCrawlResultItem()],
time_consuming: 1.5,
}
const props = createDefaultProps({ supportBatchUpload: false })
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(screen.getByTestId('crawled-result-multiple-choice')).toHaveTextContent('false')
})
it.each([
[true, 'true'],
[false, 'false'],
[undefined, 'true'], // Default value
])('should handle supportBatchUpload=%s correctly', (value, expected) => {
// Arrange
mockStoreState.step = CrawlStep.finished
mockStoreState.crawlResult = {
data: [createMockCrawlResultItem()],
time_consuming: 1.5,
}
const props = createDefaultProps({ supportBatchUpload: value })
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(screen.getByTestId('crawled-result-multiple-choice')).toHaveTextContent(expected)
})
})
describe('onCredentialChange prop', () => {
it('should call onCredentialChange with credential id and reset state', () => {
// Arrange
const mockOnCredentialChange = jest.fn()
const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange })
// Act
render(<WebsiteCrawl {...props} />)
fireEvent.click(screen.getByTestId('header-credential-change'))
// Assert
expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id')
})
})
})
// ==========================================
// State Management Tests
// ==========================================
describe('State Management', () => {
it('should display correct crawledNum and totalNum when running', () => {
// Arrange
mockStoreState.step = CrawlStep.running
const props = createDefaultProps()
// Act
render(<WebsiteCrawl {...props} />)
// Assert - Initial state is 0/0
expect(screen.getByTestId('crawling-crawled-num')).toHaveTextContent('0')
expect(screen.getByTestId('crawling-total-num')).toHaveTextContent('0')
})
it('should update step and result via ssePost callbacks', async () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
const mockCrawlData: CrawlResultItem[] = [
createMockCrawlResultItem({ source_url: 'https://example.com/1' }),
createMockCrawlResultItem({ source_url: 'https://example.com/2' }),
]
mockSsePost.mockImplementation((url, options, callbacks) => {
// Simulate processing
callbacks.onDataSourceNodeProcessing({
total: 10,
completed: 5,
})
// Simulate completion
callbacks.onDataSourceNodeCompleted({
data: mockCrawlData,
time_consuming: 2.5,
})
})
const props = createDefaultProps()
render(<WebsiteCrawl {...props} />)
// Act - Trigger submit
fireEvent.click(screen.getByTestId('options-submit-btn'))
// Assert
await waitFor(() => {
expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.running)
expect(mockStoreState.setCrawlResult).toHaveBeenCalledWith({
data: mockCrawlData,
time_consuming: 2.5,
})
expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.finished)
})
})
it('should pass runDisabled as true when no credential is selected', () => {
// Arrange
mockStoreState.currentCredentialId = ''
const props = createDefaultProps()
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(screen.getByTestId('options-run-disabled')).toHaveTextContent('true')
})
it('should pass runDisabled as true when params are being fetched', () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
mockUsePublishedPipelinePreProcessingParams.mockReturnValue({
data: { variables: [] },
isFetching: true,
})
const props = createDefaultProps()
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(screen.getByTestId('options-run-disabled')).toHaveTextContent('true')
})
it('should pass runDisabled as false when credential is selected and params are loaded', () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
mockUsePublishedPipelinePreProcessingParams.mockReturnValue({
data: { variables: [] },
isFetching: false,
})
const props = createDefaultProps()
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(screen.getByTestId('options-run-disabled')).toHaveTextContent('false')
})
})
// ==========================================
// Callback Stability and Memoization
// ==========================================
describe('Callback Stability and Memoization', () => {
it('should have stable handleCheckedCrawlResultChange that updates store', () => {
// Arrange
mockStoreState.step = CrawlStep.finished
mockStoreState.crawlResult = {
data: [createMockCrawlResultItem()],
time_consuming: 1.5,
}
const props = createDefaultProps()
render(<WebsiteCrawl {...props} />)
// Act
fireEvent.click(screen.getByTestId('crawled-result-select-change'))
// Assert
expect(mockStoreState.setWebsitePages).toHaveBeenCalledWith([
{ source_url: 'https://example.com', title: 'Test' },
])
})
it('should have stable handlePreview that updates store', () => {
// Arrange
mockStoreState.step = CrawlStep.finished
mockStoreState.crawlResult = {
data: [createMockCrawlResultItem()],
time_consuming: 1.5,
}
const props = createDefaultProps()
render(<WebsiteCrawl {...props} />)
// Act
fireEvent.click(screen.getByTestId('crawled-result-preview'))
// Assert
expect(mockStoreState.setCurrentWebsite).toHaveBeenCalledWith({
source_url: 'https://example.com',
title: 'Test',
})
expect(mockStoreState.setPreviewIndex).toHaveBeenCalledWith(0)
})
it('should have stable handleSetting callback', () => {
// Arrange
const props = createDefaultProps()
render(<WebsiteCrawl {...props} />)
// Act
fireEvent.click(screen.getByTestId('header-config-btn'))
// Assert
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
})
it('should have stable handleCredentialChange that resets state', () => {
// Arrange
const mockOnCredentialChange = jest.fn()
const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange })
render(<WebsiteCrawl {...props} />)
// Act
fireEvent.click(screen.getByTestId('header-credential-change'))
// Assert
expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id')
})
})
// ==========================================
// User Interactions and Event Handlers
// ==========================================
describe('User Interactions and Event Handlers', () => {
it('should handle submit and trigger ssePost', async () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
const props = createDefaultProps()
render(<WebsiteCrawl {...props} />)
// Act
fireEvent.click(screen.getByTestId('options-submit-btn'))
// Assert
await waitFor(() => {
expect(mockSsePost).toHaveBeenCalled()
expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.running)
})
})
it('should handle configuration button click', () => {
// Arrange
const props = createDefaultProps()
render(<WebsiteCrawl {...props} />)
// Act
fireEvent.click(screen.getByTestId('header-config-btn'))
// Assert
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
})
it('should handle credential change', () => {
// Arrange
const mockOnCredentialChange = jest.fn()
const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange })
render(<WebsiteCrawl {...props} />)
// Act
fireEvent.click(screen.getByTestId('header-credential-change'))
// Assert
expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id')
})
it('should handle selection change in CrawledResult', () => {
// Arrange
mockStoreState.step = CrawlStep.finished
mockStoreState.crawlResult = {
data: [createMockCrawlResultItem()],
time_consuming: 1.5,
}
const props = createDefaultProps()
render(<WebsiteCrawl {...props} />)
// Act
fireEvent.click(screen.getByTestId('crawled-result-select-change'))
// Assert
expect(mockStoreState.setWebsitePages).toHaveBeenCalled()
})
it('should handle preview in CrawledResult', () => {
// Arrange
mockStoreState.step = CrawlStep.finished
mockStoreState.crawlResult = {
data: [createMockCrawlResultItem()],
time_consuming: 1.5,
}
const props = createDefaultProps()
render(<WebsiteCrawl {...props} />)
// Act
fireEvent.click(screen.getByTestId('crawled-result-preview'))
// Assert
expect(mockStoreState.setCurrentWebsite).toHaveBeenCalled()
expect(mockStoreState.setPreviewIndex).toHaveBeenCalled()
})
})
// ==========================================
// API Calls Mocking
// ==========================================
describe('API Calls', () => {
it('should call ssePost with correct parameters for published workflow', async () => {
// Arrange
mockStoreState.currentCredentialId = 'test-cred'
mockPipelineId = 'pipeline-456'
const props = createDefaultProps({
nodeId: 'node-789',
isInPipeline: false,
})
render(<WebsiteCrawl {...props} />)
// Act
fireEvent.click(screen.getByTestId('options-submit-btn'))
// Assert
await waitFor(() => {
expect(mockSsePost).toHaveBeenCalledWith(
'/rag/pipelines/pipeline-456/workflows/published/datasource/nodes/node-789/run',
expect.objectContaining({
body: expect.objectContaining({
inputs: { url: 'https://example.com', depth: 2 },
datasource_type: 'website_crawl',
credential_id: 'test-cred',
response_mode: 'streaming',
}),
}),
expect.any(Object),
)
})
})
it('should call ssePost with correct parameters for draft workflow', async () => {
// Arrange
mockStoreState.currentCredentialId = 'test-cred'
mockPipelineId = 'pipeline-456'
const props = createDefaultProps({
nodeId: 'node-789',
isInPipeline: true,
})
render(<WebsiteCrawl {...props} />)
// Act
fireEvent.click(screen.getByTestId('options-submit-btn'))
// Assert
await waitFor(() => {
expect(mockSsePost).toHaveBeenCalledWith(
'/rag/pipelines/pipeline-456/workflows/draft/datasource/nodes/node-789/run',
expect.any(Object),
expect.any(Object),
)
})
})
it('should handle onDataSourceNodeProcessing callback correctly', async () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
mockStoreState.step = CrawlStep.running
mockSsePost.mockImplementation((url, options, callbacks) => {
callbacks.onDataSourceNodeProcessing({
total: 100,
completed: 50,
})
})
const props = createDefaultProps()
const { rerender } = render(<WebsiteCrawl {...props} />)
// Act
fireEvent.click(screen.getByTestId('options-submit-btn'))
// Update store state to simulate running step
mockStoreState.step = CrawlStep.running
rerender(<WebsiteCrawl {...props} />)
// Assert
await waitFor(() => {
expect(mockSsePost).toHaveBeenCalled()
})
})
it('should handle onDataSourceNodeCompleted callback correctly', async () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
const mockCrawlData: CrawlResultItem[] = [
createMockCrawlResultItem({ source_url: 'https://example.com/1' }),
createMockCrawlResultItem({ source_url: 'https://example.com/2' }),
]
mockSsePost.mockImplementation((url, options, callbacks) => {
callbacks.onDataSourceNodeCompleted({
data: mockCrawlData,
time_consuming: 3.5,
})
})
const props = createDefaultProps()
render(<WebsiteCrawl {...props} />)
// Act
fireEvent.click(screen.getByTestId('options-submit-btn'))
// Assert
await waitFor(() => {
expect(mockStoreState.setCrawlResult).toHaveBeenCalledWith({
data: mockCrawlData,
time_consuming: 3.5,
})
expect(mockStoreState.setWebsitePages).toHaveBeenCalledWith(mockCrawlData)
expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.finished)
})
})
it('should handle onDataSourceNodeCompleted with single result when supportBatchUpload is false', async () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
const mockCrawlData: CrawlResultItem[] = [
createMockCrawlResultItem({ source_url: 'https://example.com/1' }),
createMockCrawlResultItem({ source_url: 'https://example.com/2' }),
createMockCrawlResultItem({ source_url: 'https://example.com/3' }),
]
mockSsePost.mockImplementation((url, options, callbacks) => {
callbacks.onDataSourceNodeCompleted({
data: mockCrawlData,
time_consuming: 3.5,
})
})
const props = createDefaultProps({ supportBatchUpload: false })
render(<WebsiteCrawl {...props} />)
// Act
fireEvent.click(screen.getByTestId('options-submit-btn'))
// Assert
await waitFor(() => {
// Should only select first item when supportBatchUpload is false
expect(mockStoreState.setWebsitePages).toHaveBeenCalledWith([mockCrawlData[0]])
})
})
it('should handle onDataSourceNodeError callback correctly', async () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
mockSsePost.mockImplementation((url, options, callbacks) => {
callbacks.onDataSourceNodeError({
error: 'Crawl failed: Invalid URL',
})
})
const props = createDefaultProps()
render(<WebsiteCrawl {...props} />)
// Act
fireEvent.click(screen.getByTestId('options-submit-btn'))
// Assert
await waitFor(() => {
expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.finished)
})
})
it('should use useGetDataSourceAuth with correct parameters', () => {
// Arrange
const nodeData = createMockNodeData({
plugin_id: 'website-plugin',
provider_name: 'website-provider',
})
const props = createDefaultProps({ nodeData })
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(mockUseGetDataSourceAuth).toHaveBeenCalledWith({
pluginId: 'website-plugin',
provider: 'website-provider',
})
})
it('should pass credentials from useGetDataSourceAuth to Header', () => {
// Arrange
const mockCredentials = [
createMockCredential({ id: 'cred-1', name: 'Credential 1' }),
createMockCredential({ id: 'cred-2', name: 'Credential 2' }),
]
mockUseGetDataSourceAuth.mockReturnValue({
data: { result: mockCredentials },
})
const props = createDefaultProps()
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('2')
})
})
// ==========================================
// Edge Cases and Error Handling
// ==========================================
describe('Edge Cases and Error Handling', () => {
it('should handle empty credentials array', () => {
// Arrange
mockUseGetDataSourceAuth.mockReturnValue({
data: { result: [] },
})
const props = createDefaultProps()
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0')
})
it('should handle undefined dataSourceAuth result', () => {
// Arrange
mockUseGetDataSourceAuth.mockReturnValue({
data: { result: undefined },
})
const props = createDefaultProps()
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0')
})
it('should handle null dataSourceAuth data', () => {
// Arrange
mockUseGetDataSourceAuth.mockReturnValue({
data: null,
})
const props = createDefaultProps()
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0')
})
it('should handle empty crawlResult data array', () => {
// Arrange
mockStoreState.step = CrawlStep.finished
mockStoreState.crawlResult = {
data: [],
time_consuming: 0.5,
}
const props = createDefaultProps()
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(screen.getByTestId('crawled-result-count')).toHaveTextContent('0')
})
it('should handle undefined crawlResult', () => {
// Arrange
mockStoreState.step = CrawlStep.finished
mockStoreState.crawlResult = undefined
const props = createDefaultProps()
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(screen.getByTestId('crawled-result-count')).toHaveTextContent('0')
})
it('should handle time_consuming as string', () => {
// Arrange
mockStoreState.step = CrawlStep.finished
mockStoreState.crawlResult = {
data: [createMockCrawlResultItem()],
time_consuming: '2.5',
}
const props = createDefaultProps()
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(screen.getByTestId('crawled-result-used-time')).toHaveTextContent('2.5')
})
it('should handle invalid time_consuming value', () => {
// Arrange
mockStoreState.step = CrawlStep.finished
mockStoreState.crawlResult = {
data: [createMockCrawlResultItem()],
time_consuming: 'invalid',
}
const props = createDefaultProps()
// Act
render(<WebsiteCrawl {...props} />)
// Assert - NaN should become 0
expect(screen.getByTestId('crawled-result-used-time')).toHaveTextContent('0')
})
it('should handle undefined pipelineId gracefully', () => {
// Arrange
mockPipelineId = undefined
const props = createDefaultProps()
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(mockUsePublishedPipelinePreProcessingParams).toHaveBeenCalledWith(
{ pipeline_id: undefined, node_id: 'node-1' },
false, // enabled should be false when pipelineId is undefined
)
})
it('should handle empty nodeId gracefully', () => {
// Arrange
const props = createDefaultProps({ nodeId: '' })
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(mockUsePublishedPipelinePreProcessingParams).toHaveBeenCalledWith(
{ pipeline_id: 'pipeline-123', node_id: '' },
false, // enabled should be false when nodeId is empty
)
})
it('should handle undefined paramsConfig.variables (fallback to empty array)', () => {
// Arrange - Test the || [] fallback on line 169
mockUsePublishedPipelinePreProcessingParams.mockReturnValue({
data: { variables: undefined },
isFetching: false,
})
const props = createDefaultProps()
// Act
render(<WebsiteCrawl {...props} />)
// Assert - Options should receive empty array as variables
expect(screen.getByTestId('options-variables-count')).toHaveTextContent('0')
})
it('should handle undefined paramsConfig (fallback to empty array)', () => {
// Arrange - Test when paramsConfig is undefined
mockUsePublishedPipelinePreProcessingParams.mockReturnValue({
data: undefined,
isFetching: false,
})
const props = createDefaultProps()
// Act
render(<WebsiteCrawl {...props} />)
// Assert - Options should receive empty array as variables
expect(screen.getByTestId('options-variables-count')).toHaveTextContent('0')
})
it('should handle error without error message', async () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
mockSsePost.mockImplementation((url, options, callbacks) => {
callbacks.onDataSourceNodeError({
error: undefined,
})
})
const props = createDefaultProps()
render(<WebsiteCrawl {...props} />)
// Act
fireEvent.click(screen.getByTestId('options-submit-btn'))
// Assert - Should use fallback error message
await waitFor(() => {
expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.finished)
})
})
it('should handle null total and completed in processing callback', async () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
mockSsePost.mockImplementation((url, options, callbacks) => {
callbacks.onDataSourceNodeProcessing({
total: null,
completed: null,
})
})
const props = createDefaultProps()
render(<WebsiteCrawl {...props} />)
// Act
fireEvent.click(screen.getByTestId('options-submit-btn'))
// Assert - Should handle null values gracefully (default to 0)
await waitFor(() => {
expect(mockSsePost).toHaveBeenCalled()
})
})
it('should handle undefined time_consuming in completed callback', async () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
mockSsePost.mockImplementation((url, options, callbacks) => {
callbacks.onDataSourceNodeCompleted({
data: [createMockCrawlResultItem()],
time_consuming: undefined,
})
})
const props = createDefaultProps()
render(<WebsiteCrawl {...props} />)
// Act
fireEvent.click(screen.getByTestId('options-submit-btn'))
// Assert
await waitFor(() => {
expect(mockStoreState.setCrawlResult).toHaveBeenCalledWith({
data: [expect.any(Object)],
time_consuming: 0,
})
})
})
})
// ==========================================
// All Prop Variations
// ==========================================
describe('Prop Variations', () => {
it.each([
[{ isInPipeline: true, supportBatchUpload: true }],
[{ isInPipeline: true, supportBatchUpload: false }],
[{ isInPipeline: false, supportBatchUpload: true }],
[{ isInPipeline: false, supportBatchUpload: false }],
])('should render correctly with props %o', (propVariation) => {
// Arrange
mockStoreState.step = CrawlStep.finished
mockStoreState.crawlResult = {
data: [createMockCrawlResultItem()],
time_consuming: 1.5,
}
const props = createDefaultProps(propVariation)
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(screen.getByTestId('crawled-result')).toBeInTheDocument()
expect(screen.getByTestId('crawled-result-show-preview')).toHaveTextContent(
String(!propVariation.isInPipeline),
)
expect(screen.getByTestId('crawled-result-multiple-choice')).toHaveTextContent(
String(propVariation.supportBatchUpload),
)
})
it('should use default values for optional props', () => {
// Arrange
mockStoreState.step = CrawlStep.finished
mockStoreState.crawlResult = {
data: [createMockCrawlResultItem()],
time_consuming: 1.5,
}
const props: WebsiteCrawlProps = {
nodeId: 'node-1',
nodeData: createMockNodeData(),
onCredentialChange: jest.fn(),
// isInPipeline and supportBatchUpload are not provided
}
// Act
render(<WebsiteCrawl {...props} />)
// Assert - Default values: isInPipeline = false, supportBatchUpload = true
expect(screen.getByTestId('crawled-result-show-preview')).toHaveTextContent('true')
expect(screen.getByTestId('crawled-result-multiple-choice')).toHaveTextContent('true')
})
})
// ==========================================
// Error Display
// ==========================================
describe('Error Display', () => {
it('should show ErrorMessage when crawl finishes with error', async () => {
// Arrange - Need to create a scenario where error message is set
mockStoreState.currentCredentialId = 'cred-1'
// First render with init state
const props = createDefaultProps()
const { rerender } = render(<WebsiteCrawl {...props} />)
// Simulate error by setting up ssePost to call error callback
mockSsePost.mockImplementation((url, options, callbacks) => {
callbacks.onDataSourceNodeError({
error: 'Network error',
})
})
// Trigger submit
fireEvent.click(screen.getByTestId('options-submit-btn'))
// Now update store state to finished to simulate the state after error
mockStoreState.step = CrawlStep.finished
rerender(<WebsiteCrawl {...props} />)
// Assert - The component should check for error message state
await waitFor(() => {
expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.finished)
})
})
it('should not show ErrorMessage when crawl finishes without error', () => {
// Arrange
mockStoreState.step = CrawlStep.finished
mockStoreState.crawlResult = {
data: [createMockCrawlResultItem()],
time_consuming: 1.5,
}
const props = createDefaultProps()
// Act
render(<WebsiteCrawl {...props} />)
// Assert
expect(screen.queryByTestId('error-message')).not.toBeInTheDocument()
expect(screen.getByTestId('crawled-result')).toBeInTheDocument()
})
})
// ==========================================
// Integration Tests
// ==========================================
describe('Integration', () => {
it('should complete full workflow: submit -> running -> completed', async () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
const mockCrawlData: CrawlResultItem[] = [
createMockCrawlResultItem({ source_url: 'https://example.com/1' }),
createMockCrawlResultItem({ source_url: 'https://example.com/2' }),
]
mockSsePost.mockImplementation((url, options, callbacks) => {
// Simulate processing
callbacks.onDataSourceNodeProcessing({
total: 10,
completed: 5,
})
// Simulate completion
callbacks.onDataSourceNodeCompleted({
data: mockCrawlData,
time_consuming: 2.5,
})
})
const props = createDefaultProps()
render(<WebsiteCrawl {...props} />)
// Act - Trigger submit
fireEvent.click(screen.getByTestId('options-submit-btn'))
// Assert - Verify full flow
await waitFor(() => {
// Step should be set to running first
expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.running)
// Then result should be set
expect(mockStoreState.setCrawlResult).toHaveBeenCalledWith({
data: mockCrawlData,
time_consuming: 2.5,
})
// Pages should be selected
expect(mockStoreState.setWebsitePages).toHaveBeenCalledWith(mockCrawlData)
// Step should be set to finished
expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.finished)
})
})
it('should handle error flow correctly', async () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
mockSsePost.mockImplementation((url, options, callbacks) => {
callbacks.onDataSourceNodeError({
error: 'Failed to crawl website',
})
})
const props = createDefaultProps()
render(<WebsiteCrawl {...props} />)
// Act
fireEvent.click(screen.getByTestId('options-submit-btn'))
// Assert
await waitFor(() => {
expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.running)
expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.finished)
})
})
it('should handle credential change and allow new crawl', () => {
// Arrange
mockStoreState.currentCredentialId = 'initial-cred'
const mockOnCredentialChange = jest.fn()
const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange })
// Act
render(<WebsiteCrawl {...props} />)
// Change credential
fireEvent.click(screen.getByTestId('header-credential-change'))
// Assert
expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id')
})
it('should handle preview selection after crawl completes', () => {
// Arrange
mockStoreState.step = CrawlStep.finished
mockStoreState.crawlResult = {
data: [
createMockCrawlResultItem({ source_url: 'https://example.com/1' }),
createMockCrawlResultItem({ source_url: 'https://example.com/2' }),
],
time_consuming: 1.5,
}
const props = createDefaultProps()
render(<WebsiteCrawl {...props} />)
// Act - Preview first item
fireEvent.click(screen.getByTestId('crawled-result-preview'))
// Assert
expect(mockStoreState.setCurrentWebsite).toHaveBeenCalled()
expect(mockStoreState.setPreviewIndex).toHaveBeenCalledWith(0)
})
})
// ==========================================
// Component Memoization
// ==========================================
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
// Arrange
const props = createDefaultProps()
// Act
const { rerender } = render(<WebsiteCrawl {...props} />)
rerender(<WebsiteCrawl {...props} />)
// Assert - Component should still render correctly after rerender
expect(screen.getByTestId('header')).toBeInTheDocument()
expect(screen.getByTestId('options')).toBeInTheDocument()
})
it('should not re-run callbacks when props are the same', () => {
// Arrange
const onCredentialChange = jest.fn()
const props = createDefaultProps({ onCredentialChange })
// Act
const { rerender } = render(<WebsiteCrawl {...props} />)
rerender(<WebsiteCrawl {...props} />)
// Assert - The callback reference should be stable
fireEvent.click(screen.getByTestId('header-credential-change'))
expect(onCredentialChange).toHaveBeenCalledTimes(1)
})
})
// ==========================================
// Styling
// ==========================================
describe('Styling', () => {
it('should apply correct container classes', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<WebsiteCrawl {...props} />)
// Assert
const rootDiv = container.firstChild as HTMLElement
expect(rootDiv).toHaveClass('flex', 'flex-col')
})
it('should apply correct classes to options container', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<WebsiteCrawl {...props} />)
// Assert
const optionsContainer = container.querySelector('.rounded-xl')
expect(optionsContainer).toBeInTheDocument()
})
})
})