test: add integration tests for dataset settings, metadata management, and pipeline data source flows

- Introduced new test files for Dataset Settings Flow, Metadata Management Flow, and Pipeline Data Source Store Composition.
- Enhanced test coverage by validating cross-module interactions, data contracts, and state management across various components.
- Ensured proper handling of user interactions and configuration cascades in the dataset settings and metadata management processes.

These additions improve the reliability and maintainability of dataset-related features.
This commit is contained in:
CodingOnStar
2026-02-10 20:12:50 +08:00
parent 4655a6a244
commit 59f3acb021
20 changed files with 3719 additions and 1 deletions

View File

@ -0,0 +1,451 @@
/**
* Integration Test: Dataset Settings Flow
*
* Tests cross-module data contracts in the dataset settings form:
* useFormState hook ↔ index method config ↔ retrieval config ↔ permission state.
*
* The unit-level use-form-state.spec.ts validates the hook in isolation.
* This integration test verifies that changing one configuration dimension
* correctly cascades to dependent parts (index method → retrieval config,
* permission → member list visibility, embedding model → embedding available state).
*/
import type { DataSet } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { act, renderHook, waitFor } from '@testing-library/react'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType, WeightedScoreEnum } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
// --- Mocks ---
const mockMutateDatasets = vi.fn()
const mockInvalidDatasetList = vi.fn()
const mockUpdateDatasetSetting = vi.fn().mockResolvedValue({})
vi.mock('@/context/app-context', () => ({
useSelector: () => false,
}))
vi.mock('@/service/datasets', () => ({
updateDatasetSetting: (...args: unknown[]) => mockUpdateDatasetSetting(...args),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{ id: 'user-1', name: 'Alice', email: 'alice@example.com', role: 'owner', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
{ id: 'user-2', name: 'Bob', email: 'bob@example.com', role: 'admin', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
{ id: 'user-3', name: 'Charlie', email: 'charlie@example.com', role: 'normal', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
],
},
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: () => ({ data: [] }),
}))
vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({
isReRankModelSelected: () => true,
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
}))
// --- Dataset factory ---
const createMockDataset = (overrides?: Partial<DataSet>): DataSet => ({
id: 'ds-settings-1',
name: 'Settings Test Dataset',
description: 'Integration test dataset',
permission: DatasetPermission.onlyMe,
icon_info: {
icon_type: 'emoji',
icon: '📙',
icon_background: '#FFF4ED',
icon_url: '',
},
indexing_technique: 'high_quality',
indexing_status: 'completed',
data_source_type: DataSourceType.FILE,
doc_form: ChunkingMode.text,
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
embedding_available: true,
app_count: 2,
document_count: 10,
total_document_count: 10,
word_count: 5000,
provider: 'vendor',
tags: [],
partial_member_list: [],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: {
top_k: 2,
score_threshold: 0.5,
score_threshold_enabled: false,
},
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
} as RetrievalConfig,
retrieval_model: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
} as RetrievalConfig,
built_in_field_enabled: false,
keyword_number: 10,
created_by: 'user-1',
updated_by: 'user-1',
updated_at: Date.now(),
runtime_mode: 'general',
enable_api: true,
is_multimodal: false,
...overrides,
} as DataSet)
let mockDataset: DataSet = createMockDataset()
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (
selector: (state: { dataset: DataSet | null, mutateDatasetRes: () => void }) => unknown,
) => selector({ dataset: mockDataset, mutateDatasetRes: mockMutateDatasets }),
}))
// Import after mocks are registered
const { useFormState } = await import(
'@/app/components/datasets/settings/form/hooks/use-form-state',
)
describe('Dataset Settings Flow - Cross-Module Configuration Cascade', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUpdateDatasetSetting.mockResolvedValue({})
mockDataset = createMockDataset()
})
describe('Form State Initialization from Dataset → Index Method → Retrieval Config Chain', () => {
it('should initialise all form dimensions from a QUALIFIED dataset', () => {
const { result } = renderHook(() => useFormState())
expect(result.current.name).toBe('Settings Test Dataset')
expect(result.current.description).toBe('Integration test dataset')
expect(result.current.indexMethod).toBe('high_quality')
expect(result.current.embeddingModel).toEqual({
provider: 'openai',
model: 'text-embedding-ada-002',
})
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.semantic)
})
it('should initialise from an ECONOMICAL dataset with keyword retrieval', () => {
mockDataset = createMockDataset({
indexing_technique: IndexingType.ECONOMICAL,
embedding_model: '',
embedding_model_provider: '',
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.keywordSearch,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 5,
score_threshold_enabled: false,
score_threshold: 0,
} as RetrievalConfig,
})
const { result } = renderHook(() => useFormState())
expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL)
expect(result.current.embeddingModel).toEqual({ provider: '', model: '' })
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.keywordSearch)
})
})
describe('Index Method Change → Retrieval Config Sync', () => {
it('should allow switching index method from QUALIFIED to ECONOMICAL', () => {
const { result } = renderHook(() => useFormState())
expect(result.current.indexMethod).toBe('high_quality')
act(() => {
result.current.setIndexMethod(IndexingType.ECONOMICAL)
})
expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL)
})
it('should allow updating retrieval config after index method switch', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setIndexMethod(IndexingType.ECONOMICAL)
})
act(() => {
result.current.setRetrievalConfig({
...result.current.retrievalConfig,
search_method: RETRIEVE_METHOD.keywordSearch,
reranking_enable: false,
})
})
expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL)
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.keywordSearch)
expect(result.current.retrievalConfig.reranking_enable).toBe(false)
})
it('should preserve retrieval config when switching back to QUALIFIED', () => {
const { result } = renderHook(() => useFormState())
const originalConfig = { ...result.current.retrievalConfig }
act(() => {
result.current.setIndexMethod(IndexingType.ECONOMICAL)
})
act(() => {
result.current.setIndexMethod(IndexingType.QUALIFIED)
})
expect(result.current.indexMethod).toBe('high_quality')
expect(result.current.retrievalConfig.search_method).toBe(originalConfig.search_method)
})
})
describe('Permission Change → Member List Visibility Logic', () => {
it('should start with onlyMe permission and empty member selection', () => {
const { result } = renderHook(() => useFormState())
expect(result.current.permission).toBe(DatasetPermission.onlyMe)
expect(result.current.selectedMemberIDs).toEqual([])
})
it('should enable member selection when switching to partialMembers', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setPermission(DatasetPermission.partialMembers)
})
expect(result.current.permission).toBe(DatasetPermission.partialMembers)
expect(result.current.memberList).toHaveLength(3)
expect(result.current.memberList.map(m => m.id)).toEqual(['user-1', 'user-2', 'user-3'])
})
it('should persist member selection through permission toggle', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setPermission(DatasetPermission.partialMembers)
result.current.setSelectedMemberIDs(['user-1', 'user-3'])
})
act(() => {
result.current.setPermission(DatasetPermission.allTeamMembers)
})
act(() => {
result.current.setPermission(DatasetPermission.partialMembers)
})
expect(result.current.selectedMemberIDs).toEqual(['user-1', 'user-3'])
})
it('should include partial_member_list in save payload only for partialMembers', async () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setPermission(DatasetPermission.partialMembers)
result.current.setSelectedMemberIDs(['user-2'])
})
await act(async () => {
await result.current.handleSave()
})
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
datasetId: 'ds-settings-1',
body: expect.objectContaining({
permission: DatasetPermission.partialMembers,
partial_member_list: [
expect.objectContaining({ user_id: 'user-2', role: 'admin' }),
],
}),
})
})
it('should not include partial_member_list for allTeamMembers permission', async () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setPermission(DatasetPermission.allTeamMembers)
})
await act(async () => {
await result.current.handleSave()
})
const savedBody = mockUpdateDatasetSetting.mock.calls[0][0].body as Record<string, unknown>
expect(savedBody).not.toHaveProperty('partial_member_list')
})
})
describe('Form Submission Validation → All Fields Together', () => {
it('should reject empty name on save', async () => {
const Toast = await import('@/app/components/base/toast')
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setName('')
})
await act(async () => {
await result.current.handleSave()
})
expect(Toast.default.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
expect(mockUpdateDatasetSetting).not.toHaveBeenCalled()
})
it('should include all configuration dimensions in a successful save', async () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setName('Updated Name')
result.current.setDescription('Updated Description')
result.current.setIndexMethod(IndexingType.ECONOMICAL)
result.current.setKeywordNumber(15)
})
await act(async () => {
await result.current.handleSave()
})
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
datasetId: 'ds-settings-1',
body: expect.objectContaining({
name: 'Updated Name',
description: 'Updated Description',
indexing_technique: 'economy',
keyword_number: 15,
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
}),
})
})
it('should call mutateDatasets and invalidDatasetList after successful save', async () => {
const { result } = renderHook(() => useFormState())
await act(async () => {
await result.current.handleSave()
})
await waitFor(() => {
expect(mockMutateDatasets).toHaveBeenCalled()
expect(mockInvalidDatasetList).toHaveBeenCalled()
})
})
})
describe('Embedding Model Change → Retrieval Config Cascade', () => {
it('should update embedding model independently of retrieval config', () => {
const { result } = renderHook(() => useFormState())
const originalRetrievalConfig = { ...result.current.retrievalConfig }
act(() => {
result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-english-v3.0' })
})
expect(result.current.embeddingModel).toEqual({
provider: 'cohere',
model: 'embed-english-v3.0',
})
expect(result.current.retrievalConfig.search_method).toBe(originalRetrievalConfig.search_method)
})
it('should propagate embedding model into weighted retrieval config on save', async () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-v3' })
result.current.setRetrievalConfig({
...result.current.retrievalConfig,
search_method: RETRIEVE_METHOD.hybrid,
weights: {
weight_type: WeightedScoreEnum.Customized,
vector_setting: {
vector_weight: 0.6,
embedding_provider_name: '',
embedding_model_name: '',
},
keyword_setting: { keyword_weight: 0.4 },
},
})
})
await act(async () => {
await result.current.handleSave()
})
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
datasetId: 'ds-settings-1',
body: expect.objectContaining({
embedding_model: 'embed-v3',
embedding_model_provider: 'cohere',
retrieval_model: expect.objectContaining({
weights: expect.objectContaining({
vector_setting: expect.objectContaining({
embedding_provider_name: 'cohere',
embedding_model_name: 'embed-v3',
}),
}),
}),
}),
})
})
it('should handle switching from semantic to hybrid search with embedding model', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setRetrievalConfig({
...result.current.retrievalConfig,
search_method: RETRIEVE_METHOD.hybrid,
reranking_enable: true,
reranking_model: {
reranking_provider_name: 'cohere',
reranking_model_name: 'rerank-english-v3.0',
},
})
})
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.hybrid)
expect(result.current.retrievalConfig.reranking_enable).toBe(true)
expect(result.current.embeddingModel.model).toBe('text-embedding-ada-002')
})
})
})

View File

@ -0,0 +1,337 @@
/**
* Integration Test: Metadata Management Flow
*
* Tests the cross-module composition of metadata name validation, type constraints,
* and duplicate detection across the metadata management hooks.
*
* The unit-level use-check-metadata-name.spec.ts tests the validation hook alone.
* This integration test verifies:
* - Name validation combined with existing metadata list (duplicate detection)
* - Metadata type enum constraints matching expected data model
* - Full add/rename workflow: validate name → check duplicates → allow or reject
* - Name uniqueness logic: existing metadata keeps its own name, cannot take another's
*/
import type { MetadataItemWithValueLength } from '@/app/components/datasets/metadata/types'
import { renderHook } from '@testing-library/react'
import { DataType } from '@/app/components/datasets/metadata/types'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const { default: useCheckMetadataName } = await import(
'@/app/components/datasets/metadata/hooks/use-check-metadata-name',
)
// --- Factory functions ---
const createMetadataItem = (
id: string,
name: string,
type = DataType.string,
count = 0,
): MetadataItemWithValueLength => ({
id,
name,
type,
count,
})
const createMetadataList = (): MetadataItemWithValueLength[] => [
createMetadataItem('meta-1', 'author', DataType.string, 5),
createMetadataItem('meta-2', 'created_date', DataType.time, 10),
createMetadataItem('meta-3', 'page_count', DataType.number, 3),
createMetadataItem('meta-4', 'source_url', DataType.string, 8),
createMetadataItem('meta-5', 'version', DataType.number, 2),
]
describe('Metadata Management Flow - Cross-Module Validation Composition', () => {
describe('Name Validation Flow: Format Rules', () => {
it('should accept valid lowercase names with underscores', () => {
const { result } = renderHook(() => useCheckMetadataName())
expect(result.current.checkName('valid_name').errorMsg).toBe('')
expect(result.current.checkName('author').errorMsg).toBe('')
expect(result.current.checkName('page_count').errorMsg).toBe('')
expect(result.current.checkName('v2_field').errorMsg).toBe('')
})
it('should reject empty names', () => {
const { result } = renderHook(() => useCheckMetadataName())
expect(result.current.checkName('').errorMsg).toBeTruthy()
})
it('should reject names with invalid characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
expect(result.current.checkName('Author').errorMsg).toBeTruthy()
expect(result.current.checkName('my-field').errorMsg).toBeTruthy()
expect(result.current.checkName('field name').errorMsg).toBeTruthy()
expect(result.current.checkName('1field').errorMsg).toBeTruthy()
expect(result.current.checkName('_private').errorMsg).toBeTruthy()
})
it('should reject names exceeding 255 characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const longName = 'a'.repeat(256)
expect(result.current.checkName(longName).errorMsg).toBeTruthy()
const maxName = 'a'.repeat(255)
expect(result.current.checkName(maxName).errorMsg).toBe('')
})
})
describe('Metadata Type Constraints: Enum Values Match Expected Set', () => {
it('should define exactly three data types', () => {
const typeValues = Object.values(DataType)
expect(typeValues).toHaveLength(3)
})
it('should include string, number, and time types', () => {
expect(DataType.string).toBe('string')
expect(DataType.number).toBe('number')
expect(DataType.time).toBe('time')
})
it('should use consistent types in metadata items', () => {
const metadataList = createMetadataList()
const stringItems = metadataList.filter(m => m.type === DataType.string)
const numberItems = metadataList.filter(m => m.type === DataType.number)
const timeItems = metadataList.filter(m => m.type === DataType.time)
expect(stringItems).toHaveLength(2)
expect(numberItems).toHaveLength(2)
expect(timeItems).toHaveLength(1)
})
it('should enforce type-safe metadata item construction', () => {
const item = createMetadataItem('test-1', 'test_field', DataType.number, 0)
expect(item.id).toBe('test-1')
expect(item.name).toBe('test_field')
expect(item.type).toBe(DataType.number)
expect(item.count).toBe(0)
})
})
describe('Duplicate Name Detection: Add Metadata → Check Name → Detect Duplicates', () => {
it('should detect duplicate names against an existing metadata list', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const checkDuplicate = (newName: string): boolean => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg)
return false
return existingMetadata.some(m => m.name === newName)
}
expect(checkDuplicate('author')).toBe(true)
expect(checkDuplicate('created_date')).toBe(true)
expect(checkDuplicate('page_count')).toBe(true)
})
it('should allow names that do not conflict with existing metadata', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const isNameAvailable = (newName: string): boolean => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg)
return false
return !existingMetadata.some(m => m.name === newName)
}
expect(isNameAvailable('category')).toBe(true)
expect(isNameAvailable('file_size')).toBe(true)
expect(isNameAvailable('language')).toBe(true)
})
it('should reject names that fail format validation before duplicate check', () => {
const { result } = renderHook(() => useCheckMetadataName())
const validateAndCheckDuplicate = (newName: string): { valid: boolean, reason: string } => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg)
return { valid: false, reason: 'format' }
return { valid: true, reason: '' }
}
expect(validateAndCheckDuplicate('Author').reason).toBe('format')
expect(validateAndCheckDuplicate('').reason).toBe('format')
expect(validateAndCheckDuplicate('valid_name').valid).toBe(true)
})
})
describe('Name Uniqueness Across Edits: Rename Workflow', () => {
it('should allow an existing metadata item to keep its own name', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const isRenameValid = (itemId: string, newName: string): boolean => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg)
return false
// Allow keeping the same name (skip self in duplicate check)
return !existingMetadata.some(m => m.name === newName && m.id !== itemId)
}
// Author keeping its own name should be valid
expect(isRenameValid('meta-1', 'author')).toBe(true)
// page_count keeping its own name should be valid
expect(isRenameValid('meta-3', 'page_count')).toBe(true)
})
it('should reject renaming to another existing metadata name', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const isRenameValid = (itemId: string, newName: string): boolean => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg)
return false
return !existingMetadata.some(m => m.name === newName && m.id !== itemId)
}
// Author trying to rename to "page_count" (taken by meta-3)
expect(isRenameValid('meta-1', 'page_count')).toBe(false)
// version trying to rename to "source_url" (taken by meta-4)
expect(isRenameValid('meta-5', 'source_url')).toBe(false)
})
it('should allow renaming to a completely new valid name', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const isRenameValid = (itemId: string, newName: string): boolean => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg)
return false
return !existingMetadata.some(m => m.name === newName && m.id !== itemId)
}
expect(isRenameValid('meta-1', 'document_author')).toBe(true)
expect(isRenameValid('meta-2', 'publish_date')).toBe(true)
expect(isRenameValid('meta-3', 'total_pages')).toBe(true)
})
it('should reject renaming with an invalid format even if name is unique', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const isRenameValid = (itemId: string, newName: string): boolean => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg)
return false
return !existingMetadata.some(m => m.name === newName && m.id !== itemId)
}
expect(isRenameValid('meta-1', 'New Author')).toBe(false)
expect(isRenameValid('meta-2', '2024_date')).toBe(false)
expect(isRenameValid('meta-3', '')).toBe(false)
})
})
describe('Full Metadata Management Workflow', () => {
it('should support a complete add-validate-check-duplicate cycle', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const addMetadataField = (
name: string,
type: DataType,
): { success: boolean, error?: string } => {
const formatCheck = result.current.checkName(name)
if (formatCheck.errorMsg)
return { success: false, error: 'invalid_format' }
if (existingMetadata.some(m => m.name === name))
return { success: false, error: 'duplicate_name' }
existingMetadata.push(createMetadataItem(`meta-${existingMetadata.length + 1}`, name, type))
return { success: true }
}
// Add a valid new field
const result1 = addMetadataField('department', DataType.string)
expect(result1.success).toBe(true)
expect(existingMetadata).toHaveLength(6)
// Try to add a duplicate
const result2 = addMetadataField('author', DataType.string)
expect(result2.success).toBe(false)
expect(result2.error).toBe('duplicate_name')
expect(existingMetadata).toHaveLength(6)
// Try to add an invalid name
const result3 = addMetadataField('Invalid Name', DataType.string)
expect(result3.success).toBe(false)
expect(result3.error).toBe('invalid_format')
expect(existingMetadata).toHaveLength(6)
// Add another valid field
const result4 = addMetadataField('priority_level', DataType.number)
expect(result4.success).toBe(true)
expect(existingMetadata).toHaveLength(7)
})
it('should support a complete rename workflow with validation chain', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const renameMetadataField = (
itemId: string,
newName: string,
): { success: boolean, error?: string } => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg)
return { success: false, error: 'invalid_format' }
if (existingMetadata.some(m => m.name === newName && m.id !== itemId))
return { success: false, error: 'duplicate_name' }
const item = existingMetadata.find(m => m.id === itemId)
if (!item)
return { success: false, error: 'not_found' }
// Simulate the rename in-place
const index = existingMetadata.indexOf(item)
existingMetadata[index] = { ...item, name: newName }
return { success: true }
}
// Rename author to document_author
expect(renameMetadataField('meta-1', 'document_author').success).toBe(true)
expect(existingMetadata.find(m => m.id === 'meta-1')?.name).toBe('document_author')
// Try renaming created_date to page_count (already taken)
expect(renameMetadataField('meta-2', 'page_count').error).toBe('duplicate_name')
// Rename to invalid format
expect(renameMetadataField('meta-3', 'Page Count').error).toBe('invalid_format')
// Rename non-existent item
expect(renameMetadataField('meta-999', 'something').error).toBe('not_found')
})
it('should maintain validation consistency across multiple operations', () => {
const { result } = renderHook(() => useCheckMetadataName())
// Validate the same name multiple times for consistency
const name = 'consistent_field'
const results = Array.from({ length: 5 }, () => result.current.checkName(name))
expect(results.every(r => r.errorMsg === '')).toBe(true)
// Validate an invalid name multiple times
const invalidResults = Array.from({ length: 5 }, () => result.current.checkName('Invalid'))
expect(invalidResults.every(r => r.errorMsg !== '')).toBe(true)
})
})
})

View File

@ -0,0 +1,477 @@
/**
* Integration Test: Pipeline Data Source Store Composition
*
* Tests cross-slice interactions in the pipeline data source Zustand store.
* The unit-level slice specs test each slice in isolation.
* This integration test verifies:
* - Store initialization produces correct defaults across all slices
* - Cross-slice coordination (e.g. credential shared across slices)
* - State isolation: changes in one slice do not affect others
* - Full workflow simulation through credential → source → data path
*/
import type { NotionPage } from '@/models/common'
import type { CrawlResultItem, FileItem } from '@/models/datasets'
import type { OnlineDriveFile } from '@/models/pipeline'
import { createDataSourceStore } from '@/app/components/datasets/documents/create-from-pipeline/data-source/store'
import { CrawlStep } from '@/models/datasets'
import { OnlineDriveFileType } from '@/models/pipeline'
// --- Factory functions ---
const createFileItem = (id: string): FileItem => ({
fileID: id,
file: { id, name: `${id}.txt`, size: 1024 } as FileItem['file'],
progress: 100,
})
const createCrawlResultItem = (url: string, title?: string): CrawlResultItem => ({
title: title ?? `Page: ${url}`,
markdown: `# ${title ?? url}\n\nContent for ${url}`,
description: `Description for ${url}`,
source_url: url,
})
const createOnlineDriveFile = (id: string, name: string, type = OnlineDriveFileType.file): OnlineDriveFile => ({
id,
name,
size: 2048,
type,
})
const createNotionPage = (pageId: string): NotionPage => ({
page_id: pageId,
page_name: `Page ${pageId}`,
page_icon: null,
is_bound: true,
parent_id: 'parent-1',
type: 'page',
workspace_id: 'ws-1',
})
describe('Pipeline Data Source Store Composition - Cross-Slice Integration', () => {
describe('Store Initialization → All Slices Have Correct Defaults', () => {
it('should create a store with all five slices combined', () => {
const store = createDataSourceStore()
const state = store.getState()
// Common slice defaults
expect(state.currentCredentialId).toBe('')
expect(state.currentNodeIdRef.current).toBe('')
// Local file slice defaults
expect(state.localFileList).toEqual([])
expect(state.currentLocalFile).toBeUndefined()
// Online document slice defaults
expect(state.documentsData).toEqual([])
expect(state.onlineDocuments).toEqual([])
expect(state.searchValue).toBe('')
expect(state.selectedPagesId).toEqual(new Set())
// Website crawl slice defaults
expect(state.websitePages).toEqual([])
expect(state.step).toBe(CrawlStep.init)
expect(state.previewIndex).toBe(-1)
// Online drive slice defaults
expect(state.breadcrumbs).toEqual([])
expect(state.prefix).toEqual([])
expect(state.keywords).toBe('')
expect(state.selectedFileIds).toEqual([])
expect(state.onlineDriveFileList).toEqual([])
expect(state.bucket).toBe('')
expect(state.hasBucket).toBe(false)
})
})
describe('Cross-Slice Coordination: Shared Credential', () => {
it('should set credential that is accessible from the common slice', () => {
const store = createDataSourceStore()
store.getState().setCurrentCredentialId('cred-abc-123')
expect(store.getState().currentCredentialId).toBe('cred-abc-123')
})
it('should allow credential update independently of all other slices', () => {
const store = createDataSourceStore()
store.getState().setLocalFileList([createFileItem('f1')])
store.getState().setCurrentCredentialId('cred-xyz')
expect(store.getState().currentCredentialId).toBe('cred-xyz')
expect(store.getState().localFileList).toHaveLength(1)
})
})
describe('Local File Workflow: Set Files → Verify List → Clear', () => {
it('should set and retrieve local file list', () => {
const store = createDataSourceStore()
const files = [createFileItem('f1'), createFileItem('f2'), createFileItem('f3')]
store.getState().setLocalFileList(files)
expect(store.getState().localFileList).toHaveLength(3)
expect(store.getState().localFileList[0].fileID).toBe('f1')
expect(store.getState().localFileList[2].fileID).toBe('f3')
})
it('should update preview ref when setting file list', () => {
const store = createDataSourceStore()
const files = [createFileItem('f-preview')]
store.getState().setLocalFileList(files)
expect(store.getState().previewLocalFileRef.current).toBeDefined()
})
it('should clear files by setting empty list', () => {
const store = createDataSourceStore()
store.getState().setLocalFileList([createFileItem('f1')])
expect(store.getState().localFileList).toHaveLength(1)
store.getState().setLocalFileList([])
expect(store.getState().localFileList).toHaveLength(0)
})
it('should set and clear current local file selection', () => {
const store = createDataSourceStore()
const file = { id: 'current-file', name: 'current.txt' } as FileItem['file']
store.getState().setCurrentLocalFile(file)
expect(store.getState().currentLocalFile).toBeDefined()
expect(store.getState().currentLocalFile?.id).toBe('current-file')
store.getState().setCurrentLocalFile(undefined)
expect(store.getState().currentLocalFile).toBeUndefined()
})
})
describe('Online Document Workflow: Set Documents → Select Pages → Verify', () => {
it('should set documents data and online documents', () => {
const store = createDataSourceStore()
const pages = [createNotionPage('page-1'), createNotionPage('page-2')]
store.getState().setOnlineDocuments(pages)
expect(store.getState().onlineDocuments).toHaveLength(2)
expect(store.getState().onlineDocuments[0].page_id).toBe('page-1')
})
it('should update preview ref when setting online documents', () => {
const store = createDataSourceStore()
const pages = [createNotionPage('page-preview')]
store.getState().setOnlineDocuments(pages)
expect(store.getState().previewOnlineDocumentRef.current).toBeDefined()
expect(store.getState().previewOnlineDocumentRef.current?.page_id).toBe('page-preview')
})
it('should track selected page IDs', () => {
const store = createDataSourceStore()
const pages = [createNotionPage('p1'), createNotionPage('p2'), createNotionPage('p3')]
store.getState().setOnlineDocuments(pages)
store.getState().setSelectedPagesId(new Set(['p1', 'p3']))
expect(store.getState().selectedPagesId.size).toBe(2)
expect(store.getState().selectedPagesId.has('p1')).toBe(true)
expect(store.getState().selectedPagesId.has('p2')).toBe(false)
expect(store.getState().selectedPagesId.has('p3')).toBe(true)
})
it('should manage search value for filtering documents', () => {
const store = createDataSourceStore()
store.getState().setSearchValue('meeting notes')
expect(store.getState().searchValue).toBe('meeting notes')
})
it('should set and clear current document selection', () => {
const store = createDataSourceStore()
const page = createNotionPage('current-page')
store.getState().setCurrentDocument(page)
expect(store.getState().currentDocument?.page_id).toBe('current-page')
store.getState().setCurrentDocument(undefined)
expect(store.getState().currentDocument).toBeUndefined()
})
})
describe('Website Crawl Workflow: Set Pages → Track Step → Preview', () => {
it('should set website pages and update preview ref', () => {
const store = createDataSourceStore()
const pages = [
createCrawlResultItem('https://example.com'),
createCrawlResultItem('https://example.com/about'),
]
store.getState().setWebsitePages(pages)
expect(store.getState().websitePages).toHaveLength(2)
expect(store.getState().previewWebsitePageRef.current?.source_url).toBe('https://example.com')
})
it('should manage crawl step transitions', () => {
const store = createDataSourceStore()
expect(store.getState().step).toBe(CrawlStep.init)
store.getState().setStep(CrawlStep.running)
expect(store.getState().step).toBe(CrawlStep.running)
store.getState().setStep(CrawlStep.finished)
expect(store.getState().step).toBe(CrawlStep.finished)
})
it('should set crawl result with data and timing', () => {
const store = createDataSourceStore()
const result = {
data: [createCrawlResultItem('https://test.com')],
time_consuming: 3.5,
}
store.getState().setCrawlResult(result)
expect(store.getState().crawlResult?.data).toHaveLength(1)
expect(store.getState().crawlResult?.time_consuming).toBe(3.5)
})
it('should manage preview index for page navigation', () => {
const store = createDataSourceStore()
store.getState().setPreviewIndex(2)
expect(store.getState().previewIndex).toBe(2)
store.getState().setPreviewIndex(-1)
expect(store.getState().previewIndex).toBe(-1)
})
it('should set and clear current website selection', () => {
const store = createDataSourceStore()
const page = createCrawlResultItem('https://current.com')
store.getState().setCurrentWebsite(page)
expect(store.getState().currentWebsite?.source_url).toBe('https://current.com')
store.getState().setCurrentWebsite(undefined)
expect(store.getState().currentWebsite).toBeUndefined()
})
})
describe('Online Drive Workflow: Breadcrumbs → File Selection → Navigation', () => {
it('should manage breadcrumb navigation', () => {
const store = createDataSourceStore()
store.getState().setBreadcrumbs(['root', 'folder-a', 'subfolder'])
expect(store.getState().breadcrumbs).toEqual(['root', 'folder-a', 'subfolder'])
})
it('should support breadcrumb push/pop pattern', () => {
const store = createDataSourceStore()
store.getState().setBreadcrumbs(['root'])
store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'level-1'])
store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'level-2'])
expect(store.getState().breadcrumbs).toEqual(['root', 'level-1', 'level-2'])
// Pop back one level
store.getState().setBreadcrumbs(store.getState().breadcrumbs.slice(0, -1))
expect(store.getState().breadcrumbs).toEqual(['root', 'level-1'])
})
it('should manage file list and selection', () => {
const store = createDataSourceStore()
const files = [
createOnlineDriveFile('drive-1', 'report.pdf'),
createOnlineDriveFile('drive-2', 'data.csv'),
createOnlineDriveFile('drive-3', 'images', OnlineDriveFileType.folder),
]
store.getState().setOnlineDriveFileList(files)
expect(store.getState().onlineDriveFileList).toHaveLength(3)
store.getState().setSelectedFileIds(['drive-1', 'drive-2'])
expect(store.getState().selectedFileIds).toEqual(['drive-1', 'drive-2'])
})
it('should update preview ref when selecting files', () => {
const store = createDataSourceStore()
const files = [
createOnlineDriveFile('drive-a', 'file-a.txt'),
createOnlineDriveFile('drive-b', 'file-b.txt'),
]
store.getState().setOnlineDriveFileList(files)
store.getState().setSelectedFileIds(['drive-b'])
expect(store.getState().previewOnlineDriveFileRef.current?.id).toBe('drive-b')
})
it('should manage bucket and prefix for S3-like navigation', () => {
const store = createDataSourceStore()
store.getState().setBucket('my-data-bucket')
store.getState().setPrefix(['data', '2024'])
store.getState().setHasBucket(true)
expect(store.getState().bucket).toBe('my-data-bucket')
expect(store.getState().prefix).toEqual(['data', '2024'])
expect(store.getState().hasBucket).toBe(true)
})
it('should manage keywords for search filtering', () => {
const store = createDataSourceStore()
store.getState().setKeywords('quarterly report')
expect(store.getState().keywords).toBe('quarterly report')
})
})
describe('State Isolation: Changes to One Slice Do Not Affect Others', () => {
it('should keep local file state independent from online document state', () => {
const store = createDataSourceStore()
store.getState().setLocalFileList([createFileItem('local-1')])
store.getState().setOnlineDocuments([createNotionPage('notion-1')])
expect(store.getState().localFileList).toHaveLength(1)
expect(store.getState().onlineDocuments).toHaveLength(1)
// Clearing local files should not affect online documents
store.getState().setLocalFileList([])
expect(store.getState().localFileList).toHaveLength(0)
expect(store.getState().onlineDocuments).toHaveLength(1)
})
it('should keep website crawl state independent from online drive state', () => {
const store = createDataSourceStore()
store.getState().setWebsitePages([createCrawlResultItem('https://site.com')])
store.getState().setOnlineDriveFileList([createOnlineDriveFile('d1', 'file.txt')])
expect(store.getState().websitePages).toHaveLength(1)
expect(store.getState().onlineDriveFileList).toHaveLength(1)
// Clearing website pages should not affect drive files
store.getState().setWebsitePages([])
expect(store.getState().websitePages).toHaveLength(0)
expect(store.getState().onlineDriveFileList).toHaveLength(1)
})
it('should create fully independent store instances', () => {
const storeA = createDataSourceStore()
const storeB = createDataSourceStore()
storeA.getState().setCurrentCredentialId('cred-A')
storeA.getState().setLocalFileList([createFileItem('fa-1')])
expect(storeA.getState().currentCredentialId).toBe('cred-A')
expect(storeB.getState().currentCredentialId).toBe('')
expect(storeB.getState().localFileList).toEqual([])
})
})
describe('Full Workflow Simulation: Credential → Source → Data → Verify', () => {
it('should support a complete local file upload workflow', () => {
const store = createDataSourceStore()
// Step 1: Set credential
store.getState().setCurrentCredentialId('upload-cred-1')
// Step 2: Set file list
const files = [createFileItem('upload-1'), createFileItem('upload-2')]
store.getState().setLocalFileList(files)
// Step 3: Select current file for preview
store.getState().setCurrentLocalFile(files[0].file)
// Verify all state is consistent
expect(store.getState().currentCredentialId).toBe('upload-cred-1')
expect(store.getState().localFileList).toHaveLength(2)
expect(store.getState().currentLocalFile?.id).toBe('upload-1')
expect(store.getState().previewLocalFileRef.current).toBeDefined()
})
it('should support a complete website crawl workflow', () => {
const store = createDataSourceStore()
// Step 1: Set credential
store.getState().setCurrentCredentialId('crawl-cred-1')
// Step 2: Init crawl
store.getState().setStep(CrawlStep.running)
// Step 3: Crawl completes with results
const crawledPages = [
createCrawlResultItem('https://docs.example.com/guide'),
createCrawlResultItem('https://docs.example.com/api'),
createCrawlResultItem('https://docs.example.com/faq'),
]
store.getState().setCrawlResult({ data: crawledPages, time_consuming: 12.5 })
store.getState().setStep(CrawlStep.finished)
// Step 4: Set website pages from results
store.getState().setWebsitePages(crawledPages)
// Step 5: Set preview
store.getState().setPreviewIndex(1)
// Verify all state
expect(store.getState().currentCredentialId).toBe('crawl-cred-1')
expect(store.getState().step).toBe(CrawlStep.finished)
expect(store.getState().websitePages).toHaveLength(3)
expect(store.getState().crawlResult?.time_consuming).toBe(12.5)
expect(store.getState().previewIndex).toBe(1)
expect(store.getState().previewWebsitePageRef.current?.source_url).toBe('https://docs.example.com/guide')
})
it('should support a complete online drive navigation workflow', () => {
const store = createDataSourceStore()
// Step 1: Set credential
store.getState().setCurrentCredentialId('drive-cred-1')
// Step 2: Set bucket
store.getState().setBucket('company-docs')
store.getState().setHasBucket(true)
// Step 3: Navigate into folders
store.getState().setBreadcrumbs(['company-docs'])
store.getState().setPrefix(['projects'])
const folderFiles = [
createOnlineDriveFile('proj-1', 'project-alpha', OnlineDriveFileType.folder),
createOnlineDriveFile('proj-2', 'project-beta', OnlineDriveFileType.folder),
createOnlineDriveFile('readme', 'README.md', OnlineDriveFileType.file),
]
store.getState().setOnlineDriveFileList(folderFiles)
// Step 4: Navigate deeper
store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'project-alpha'])
store.getState().setPrefix([...store.getState().prefix, 'project-alpha'])
// Step 5: Select files
store.getState().setOnlineDriveFileList([
createOnlineDriveFile('doc-1', 'spec.pdf'),
createOnlineDriveFile('doc-2', 'design.fig'),
])
store.getState().setSelectedFileIds(['doc-1'])
// Verify full state
expect(store.getState().currentCredentialId).toBe('drive-cred-1')
expect(store.getState().bucket).toBe('company-docs')
expect(store.getState().breadcrumbs).toEqual(['company-docs', 'project-alpha'])
expect(store.getState().prefix).toEqual(['projects', 'project-alpha'])
expect(store.getState().onlineDriveFileList).toHaveLength(2)
expect(store.getState().selectedFileIds).toEqual(['doc-1'])
expect(store.getState().previewOnlineDriveFileRef.current?.name).toBe('spec.pdf')
})
})
})

View File

@ -10,7 +10,7 @@ import type {
Rules,
} from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { act, fireEvent, render, renderHook, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'
import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { ChunkingMode, DataSourceType, ProcessMode } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
@ -2210,6 +2210,10 @@ describe('StepTwo Component', () => {
mockCurrentDataset = null
})
afterEach(() => {
cleanup()
})
const defaultStepTwoProps = {
dataSourceType: DataSourceType.FILE,
files: [createMockFile()],

View File

@ -0,0 +1,313 @@
import type { CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CrawledResult from './crawled-result'
vi.mock('./checkbox-with-label', () => ({
default: ({ isChecked, onChange, label, testId }: {
isChecked: boolean
onChange: (checked: boolean) => void
label: string
testId?: string
}) => (
<label data-testid={testId}>
<input
type="checkbox"
checked={isChecked}
onChange={() => onChange(!isChecked)}
data-testid={`checkbox-${testId}`}
/>
<span>{label}</span>
</label>
),
}))
vi.mock('./crawled-result-item', () => ({
default: ({ payload, isChecked, isPreview, onCheckChange, onPreview, testId }: {
payload: CrawlResultItem
isChecked: boolean
isPreview: boolean
onCheckChange: (checked: boolean) => void
onPreview: () => void
testId?: string
}) => (
<div data-testid={testId} data-preview={isPreview}>
<input
type="checkbox"
checked={isChecked}
onChange={() => onCheckChange(!isChecked)}
data-testid={`check-${testId}`}
/>
<span>{payload.title}</span>
<span>{payload.source_url}</span>
<button onClick={onPreview} data-testid={`preview-${testId}`}>Preview</button>
</div>
),
}))
const createMockItem = (overrides: Partial<CrawlResultItem> = {}): CrawlResultItem => ({
title: 'Test Page',
markdown: '# Test',
description: 'A test page',
source_url: 'https://example.com',
...overrides,
})
const createMockList = (): CrawlResultItem[] => [
createMockItem({ title: 'Page 1', source_url: 'https://example.com/1' }),
createMockItem({ title: 'Page 2', source_url: 'https://example.com/2' }),
createMockItem({ title: 'Page 3', source_url: 'https://example.com/3' }),
]
describe('CrawledResult', () => {
const mockOnSelectedChange = vi.fn()
const mockOnPreview = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render select all checkbox', () => {
const list = createMockList()
render(
<CrawledResult
list={list}
checkedList={[]}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={1.5}
/>,
)
expect(screen.getByTestId('select-all')).toBeInTheDocument()
})
it('should render all items from list', () => {
const list = createMockList()
render(
<CrawledResult
list={list}
checkedList={[]}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={1.5}
/>,
)
expect(screen.getByTestId('item-0')).toBeInTheDocument()
expect(screen.getByTestId('item-1')).toBeInTheDocument()
expect(screen.getByTestId('item-2')).toBeInTheDocument()
})
it('should render scrap time info', () => {
const list = createMockList()
render(
<CrawledResult
list={list}
checkedList={[]}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={1.5}
/>,
)
expect(screen.getByText(/scrapTimeInfo/i)).toBeInTheDocument()
})
it('should apply custom className', () => {
const list = createMockList()
const { container } = render(
<CrawledResult
className="custom-class"
list={list}
checkedList={[]}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={1.5}
/>,
)
const rootElement = container.firstChild as HTMLElement
expect(rootElement).toHaveClass('custom-class')
})
})
describe('Select All', () => {
it('should call onSelectedChange with full list when not all checked', () => {
const list = createMockList()
render(
<CrawledResult
list={list}
checkedList={[list[0]]}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={1.5}
/>,
)
const selectAllCheckbox = screen.getByTestId('checkbox-select-all')
fireEvent.click(selectAllCheckbox)
expect(mockOnSelectedChange).toHaveBeenCalledWith(list)
})
it('should call onSelectedChange with empty array when all checked', () => {
const list = createMockList()
render(
<CrawledResult
list={list}
checkedList={list}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={1.5}
/>,
)
const selectAllCheckbox = screen.getByTestId('checkbox-select-all')
fireEvent.click(selectAllCheckbox)
expect(mockOnSelectedChange).toHaveBeenCalledWith([])
})
it('should show selectAll label when not all checked', () => {
const list = createMockList()
render(
<CrawledResult
list={list}
checkedList={[list[0]]}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={1.5}
/>,
)
expect(screen.getByText(/selectAll/i)).toBeInTheDocument()
})
it('should show resetAll label when all checked', () => {
const list = createMockList()
render(
<CrawledResult
list={list}
checkedList={list}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={1.5}
/>,
)
expect(screen.getByText(/resetAll/i)).toBeInTheDocument()
})
})
describe('Individual Item Check', () => {
it('should call onSelectedChange with added item when checking', () => {
const list = createMockList()
const checkedList = [list[0]]
render(
<CrawledResult
list={list}
checkedList={checkedList}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={1.5}
/>,
)
const item1Checkbox = screen.getByTestId('check-item-1')
fireEvent.click(item1Checkbox)
expect(mockOnSelectedChange).toHaveBeenCalledWith([list[0], list[1]])
})
it('should call onSelectedChange with removed item when unchecking', () => {
const list = createMockList()
const checkedList = [list[0], list[1]]
render(
<CrawledResult
list={list}
checkedList={checkedList}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={1.5}
/>,
)
const item0Checkbox = screen.getByTestId('check-item-0')
fireEvent.click(item0Checkbox)
expect(mockOnSelectedChange).toHaveBeenCalledWith([list[1]])
})
})
describe('Preview', () => {
it('should call onPreview with correct item when preview clicked', () => {
const list = createMockList()
render(
<CrawledResult
list={list}
checkedList={[]}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={1.5}
/>,
)
const previewButton = screen.getByTestId('preview-item-1')
fireEvent.click(previewButton)
expect(mockOnPreview).toHaveBeenCalledWith(list[1])
})
it('should update preview state when preview button is clicked', () => {
const list = createMockList()
render(
<CrawledResult
list={list}
checkedList={[]}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={1.5}
/>,
)
const previewButton = screen.getByTestId('preview-item-0')
fireEvent.click(previewButton)
const item0 = screen.getByTestId('item-0')
expect(item0).toHaveAttribute('data-preview', 'true')
})
})
describe('Edge Cases', () => {
it('should render empty list without crashing', () => {
render(
<CrawledResult
list={[]}
checkedList={[]}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={0}
/>,
)
expect(screen.getByTestId('select-all')).toBeInTheDocument()
})
it('should handle single item list', () => {
const list = [createMockItem()]
render(
<CrawledResult
list={list}
checkedList={[]}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={0.5}
/>,
)
expect(screen.getByTestId('item-0')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,286 @@
import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types'
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
import Website from './index'
const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
vi.mock('./index.module.css', () => ({
default: {
jinaLogo: 'jina-logo',
watercrawlLogo: 'watercrawl-logo',
},
}))
vi.mock('./firecrawl', () => ({
default: (props: Record<string, unknown>) => <div data-testid="firecrawl-component" data-props={JSON.stringify(props)} />,
}))
vi.mock('./jina-reader', () => ({
default: (props: Record<string, unknown>) => <div data-testid="jina-reader-component" data-props={JSON.stringify(props)} />,
}))
vi.mock('./watercrawl', () => ({
default: (props: Record<string, unknown>) => <div data-testid="watercrawl-component" data-props={JSON.stringify(props)} />,
}))
vi.mock('./no-data', () => ({
default: ({ onConfig, provider }: { onConfig: () => void, provider: string }) => (
<div data-testid="no-data-component" data-provider={provider}>
<button onClick={onConfig} data-testid="no-data-config-button">Configure</button>
</div>
),
}))
let mockEnableJinaReader = true
let mockEnableFirecrawl = true
let mockEnableWatercrawl = true
vi.mock('@/config', () => ({
get ENABLE_WEBSITE_JINAREADER() { return mockEnableJinaReader },
get ENABLE_WEBSITE_FIRECRAWL() { return mockEnableFirecrawl },
get ENABLE_WEBSITE_WATERCRAWL() { return mockEnableWatercrawl },
}))
const createMockCrawlOptions = (overrides: Partial<CrawlOptions> = {}): CrawlOptions => ({
crawl_sub_pages: true,
limit: 10,
max_depth: 2,
excludes: '',
includes: '',
only_main_content: false,
use_sitemap: false,
...overrides,
})
const createMockDataSourceAuth = (
provider: string,
credentialsCount = 1,
): DataSourceAuth => ({
author: 'test',
provider,
plugin_id: `${provider}-plugin`,
plugin_unique_identifier: `${provider}-unique`,
icon: 'icon.png',
name: provider,
label: { en_US: provider, zh_Hans: provider },
description: { en_US: `${provider} description`, zh_Hans: `${provider} description` },
credentials_list: Array.from({ length: credentialsCount }, (_, i) => ({
credential: {},
type: CredentialTypeEnum.API_KEY,
name: `cred-${i}`,
id: `cred-${i}`,
is_default: i === 0,
avatar_url: '',
})),
})
type RenderProps = {
authedDataSourceList?: DataSourceAuth[]
enableJina?: boolean
enableFirecrawl?: boolean
enableWatercrawl?: boolean
}
const renderWebsite = ({
authedDataSourceList = [],
enableJina = true,
enableFirecrawl = true,
enableWatercrawl = true,
}: RenderProps = {}) => {
mockEnableJinaReader = enableJina
mockEnableFirecrawl = enableFirecrawl
mockEnableWatercrawl = enableWatercrawl
const props = {
onPreview: vi.fn() as (payload: CrawlResultItem) => void,
checkedCrawlResult: [] as CrawlResultItem[],
onCheckedCrawlResultChange: vi.fn() as (payload: CrawlResultItem[]) => void,
onCrawlProviderChange: vi.fn(),
onJobIdChange: vi.fn(),
crawlOptions: createMockCrawlOptions(),
onCrawlOptionsChange: vi.fn() as (payload: CrawlOptions) => void,
authedDataSourceList,
}
const result = render(<Website {...props} />)
return { ...result, props }
}
describe('Website', () => {
beforeEach(() => {
vi.clearAllMocks()
mockEnableJinaReader = true
mockEnableFirecrawl = true
mockEnableWatercrawl = true
})
describe('Rendering', () => {
it('should render provider selection section', () => {
renderWebsite()
expect(screen.getByText(/chooseProvider/i)).toBeInTheDocument()
})
it('should show Jina Reader button when ENABLE_WEBSITE_JINAREADER is true', () => {
renderWebsite({ enableJina: true })
expect(screen.getByText('Jina Reader')).toBeInTheDocument()
})
it('should not show Jina Reader button when ENABLE_WEBSITE_JINAREADER is false', () => {
renderWebsite({ enableJina: false })
expect(screen.queryByText('Jina Reader')).not.toBeInTheDocument()
})
it('should show Firecrawl button when ENABLE_WEBSITE_FIRECRAWL is true', () => {
renderWebsite({ enableFirecrawl: true })
expect(screen.getByText(/Firecrawl/)).toBeInTheDocument()
})
it('should not show Firecrawl button when ENABLE_WEBSITE_FIRECRAWL is false', () => {
renderWebsite({ enableFirecrawl: false })
expect(screen.queryByText(/Firecrawl/)).not.toBeInTheDocument()
})
it('should show WaterCrawl button when ENABLE_WEBSITE_WATERCRAWL is true', () => {
renderWebsite({ enableWatercrawl: true })
expect(screen.getByText('WaterCrawl')).toBeInTheDocument()
})
it('should not show WaterCrawl button when ENABLE_WEBSITE_WATERCRAWL is false', () => {
renderWebsite({ enableWatercrawl: false })
expect(screen.queryByText('WaterCrawl')).not.toBeInTheDocument()
})
})
describe('Provider Selection', () => {
it('should select Jina Reader by default', () => {
const authedDataSourceList = [createMockDataSourceAuth('jinareader')]
renderWebsite({ authedDataSourceList })
expect(screen.getByTestId('jina-reader-component')).toBeInTheDocument()
})
it('should switch to Firecrawl when Firecrawl button clicked', () => {
const authedDataSourceList = [
createMockDataSourceAuth('jinareader'),
createMockDataSourceAuth('firecrawl'),
]
renderWebsite({ authedDataSourceList })
const firecrawlButton = screen.getByText(/Firecrawl/)
fireEvent.click(firecrawlButton)
expect(screen.getByTestId('firecrawl-component')).toBeInTheDocument()
expect(screen.queryByTestId('jina-reader-component')).not.toBeInTheDocument()
})
it('should switch to WaterCrawl when WaterCrawl button clicked', () => {
const authedDataSourceList = [
createMockDataSourceAuth('jinareader'),
createMockDataSourceAuth('watercrawl'),
]
renderWebsite({ authedDataSourceList })
const watercrawlButton = screen.getByText('WaterCrawl')
fireEvent.click(watercrawlButton)
expect(screen.getByTestId('watercrawl-component')).toBeInTheDocument()
expect(screen.queryByTestId('jina-reader-component')).not.toBeInTheDocument()
})
it('should call onCrawlProviderChange when provider switched', () => {
const authedDataSourceList = [
createMockDataSourceAuth('jinareader'),
createMockDataSourceAuth('firecrawl'),
]
const { props } = renderWebsite({ authedDataSourceList })
const firecrawlButton = screen.getByText(/Firecrawl/)
fireEvent.click(firecrawlButton)
expect(props.onCrawlProviderChange).toHaveBeenCalledWith('firecrawl')
})
})
describe('Provider Content', () => {
it('should show JinaReader component when selected and available', () => {
const authedDataSourceList = [createMockDataSourceAuth('jinareader')]
renderWebsite({ authedDataSourceList })
expect(screen.getByTestId('jina-reader-component')).toBeInTheDocument()
})
it('should show Firecrawl component when selected and available', () => {
const authedDataSourceList = [
createMockDataSourceAuth('jinareader'),
createMockDataSourceAuth('firecrawl'),
]
renderWebsite({ authedDataSourceList })
const firecrawlButton = screen.getByText(/Firecrawl/)
fireEvent.click(firecrawlButton)
expect(screen.getByTestId('firecrawl-component')).toBeInTheDocument()
})
it('should show NoData when selected provider has no credentials', () => {
const authedDataSourceList = [createMockDataSourceAuth('jinareader', 0)]
renderWebsite({ authedDataSourceList })
expect(screen.getByTestId('no-data-component')).toBeInTheDocument()
})
it('should show NoData when no data source available for selected provider', () => {
renderWebsite({ authedDataSourceList: [] })
expect(screen.getByTestId('no-data-component')).toBeInTheDocument()
})
})
describe('NoData Config', () => {
it('should call setShowAccountSettingModal when NoData onConfig is triggered', () => {
renderWebsite({ authedDataSourceList: [] })
const configButton = screen.getByTestId('no-data-config-button')
fireEvent.click(configButton)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'data-source',
})
})
})
describe('Edge Cases', () => {
it('should handle no providers enabled', () => {
renderWebsite({
enableJina: false,
enableFirecrawl: false,
enableWatercrawl: false,
})
expect(screen.queryByText('Jina Reader')).not.toBeInTheDocument()
expect(screen.queryByText(/Firecrawl/)).not.toBeInTheDocument()
expect(screen.queryByText('WaterCrawl')).not.toBeInTheDocument()
})
it('should handle only one provider enabled', () => {
renderWebsite({
enableJina: true,
enableFirecrawl: false,
enableWatercrawl: false,
})
expect(screen.getByText('Jina Reader')).toBeInTheDocument()
expect(screen.queryByText(/Firecrawl/)).not.toBeInTheDocument()
expect(screen.queryByText('WaterCrawl')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,212 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ============================================================================
// Component Imports (after mocks)
// ============================================================================
import UrlInput from './url-input'
// ============================================================================
// Mock Setup
// ============================================================================
vi.mock('@/context/i18n', () => ({
useDocLink: vi.fn(() => () => 'https://docs.example.com'),
}))
// ============================================================================
// Jina Reader UrlInput Component Tests
// ============================================================================
describe('UrlInput (jina-reader)', () => {
const mockOnRun = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render input and run button', () => {
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render input with placeholder from docLink', () => {
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
expect(input).toHaveAttribute('placeholder', 'https://docs.example.com')
})
it('should show run text when not running', () => {
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const button = screen.getByRole('button')
expect(button).toHaveTextContent(/run/i)
})
it('should hide run text when running', () => {
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
const button = screen.getByRole('button')
expect(button).not.toHaveTextContent(/run/i)
})
it('should show loading state on button when running', () => {
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
const button = screen.getByRole('button')
expect(button).toHaveTextContent(/loading/i)
})
it('should not show loading state on button when not running', () => {
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const button = screen.getByRole('button')
expect(button).not.toHaveTextContent(/loading/i)
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should update url when user types in input', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
await user.type(input, 'https://example.com')
expect(input).toHaveValue('https://example.com')
})
it('should call onRun with url when run button clicked and not running', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
await user.type(input, 'https://example.com')
const button = screen.getByRole('button')
await user.click(button)
expect(mockOnRun).toHaveBeenCalledWith('https://example.com')
expect(mockOnRun).toHaveBeenCalledTimes(1)
})
it('should NOT call onRun when isRunning is true', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'https://example.com' } })
const button = screen.getByRole('button')
await user.click(button)
expect(mockOnRun).not.toHaveBeenCalled()
})
it('should call onRun with empty string when button clicked with empty input', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const button = screen.getByRole('button')
await user.click(button)
expect(mockOnRun).toHaveBeenCalledWith('')
})
})
// --------------------------------------------------------------------------
// Props Variations Tests
// --------------------------------------------------------------------------
describe('Props Variations', () => {
it('should update button state when isRunning changes from false to true', () => {
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
expect(screen.getByRole('button')).toHaveTextContent(/run/i)
rerender(<UrlInput isRunning={true} onRun={mockOnRun} />)
expect(screen.getByRole('button')).not.toHaveTextContent(/run/i)
})
it('should preserve input value when isRunning prop changes', async () => {
const user = userEvent.setup()
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
await user.type(input, 'https://preserved.com')
expect(input).toHaveValue('https://preserved.com')
rerender(<UrlInput isRunning={true} onRun={mockOnRun} />)
expect(input).toHaveValue('https://preserved.com')
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle special characters in url', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
const specialUrl = 'https://example.com/path?query=test&param=value#anchor'
await user.type(input, specialUrl)
const button = screen.getByRole('button')
await user.click(button)
expect(mockOnRun).toHaveBeenCalledWith(specialUrl)
})
it('should handle rapid input changes', () => {
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'a' } })
fireEvent.change(input, { target: { value: 'ab' } })
fireEvent.change(input, { target: { value: 'https://final.com' } })
expect(input).toHaveValue('https://final.com')
fireEvent.click(screen.getByRole('button'))
expect(mockOnRun).toHaveBeenCalledWith('https://final.com')
})
})
// --------------------------------------------------------------------------
// Integration Tests
// --------------------------------------------------------------------------
describe('Integration', () => {
it('should complete full workflow: type url -> click run -> verify callback', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
await user.type(input, 'https://mywebsite.com')
const button = screen.getByRole('button')
await user.click(button)
expect(mockOnRun).toHaveBeenCalledWith('https://mywebsite.com')
})
it('should show correct states during running workflow', () => {
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
expect(screen.getByRole('button')).toHaveTextContent(/run/i)
rerender(<UrlInput isRunning={true} onRun={mockOnRun} />)
expect(screen.getByRole('button')).not.toHaveTextContent(/run/i)
rerender(<UrlInput isRunning={false} onRun={mockOnRun} />)
expect(screen.getByRole('button')).toHaveTextContent(/run/i)
})
})
})

View File

@ -0,0 +1,209 @@
import type { CrawlOptions } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Options from './options'
// ============================================================================
// Test Data Factory
// ============================================================================
const createMockCrawlOptions = (overrides: Partial<CrawlOptions> = {}): CrawlOptions => ({
crawl_sub_pages: true,
limit: 10,
max_depth: 2,
excludes: '',
includes: '',
only_main_content: false,
use_sitemap: false,
...overrides,
})
// ============================================================================
// Jina Reader Options Component Tests
// ============================================================================
describe('Options (jina-reader)', () => {
const mockOnChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
const getCheckboxes = (container: HTMLElement) => {
return container.querySelectorAll('[data-testid^="checkbox-"]')
}
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render crawlSubPage and useSitemap checkboxes and limit field', () => {
const payload = createMockCrawlOptions()
render(<Options payload={payload} onChange={mockOnChange} />)
expect(screen.getByText(/crawlSubPage/i)).toBeInTheDocument()
expect(screen.getByText(/useSitemap/i)).toBeInTheDocument()
expect(screen.getByText(/limit/i)).toBeInTheDocument()
})
it('should render two checkboxes', () => {
const payload = createMockCrawlOptions()
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
expect(checkboxes.length).toBe(2)
})
it('should render limit field with required indicator', () => {
const payload = createMockCrawlOptions()
render(<Options payload={payload} onChange={mockOnChange} />)
const requiredIndicator = screen.getByText('*')
expect(requiredIndicator).toBeInTheDocument()
})
it('should render with custom className', () => {
const payload = createMockCrawlOptions()
const { container } = render(
<Options payload={payload} onChange={mockOnChange} className="custom-class" />,
)
const rootElement = container.firstChild as HTMLElement
expect(rootElement).toHaveClass('custom-class')
})
})
// --------------------------------------------------------------------------
// Props Display Tests
// --------------------------------------------------------------------------
describe('Props Display', () => {
it('should display crawl_sub_pages checkbox with check icon when true', () => {
const payload = createMockCrawlOptions({ crawl_sub_pages: true })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
expect(checkboxes[0].querySelector('svg')).toBeInTheDocument()
})
it('should display crawl_sub_pages checkbox without check icon when false', () => {
const payload = createMockCrawlOptions({ crawl_sub_pages: false })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
expect(checkboxes[0].querySelector('svg')).not.toBeInTheDocument()
})
it('should display use_sitemap checkbox with check icon when true', () => {
const payload = createMockCrawlOptions({ use_sitemap: true })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
expect(checkboxes[1].querySelector('svg')).toBeInTheDocument()
})
it('should display use_sitemap checkbox without check icon when false', () => {
const payload = createMockCrawlOptions({ use_sitemap: false })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
expect(checkboxes[1].querySelector('svg')).not.toBeInTheDocument()
})
it('should display limit value in input', () => {
const payload = createMockCrawlOptions({ limit: 25 })
render(<Options payload={payload} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('25')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onChange with updated crawl_sub_pages when checkbox is clicked', () => {
const payload = createMockCrawlOptions({ crawl_sub_pages: true })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
fireEvent.click(checkboxes[0])
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
crawl_sub_pages: false,
})
})
it('should call onChange with updated use_sitemap when checkbox is clicked', () => {
const payload = createMockCrawlOptions({ use_sitemap: false })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
fireEvent.click(checkboxes[1])
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
use_sitemap: true,
})
})
it('should call onChange with updated limit when input changes', () => {
const payload = createMockCrawlOptions({ limit: 10 })
render(<Options payload={payload} onChange={mockOnChange} />)
const limitInput = screen.getByDisplayValue('10')
fireEvent.change(limitInput, { target: { value: '50' } })
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
limit: 50,
})
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle zero limit value', () => {
const payload = createMockCrawlOptions({ limit: 0 })
render(<Options payload={payload} onChange={mockOnChange} />)
const zeroInputs = screen.getAllByDisplayValue('0')
expect(zeroInputs.length).toBeGreaterThanOrEqual(1)
})
it('should preserve other payload fields when updating one field', () => {
const payload = createMockCrawlOptions({
crawl_sub_pages: true,
limit: 10,
use_sitemap: true,
})
render(<Options payload={payload} onChange={mockOnChange} />)
const limitInput = screen.getByDisplayValue('10')
fireEvent.change(limitInput, { target: { value: '20' } })
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
limit: 20,
})
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should re-render when payload changes', () => {
const payload1 = createMockCrawlOptions({ limit: 10 })
const payload2 = createMockCrawlOptions({ limit: 20 })
const { rerender } = render(<Options payload={payload1} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('10')).toBeInTheDocument()
rerender(<Options payload={payload2} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('20')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,294 @@
import type { CrawlOptions } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Options from './options'
// ============================================================================
// Test Data Factory
// ============================================================================
const createMockCrawlOptions = (overrides: Partial<CrawlOptions> = {}): CrawlOptions => ({
crawl_sub_pages: true,
limit: 10,
max_depth: 2,
excludes: '',
includes: '',
only_main_content: false,
use_sitemap: false,
...overrides,
})
// ============================================================================
// WaterCrawl Options Component Tests
// ============================================================================
describe('Options (watercrawl)', () => {
const mockOnChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
const getCheckboxes = (container: HTMLElement) => {
return container.querySelectorAll('[data-testid^="checkbox-"]')
}
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render all form fields', () => {
const payload = createMockCrawlOptions()
render(<Options payload={payload} onChange={mockOnChange} />)
expect(screen.getByText(/crawlSubPage/i)).toBeInTheDocument()
expect(screen.getByText(/extractOnlyMainContent/i)).toBeInTheDocument()
expect(screen.getByText(/limit/i)).toBeInTheDocument()
expect(screen.getByText(/maxDepth/i)).toBeInTheDocument()
expect(screen.getByText(/excludePaths/i)).toBeInTheDocument()
expect(screen.getByText(/includeOnlyPaths/i)).toBeInTheDocument()
})
it('should render two checkboxes', () => {
const payload = createMockCrawlOptions()
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
expect(checkboxes.length).toBe(2)
})
it('should render limit field with required indicator', () => {
const payload = createMockCrawlOptions()
render(<Options payload={payload} onChange={mockOnChange} />)
const requiredIndicator = screen.getByText('*')
expect(requiredIndicator).toBeInTheDocument()
})
it('should render placeholder for excludes field', () => {
const payload = createMockCrawlOptions()
render(<Options payload={payload} onChange={mockOnChange} />)
expect(screen.getByPlaceholderText('blog/*, /about/*')).toBeInTheDocument()
})
it('should render placeholder for includes field', () => {
const payload = createMockCrawlOptions()
render(<Options payload={payload} onChange={mockOnChange} />)
expect(screen.getByPlaceholderText('articles/*')).toBeInTheDocument()
})
it('should render with custom className', () => {
const payload = createMockCrawlOptions()
const { container } = render(
<Options payload={payload} onChange={mockOnChange} className="custom-class" />,
)
const rootElement = container.firstChild as HTMLElement
expect(rootElement).toHaveClass('custom-class')
})
})
// --------------------------------------------------------------------------
// Props Display Tests
// --------------------------------------------------------------------------
describe('Props Display', () => {
it('should display crawl_sub_pages checkbox with check icon when true', () => {
const payload = createMockCrawlOptions({ crawl_sub_pages: true })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
expect(checkboxes[0].querySelector('svg')).toBeInTheDocument()
})
it('should display crawl_sub_pages checkbox without check icon when false', () => {
const payload = createMockCrawlOptions({ crawl_sub_pages: false })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
expect(checkboxes[0].querySelector('svg')).not.toBeInTheDocument()
})
it('should display only_main_content checkbox with check icon when true', () => {
const payload = createMockCrawlOptions({ only_main_content: true })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
expect(checkboxes[1].querySelector('svg')).toBeInTheDocument()
})
it('should display only_main_content checkbox without check icon when false', () => {
const payload = createMockCrawlOptions({ only_main_content: false })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
expect(checkboxes[1].querySelector('svg')).not.toBeInTheDocument()
})
it('should display limit value in input', () => {
const payload = createMockCrawlOptions({ limit: 25 })
render(<Options payload={payload} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('25')).toBeInTheDocument()
})
it('should display max_depth value in input', () => {
const payload = createMockCrawlOptions({ max_depth: 5 })
render(<Options payload={payload} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('5')).toBeInTheDocument()
})
it('should display excludes value in input', () => {
const payload = createMockCrawlOptions({ excludes: 'test/*' })
render(<Options payload={payload} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('test/*')).toBeInTheDocument()
})
it('should display includes value in input', () => {
const payload = createMockCrawlOptions({ includes: 'docs/*' })
render(<Options payload={payload} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('docs/*')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onChange with updated crawl_sub_pages when checkbox is clicked', () => {
const payload = createMockCrawlOptions({ crawl_sub_pages: true })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
fireEvent.click(checkboxes[0])
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
crawl_sub_pages: false,
})
})
it('should call onChange with updated only_main_content when checkbox is clicked', () => {
const payload = createMockCrawlOptions({ only_main_content: false })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
fireEvent.click(checkboxes[1])
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
only_main_content: true,
})
})
it('should call onChange with updated limit when input changes', () => {
const payload = createMockCrawlOptions({ limit: 10 })
render(<Options payload={payload} onChange={mockOnChange} />)
const limitInput = screen.getByDisplayValue('10')
fireEvent.change(limitInput, { target: { value: '50' } })
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
limit: 50,
})
})
it('should call onChange with updated max_depth when input changes', () => {
const payload = createMockCrawlOptions({ max_depth: 2 })
render(<Options payload={payload} onChange={mockOnChange} />)
const maxDepthInput = screen.getByDisplayValue('2')
fireEvent.change(maxDepthInput, { target: { value: '10' } })
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
max_depth: 10,
})
})
it('should call onChange with updated excludes when input changes', () => {
const payload = createMockCrawlOptions({ excludes: '' })
render(<Options payload={payload} onChange={mockOnChange} />)
const excludesInput = screen.getByPlaceholderText('blog/*, /about/*')
fireEvent.change(excludesInput, { target: { value: 'admin/*' } })
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
excludes: 'admin/*',
})
})
it('should call onChange with updated includes when input changes', () => {
const payload = createMockCrawlOptions({ includes: '' })
render(<Options payload={payload} onChange={mockOnChange} />)
const includesInput = screen.getByPlaceholderText('articles/*')
fireEvent.change(includesInput, { target: { value: 'public/*' } })
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
includes: 'public/*',
})
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should preserve other payload fields when updating one field', () => {
const payload = createMockCrawlOptions({
crawl_sub_pages: true,
limit: 10,
max_depth: 2,
excludes: 'test/*',
includes: 'docs/*',
only_main_content: true,
})
render(<Options payload={payload} onChange={mockOnChange} />)
const limitInput = screen.getByDisplayValue('10')
fireEvent.change(limitInput, { target: { value: '20' } })
expect(mockOnChange).toHaveBeenCalledWith({
crawl_sub_pages: true,
limit: 20,
max_depth: 2,
excludes: 'test/*',
includes: 'docs/*',
only_main_content: true,
use_sitemap: false,
})
})
it('should handle zero values', () => {
const payload = createMockCrawlOptions({ limit: 0, max_depth: 0 })
render(<Options payload={payload} onChange={mockOnChange} />)
const zeroInputs = screen.getAllByDisplayValue('0')
expect(zeroInputs.length).toBeGreaterThanOrEqual(1)
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should re-render when payload changes', () => {
const payload1 = createMockCrawlOptions({ limit: 10 })
const payload2 = createMockCrawlOptions({ limit: 20 })
const { rerender } = render(<Options payload={payload1} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('10')).toBeInTheDocument()
rerender(<Options payload={payload2} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('20')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,141 @@
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import { renderHook } from '@testing-library/react'
import { useDatasourceIcon } from './hooks'
const mockTransformDataSourceToTool = vi.fn()
vi.mock('@/app/components/workflow/block-selector/utils', () => ({
transformDataSourceToTool: (...args: unknown[]) => mockTransformDataSourceToTool(...args),
}))
let mockDataSourceListReturn: {
data: Array<{
plugin_id: string
provider: string
declaration: { identity: { icon: string, author: string } }
}> | undefined
isSuccess: boolean
}
vi.mock('@/service/use-pipeline', () => ({
useDataSourceList: () => mockDataSourceListReturn,
}))
vi.mock('@/utils/var', () => ({
basePath: '',
}))
const createMockDataSourceNode = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({
plugin_id: 'plugin-abc',
provider_type: 'builtin',
provider_name: 'web-scraper',
datasource_name: 'scraper',
datasource_label: 'Web Scraper',
datasource_parameters: {},
datasource_configurations: {},
title: 'DataSource',
desc: '',
type: '' as DataSourceNodeType['type'],
...overrides,
} as DataSourceNodeType)
describe('useDatasourceIcon', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDataSourceListReturn = { data: undefined, isSuccess: false }
mockTransformDataSourceToTool.mockReset()
})
// Returns undefined when data has not loaded
describe('Loading State', () => {
it('should return undefined when data is not loaded (isSuccess false)', () => {
mockDataSourceListReturn = { data: undefined, isSuccess: false }
const { result } = renderHook(() =>
useDatasourceIcon(createMockDataSourceNode()),
)
expect(result.current).toBeUndefined()
})
})
// Returns correct icon when plugin_id matches
describe('Icon Resolution', () => {
it('should return correct icon when plugin_id matches', () => {
const mockIcon = 'https://example.com/icon.svg'
mockDataSourceListReturn = {
data: [
{
plugin_id: 'plugin-abc',
provider: 'web-scraper',
declaration: { identity: { icon: mockIcon, author: 'dify' } },
},
],
isSuccess: true,
}
mockTransformDataSourceToTool.mockImplementation((item: { plugin_id: string, declaration: { identity: { icon: string } } }) => ({
plugin_id: item.plugin_id,
icon: item.declaration.identity.icon,
}))
const { result } = renderHook(() =>
useDatasourceIcon(createMockDataSourceNode({ plugin_id: 'plugin-abc' })),
)
expect(result.current).toBe(mockIcon)
})
it('should return undefined when plugin_id does not match', () => {
mockDataSourceListReturn = {
data: [
{
plugin_id: 'plugin-xyz',
provider: 'other',
declaration: { identity: { icon: '/icon.svg', author: 'dify' } },
},
],
isSuccess: true,
}
mockTransformDataSourceToTool.mockImplementation((item: { plugin_id: string, declaration: { identity: { icon: string } } }) => ({
plugin_id: item.plugin_id,
icon: item.declaration.identity.icon,
}))
const { result } = renderHook(() =>
useDatasourceIcon(createMockDataSourceNode({ plugin_id: 'plugin-abc' })),
)
expect(result.current).toBeUndefined()
})
})
// basePath prepending
describe('basePath Prepending', () => {
it('should prepend basePath to icon URL when not already included', () => {
// basePath is mocked as '' so prepending '' to '/icon.png' results in '/icon.png'
// The important thing is that the forEach logic runs without error
mockDataSourceListReturn = {
data: [
{
plugin_id: 'plugin-abc',
provider: 'web-scraper',
declaration: { identity: { icon: '/icon.png', author: 'dify' } },
},
],
isSuccess: true,
}
mockTransformDataSourceToTool.mockImplementation((item: { plugin_id: string, declaration: { identity: { icon: string } } }) => ({
plugin_id: item.plugin_id,
icon: item.declaration.identity.icon,
}))
const { result } = renderHook(() =>
useDatasourceIcon(createMockDataSourceNode({ plugin_id: 'plugin-abc' })),
)
// With empty basePath, icon stays as '/icon.png'
expect(result.current).toBe('/icon.png')
})
})
})

View File

@ -0,0 +1,110 @@
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import OptionCard from './option-card'
const TEST_ICON_URL = 'https://example.com/test-icon.png'
vi.mock('./hooks', () => ({
useDatasourceIcon: () => TEST_ICON_URL,
}))
vi.mock('./datasource-icon', () => ({
default: ({ iconUrl }: { iconUrl: string }) => (
<img data-testid="datasource-icon" src={iconUrl} alt="datasource" />
),
}))
const createMockNodeData = (overrides: Partial<DataSourceNodeType> = {}): DataSourceNodeType => ({
title: 'Test Node',
desc: '',
type: {} as DataSourceNodeType['type'],
plugin_id: 'test-plugin',
provider_type: 'builtin',
provider_name: 'test-provider',
datasource_name: 'test-ds',
datasource_label: 'Test DS',
datasource_parameters: {},
datasource_configurations: {},
...overrides,
} as DataSourceNodeType)
describe('OptionCard', () => {
const defaultProps = {
label: 'Google Drive',
selected: false,
nodeData: createMockNodeData(),
onClick: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering: label text and icon
describe('Rendering', () => {
it('should render label text', () => {
render(<OptionCard {...defaultProps} />)
expect(screen.getByText('Google Drive')).toBeInTheDocument()
})
it('should render datasource icon with correct URL', () => {
render(<OptionCard {...defaultProps} />)
const icon = screen.getByTestId('datasource-icon')
expect(icon).toHaveAttribute('src', TEST_ICON_URL)
})
it('should set title attribute on label element', () => {
render(<OptionCard {...defaultProps} />)
expect(screen.getByTitle('Google Drive')).toBeInTheDocument()
})
})
// User interactions: clicking the card
describe('User Interactions', () => {
it('should call onClick when clicked', () => {
render(<OptionCard {...defaultProps} />)
fireEvent.click(screen.getByText('Google Drive'))
expect(defaultProps.onClick).toHaveBeenCalledOnce()
})
it('should not throw when onClick is undefined', () => {
expect(() => {
const { container } = render(
<OptionCard {...defaultProps} onClick={undefined} />,
)
fireEvent.click(container.firstElementChild!)
}).not.toThrow()
})
})
// Props: selected state applies different styles
describe('Props', () => {
it('should apply selected styles when selected is true', () => {
const { container } = render(<OptionCard {...defaultProps} selected />)
const card = container.firstElementChild
expect(card?.className).toContain('border-components-option-card-option-selected-border')
expect(card?.className).toContain('bg-components-option-card-option-selected-bg')
})
it('should apply default styles when selected is false', () => {
const { container } = render(<OptionCard {...defaultProps} selected={false} />)
const card = container.firstElementChild
expect(card?.className).not.toContain('border-components-option-card-option-selected-border')
})
it('should apply text-text-primary class to label when selected', () => {
render(<OptionCard {...defaultProps} selected />)
const labelEl = screen.getByTitle('Google Drive')
expect(labelEl.className).toContain('text-text-primary')
})
})
})

View File

@ -0,0 +1,61 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Drive from './drive'
describe('Drive', () => {
const defaultProps = {
breadcrumbs: [] as string[],
handleBackToRoot: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering: button text and separator visibility
describe('Rendering', () => {
it('should render "All Files" button text', () => {
render(<Drive {...defaultProps} />)
expect(screen.getByRole('button')).toHaveTextContent('datasetPipeline.onlineDrive.breadcrumbs.allFiles')
})
it('should show separator "/" when breadcrumbs has items', () => {
render(<Drive {...defaultProps} breadcrumbs={['Folder A']} />)
expect(screen.getByText('/')).toBeInTheDocument()
})
it('should hide separator when breadcrumbs is empty', () => {
render(<Drive {...defaultProps} breadcrumbs={[]} />)
expect(screen.queryByText('/')).not.toBeInTheDocument()
})
})
// Props: disabled state depends on breadcrumbs length
describe('Props', () => {
it('should disable button when breadcrumbs is empty', () => {
render(<Drive {...defaultProps} breadcrumbs={[]} />)
expect(screen.getByRole('button')).toBeDisabled()
})
it('should enable button when breadcrumbs has items', () => {
render(<Drive {...defaultProps} breadcrumbs={['Folder A', 'Folder B']} />)
expect(screen.getByRole('button')).not.toBeDisabled()
})
})
// User interactions: clicking the root button
describe('User Interactions', () => {
it('should call handleBackToRoot on click when enabled', () => {
render(<Drive {...defaultProps} breadcrumbs={['Folder A']} />)
fireEvent.click(screen.getByRole('button'))
expect(defaultProps.handleBackToRoot).toHaveBeenCalledOnce()
})
})
})

View File

@ -0,0 +1,44 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Item from './item'
describe('Item', () => {
const defaultProps = {
name: 'Documents',
index: 2,
onBreadcrumbClick: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering: verify the breadcrumb name is displayed
describe('Rendering', () => {
it('should render breadcrumb name', () => {
render(<Item {...defaultProps} />)
expect(screen.getByText('Documents')).toBeInTheDocument()
})
})
// User interactions: clicking triggers callback with correct index
describe('User Interactions', () => {
it('should call onBreadcrumbClick with correct index on click', () => {
render(<Item {...defaultProps} />)
fireEvent.click(screen.getByText('Documents'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledOnce()
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(2)
})
it('should pass different index values correctly', () => {
render(<Item {...defaultProps} index={5} />)
fireEvent.click(screen.getByText('Documents'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(5)
})
})
})

View File

@ -0,0 +1,79 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Menu from './menu'
describe('Menu', () => {
const defaultProps = {
breadcrumbs: ['Folder A', 'Folder B', 'Folder C'],
startIndex: 1,
onBreadcrumbClick: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering: verify all breadcrumb items are displayed
describe('Rendering', () => {
it('should render all breadcrumb items', () => {
render(<Menu {...defaultProps} />)
expect(screen.getByText('Folder A')).toBeInTheDocument()
expect(screen.getByText('Folder B')).toBeInTheDocument()
expect(screen.getByText('Folder C')).toBeInTheDocument()
})
it('should render empty list when no breadcrumbs provided', () => {
const { container } = render(
<Menu breadcrumbs={[]} startIndex={0} onBreadcrumbClick={vi.fn()} />,
)
const menuContainer = container.firstElementChild
expect(menuContainer?.children).toHaveLength(0)
})
})
// Index mapping: startIndex offsets are applied correctly
describe('Index Mapping', () => {
it('should pass correct index (startIndex + offset) to each item', () => {
render(<Menu {...defaultProps} />)
fireEvent.click(screen.getByText('Folder A'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(1)
fireEvent.click(screen.getByText('Folder B'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(2)
fireEvent.click(screen.getByText('Folder C'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(3)
})
it('should offset from startIndex of zero', () => {
render(
<Menu
breadcrumbs={['First', 'Second']}
startIndex={0}
onBreadcrumbClick={defaultProps.onBreadcrumbClick}
/>,
)
fireEvent.click(screen.getByText('First'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(0)
fireEvent.click(screen.getByText('Second'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(1)
})
})
// User interactions: clicking items triggers the callback
describe('User Interactions', () => {
it('should call onBreadcrumbClick with correct index when item clicked', () => {
render(<Menu {...defaultProps} />)
fireEvent.click(screen.getByText('Folder B'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledOnce()
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(2)
})
})
})

View File

@ -0,0 +1,89 @@
import { render, screen } from '@testing-library/react'
import { useContext } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DataSourceProvider, { DataSourceContext } from './provider'
const mockStore = { getState: vi.fn(), setState: vi.fn(), subscribe: vi.fn() }
vi.mock('./', () => ({
createDataSourceStore: () => mockStore,
}))
// Test consumer component that reads from context
function ContextConsumer() {
const store = useContext(DataSourceContext)
return (
<div data-testid="context-value" data-has-store={store !== null}>
{store ? 'has-store' : 'no-store'}
</div>
)
}
describe('DataSourceProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering: verifies children are passed through
describe('Rendering', () => {
it('should render children', () => {
render(
<DataSourceProvider>
<span data-testid="child">Hello</span>
</DataSourceProvider>,
)
expect(screen.getByTestId('child')).toBeInTheDocument()
expect(screen.getByText('Hello')).toBeInTheDocument()
})
})
// Context: verifies the store is provided to consumers
describe('Context', () => {
it('should provide store value to context consumers', () => {
render(
<DataSourceProvider>
<ContextConsumer />
</DataSourceProvider>,
)
expect(screen.getByTestId('context-value')).toHaveTextContent('has-store')
expect(screen.getByTestId('context-value')).toHaveAttribute('data-has-store', 'true')
})
it('should provide null when no provider wraps the consumer', () => {
render(<ContextConsumer />)
expect(screen.getByTestId('context-value')).toHaveTextContent('no-store')
expect(screen.getByTestId('context-value')).toHaveAttribute('data-has-store', 'false')
})
})
// Stability: verifies the store reference is stable across re-renders
describe('Store Stability', () => {
it('should reuse same store on re-render (stable reference)', () => {
const storeValues: Array<typeof mockStore | null> = []
function StoreCapture() {
const store = useContext(DataSourceContext)
storeValues.push(store as typeof mockStore | null)
return null
}
const { rerender } = render(
<DataSourceProvider>
<StoreCapture />
</DataSourceProvider>,
)
rerender(
<DataSourceProvider>
<StoreCapture />
</DataSourceProvider>,
)
expect(storeValues).toHaveLength(2)
expect(storeValues[0]).toBe(storeValues[1])
})
})
})

View File

@ -0,0 +1,218 @@
import type { CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import CrawledResult from './crawled-result'
vi.mock('./checkbox-with-label', () => ({
default: ({ isChecked, onChange, label }: { isChecked: boolean, onChange: () => void, label: string }) => (
<label>
<input
type="checkbox"
checked={isChecked}
onChange={onChange}
data-testid="check-all-checkbox"
/>
{label}
</label>
),
}))
vi.mock('./crawled-result-item', () => ({
default: ({
payload,
isChecked,
onCheckChange,
onPreview,
}: {
payload: CrawlResultItem
isChecked: boolean
onCheckChange: (checked: boolean) => void
onPreview: () => void
}) => (
<div data-testid={`crawled-item-${payload.source_url}`}>
<span data-testid="item-url">{payload.source_url}</span>
<button data-testid={`check-${payload.source_url}`} onClick={() => onCheckChange(!isChecked)}>
{isChecked ? 'uncheck' : 'check'}
</button>
<button data-testid={`preview-${payload.source_url}`} onClick={onPreview}>
preview
</button>
</div>
),
}))
const createItem = (url: string): CrawlResultItem => ({
source_url: url,
title: `Title for ${url}`,
markdown: `# ${url}`,
description: `Desc for ${url}`,
})
const defaultList: CrawlResultItem[] = [
createItem('https://example.com/a'),
createItem('https://example.com/b'),
createItem('https://example.com/c'),
]
describe('CrawledResult', () => {
const defaultProps = {
list: defaultList,
checkedList: [] as CrawlResultItem[],
onSelectedChange: vi.fn(),
usedTime: 12.345,
}
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests
describe('Rendering', () => {
it('should render scrap time info with correct total and time', () => {
render(<CrawledResult {...defaultProps} />)
expect(
screen.getByText(/scrapTimeInfo/),
).toBeInTheDocument()
// The global i18n mock serialises params, so verify total and time appear
expect(screen.getByText(/"total":3/)).toBeInTheDocument()
expect(screen.getByText(/"time":"12.3"/)).toBeInTheDocument()
})
it('should render all items from list', () => {
render(<CrawledResult {...defaultProps} />)
for (const item of defaultList) {
expect(screen.getByTestId(`crawled-item-${item.source_url}`)).toBeInTheDocument()
}
})
it('should apply custom className', () => {
const { container } = render(
<CrawledResult {...defaultProps} className="my-custom-class" />,
)
expect(container.firstChild).toHaveClass('my-custom-class')
})
})
// Check-all checkbox visibility
describe('Check All Checkbox', () => {
it('should show check-all checkbox in multiple choice mode', () => {
render(<CrawledResult {...defaultProps} isMultipleChoice={true} />)
expect(screen.getByTestId('check-all-checkbox')).toBeInTheDocument()
})
it('should hide check-all checkbox in single choice mode', () => {
render(<CrawledResult {...defaultProps} isMultipleChoice={false} />)
expect(screen.queryByTestId('check-all-checkbox')).not.toBeInTheDocument()
})
})
// Toggle all items
describe('Toggle All', () => {
it('should select all when not all checked', () => {
const onSelectedChange = vi.fn()
render(
<CrawledResult
{...defaultProps}
checkedList={[defaultList[0]]}
onSelectedChange={onSelectedChange}
/>,
)
fireEvent.click(screen.getByTestId('check-all-checkbox'))
expect(onSelectedChange).toHaveBeenCalledWith(defaultList)
})
it('should deselect all when all checked', () => {
const onSelectedChange = vi.fn()
render(
<CrawledResult
{...defaultProps}
checkedList={[...defaultList]}
onSelectedChange={onSelectedChange}
/>,
)
fireEvent.click(screen.getByTestId('check-all-checkbox'))
expect(onSelectedChange).toHaveBeenCalledWith([])
})
})
// Individual item check
describe('Individual Item Check', () => {
it('should add item to selection in multiple choice mode', () => {
const onSelectedChange = vi.fn()
render(
<CrawledResult
{...defaultProps}
checkedList={[defaultList[0]]}
onSelectedChange={onSelectedChange}
isMultipleChoice={true}
/>,
)
// Click check on unchecked second item
fireEvent.click(screen.getByTestId(`check-${defaultList[1].source_url}`))
expect(onSelectedChange).toHaveBeenCalledWith([defaultList[0], defaultList[1]])
})
it('should replace selection in single choice mode', () => {
const onSelectedChange = vi.fn()
render(
<CrawledResult
{...defaultProps}
checkedList={[defaultList[0]]}
onSelectedChange={onSelectedChange}
isMultipleChoice={false}
/>,
)
// Click check on unchecked second item
fireEvent.click(screen.getByTestId(`check-${defaultList[1].source_url}`))
expect(onSelectedChange).toHaveBeenCalledWith([defaultList[1]])
})
it('should remove item from selection when unchecked', () => {
const onSelectedChange = vi.fn()
render(
<CrawledResult
{...defaultProps}
checkedList={[defaultList[0], defaultList[1]]}
onSelectedChange={onSelectedChange}
isMultipleChoice={true}
/>,
)
// Click uncheck on checked first item
fireEvent.click(screen.getByTestId(`check-${defaultList[0].source_url}`))
expect(onSelectedChange).toHaveBeenCalledWith([defaultList[1]])
})
})
// Preview
describe('Preview', () => {
it('should call onPreview with correct item and index', () => {
const onPreview = vi.fn()
render(
<CrawledResult
{...defaultProps}
onPreview={onPreview}
showPreview={true}
/>,
)
fireEvent.click(screen.getByTestId(`preview-${defaultList[1].source_url}`))
expect(onPreview).toHaveBeenCalledWith(defaultList[1], 1)
})
})
})

View File

@ -0,0 +1,115 @@
import type { Step } from './step-indicator'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import LeftHeader from './left-header'
vi.mock('next/navigation', () => ({
useParams: () => ({ datasetId: 'test-ds-id' }),
}))
vi.mock('next/link', () => ({
default: ({ children, href }: { children: React.ReactNode, href: string }) => (
<a href={href} data-testid="back-link">{children}</a>
),
}))
vi.mock('@remixicon/react', () => ({
RiArrowLeftLine: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="arrow-left-icon" {...props} />,
}))
vi.mock('./step-indicator', () => ({
default: ({ steps, currentStep }: { steps: Step[], currentStep: number }) => (
<div data-testid="step-indicator" data-steps={steps.length} data-current={currentStep} />
),
}))
vi.mock('@/app/components/base/effect', () => ({
default: ({ className }: { className?: string }) => (
<div data-testid="effect" className={className} />
),
}))
const createSteps = (): Step[] => [
{ label: 'Data Source', value: 'data-source' },
{ label: 'Processing', value: 'processing' },
{ label: 'Complete', value: 'complete' },
]
describe('LeftHeader', () => {
const steps = createSteps()
const defaultProps = {
steps,
title: 'Add Documents',
currentStep: 1,
}
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering: title, step label, and step indicator
describe('Rendering', () => {
it('should render title text', () => {
render(<LeftHeader {...defaultProps} />)
expect(screen.getByText('Add Documents')).toBeInTheDocument()
})
it('should render current step label (steps[currentStep-1].label)', () => {
render(<LeftHeader {...defaultProps} currentStep={2} />)
expect(screen.getByText('Processing')).toBeInTheDocument()
})
it('should render step indicator component', () => {
render(<LeftHeader {...defaultProps} />)
expect(screen.getByTestId('step-indicator')).toBeInTheDocument()
})
it('should render separator between title and step indicator', () => {
render(<LeftHeader {...defaultProps} />)
expect(screen.getByText('/')).toBeInTheDocument()
})
})
// Back button visibility depends on currentStep vs total steps
describe('Back Button', () => {
it('should show back button when currentStep !== steps.length', () => {
render(<LeftHeader {...defaultProps} currentStep={1} />)
expect(screen.getByTestId('back-link')).toBeInTheDocument()
expect(screen.getByTestId('arrow-left-icon')).toBeInTheDocument()
})
it('should hide back button when currentStep === steps.length', () => {
render(<LeftHeader {...defaultProps} currentStep={steps.length} />)
expect(screen.queryByTestId('back-link')).not.toBeInTheDocument()
})
it('should link to correct URL using datasetId from params', () => {
render(<LeftHeader {...defaultProps} currentStep={1} />)
const link = screen.getByTestId('back-link')
expect(link).toHaveAttribute('href', '/datasets/test-ds-id/documents')
})
})
// Edge case: step label for boundary values
describe('Edge Cases', () => {
it('should render first step label when currentStep is 1', () => {
render(<LeftHeader {...defaultProps} currentStep={1} />)
expect(screen.getByText('Data Source')).toBeInTheDocument()
})
it('should render last step label when currentStep equals steps.length', () => {
render(<LeftHeader {...defaultProps} currentStep={3} />)
expect(screen.getByText('Complete')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,73 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Actions from './actions'
vi.mock('@remixicon/react', () => ({
RiArrowLeftLine: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="arrow-left-icon" {...props} />,
}))
describe('Actions', () => {
const defaultProps = {
onBack: vi.fn(),
onProcess: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering: verify both action buttons render with correct labels
describe('Rendering', () => {
it('should render back button and process button', () => {
render(<Actions {...defaultProps} />)
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(2)
expect(screen.getByText('datasetPipeline.operations.dataSource')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.operations.saveAndProcess')).toBeInTheDocument()
})
})
// User interactions: clicking back and process buttons
describe('User Interactions', () => {
it('should call onBack when back button clicked', () => {
render(<Actions {...defaultProps} />)
fireEvent.click(screen.getByText('datasetPipeline.operations.dataSource'))
expect(defaultProps.onBack).toHaveBeenCalledOnce()
})
it('should call onProcess when process button clicked', () => {
render(<Actions {...defaultProps} />)
fireEvent.click(screen.getByText('datasetPipeline.operations.saveAndProcess'))
expect(defaultProps.onProcess).toHaveBeenCalledOnce()
})
})
// Props: disabled state for the process button
describe('Props', () => {
it('should disable process button when runDisabled is true', () => {
render(<Actions {...defaultProps} runDisabled />)
const processButton = screen.getByText('datasetPipeline.operations.saveAndProcess').closest('button')
expect(processButton).toBeDisabled()
})
it('should enable process button when runDisabled is false', () => {
render(<Actions {...defaultProps} runDisabled={false} />)
const processButton = screen.getByText('datasetPipeline.operations.saveAndProcess').closest('button')
expect(processButton).not.toBeDisabled()
})
it('should enable process button when runDisabled is undefined', () => {
render(<Actions {...defaultProps} />)
const processButton = screen.getByText('datasetPipeline.operations.saveAndProcess').closest('button')
expect(processButton).not.toBeDisabled()
})
})
})

View File

@ -0,0 +1,135 @@
import { render, screen } from '@testing-library/react'
import Drawer from './drawer'
// Capture the useKeyPress callback so tests can invoke it
let capturedKeyPressCallback: ((e: KeyboardEvent) => void) | undefined
vi.mock('ahooks', () => ({
useKeyPress: vi.fn((_key: string, cb: (e: KeyboardEvent) => void) => {
capturedKeyPressCallback = cb
}),
}))
vi.mock('..', () => ({
useSegmentListContext: (selector: (state: {
currSegment: { showModal: boolean }
currChildChunk: { showModal: boolean }
}) => unknown) =>
selector({
currSegment: { showModal: false },
currChildChunk: { showModal: false },
}),
}))
describe('Drawer', () => {
const defaultProps = {
open: true,
onClose: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
capturedKeyPressCallback = undefined
})
// Rendering tests
describe('Rendering', () => {
it('should return null when open is false', () => {
const { container } = render(
<Drawer open={false} onClose={vi.fn()}>
<span>Content</span>
</Drawer>,
)
expect(container.innerHTML).toBe('')
expect(screen.queryByText('Content')).not.toBeInTheDocument()
})
it('should render children in portal when open is true', () => {
render(
<Drawer {...defaultProps}>
<span>Drawer content</span>
</Drawer>,
)
expect(screen.getByText('Drawer content')).toBeInTheDocument()
})
it('should render dialog with role="dialog"', () => {
render(
<Drawer {...defaultProps}>
<span>Content</span>
</Drawer>,
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
// Overlay visibility
describe('Overlay', () => {
it('should show overlay when showOverlay is true', () => {
render(
<Drawer {...defaultProps} showOverlay={true}>
<span>Content</span>
</Drawer>,
)
const overlay = document.querySelector('[aria-hidden="true"]')
expect(overlay).toBeInTheDocument()
})
it('should hide overlay when showOverlay is false', () => {
render(
<Drawer {...defaultProps} showOverlay={false}>
<span>Content</span>
</Drawer>,
)
const overlay = document.querySelector('[aria-hidden="true"]')
expect(overlay).not.toBeInTheDocument()
})
})
// aria-modal attribute
describe('aria-modal', () => {
it('should set aria-modal="true" when modal is true', () => {
render(
<Drawer {...defaultProps} modal={true}>
<span>Content</span>
</Drawer>,
)
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true')
})
it('should set aria-modal="false" when modal is false', () => {
render(
<Drawer {...defaultProps} modal={false}>
<span>Content</span>
</Drawer>,
)
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'false')
})
})
// ESC key handling
describe('ESC Key', () => {
it('should call onClose when ESC is pressed and drawer is open', () => {
const onClose = vi.fn()
render(
<Drawer open={true} onClose={onClose}>
<span>Content</span>
</Drawer>,
)
expect(capturedKeyPressCallback).toBeDefined()
const fakeEvent = { preventDefault: vi.fn() } as unknown as KeyboardEvent
capturedKeyPressCallback!(fakeEvent)
expect(onClose).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,70 @@
import { renderHook } from '@testing-library/react'
import { useInputVariables } from './hooks'
let mockPipelineId: string | undefined
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset: { pipeline_id?: string } | null }) => unknown) =>
selector({ dataset: mockPipelineId ? { pipeline_id: mockPipelineId } : null }),
}))
let mockParamsReturn: {
data: Record<string, unknown> | undefined
isFetching: boolean
}
const mockUsePublishedPipelineProcessingParams = vi.fn(
(_params: { pipeline_id: string, node_id: string }) => mockParamsReturn,
)
vi.mock('@/service/use-pipeline', () => ({
usePublishedPipelineProcessingParams: (params: { pipeline_id: string, node_id: string }) =>
mockUsePublishedPipelineProcessingParams(params),
}))
describe('useInputVariables', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPipelineId = 'pipeline-123'
mockParamsReturn = {
data: undefined,
isFetching: false,
}
})
// Returns paramsConfig from API
describe('Data Retrieval', () => {
it('should return paramsConfig from API', () => {
const mockConfig = { variables: [{ name: 'var1', type: 'string' }] }
mockParamsReturn = { data: mockConfig, isFetching: false }
const { result } = renderHook(() => useInputVariables('node-456'))
expect(result.current.paramsConfig).toEqual(mockConfig)
})
it('should return isFetchingParams loading state', () => {
mockParamsReturn = { data: undefined, isFetching: true }
const { result } = renderHook(() => useInputVariables('node-456'))
expect(result.current.isFetchingParams).toBe(true)
})
})
// Passes correct parameters to API hook
describe('Parameter Passing', () => {
it('should pass correct pipeline_id and node_id to API hook', () => {
mockPipelineId = 'pipeline-789'
mockParamsReturn = { data: undefined, isFetching: false }
renderHook(() => useInputVariables('node-abc'))
expect(mockUsePublishedPipelineProcessingParams).toHaveBeenCalledWith({
pipeline_id: 'pipeline-789',
node_id: 'node-abc',
})
})
})
})